diff --git a/README.md b/README.md index 45cc0786..d9d40c0e 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ Production runtime substrate for domain agents. Owns the task lifecycle (knowledge readiness, control loop, session resume, sanitized telemetry, -durable runs across worker / DO crashes, canonical `RuntimeRunRow` -persistence + cost ledger), the chat-model catalog + admission, and the -declarative `defineAgent` manifest — so domain repos stop inventing their -own. +canonical `RuntimeRunRow` persistence + cost ledger), the chat-turn +engine (NDJSON envelope + product hooks), the chat-model catalog + +admission, and the declarative `defineAgent` manifest — so domain +repos stop inventing their own. Long-running execution durability +(reconnect, replay, dedup) lives in `@tangle-network/sandbox`. ```bash pnpm add @tangle-network/agent-runtime @tangle-network/agent-eval @@ -17,12 +18,9 @@ pnpm add @tangle-network/agent-runtime @tangle-network/agent-eval |---|---| | `runAgentTask` | Single-shot adapter-driven task with eval/verification | | `runAgentTaskStream` | Streaming product loop with session resume + backends | -| `runDurableTurn` | Checkpoint+replay chat turn — survives a worker crash *after* completion | -| `runSupervisedTurn` | Always-attached durable turn — re-attaches an in-flight sandbox run *during* a crash | -| `SessionSupervisorDO` | Cloudflare Durable Object host for `runSupervisedTurn` (with alarm-driven orphan re-attach) | -| `DurableChatTurnEngine` | Framework-neutral chat-turn orchestrator (durable turn + NDJSON + session lifecycle + product hooks) | +| `handleChatTurn` | Framework-neutral chat-turn orchestrator (NDJSON + `session.run.*` envelope + product hooks) | +| `deriveExecutionId` | Stable substrate executionId for `X-Execution-ID` cross-process reconnect | | `startRuntimeRun` | Canonical production-run row + cost ledger | -| `runDurable` + `*DurableRunStore` | General durable-step substrate (in-memory / file-system / D1) | | `defineAgent` | Declarative per-vertical agent manifest — surfaces, knowledge, rubric, run fn | | `resolveChatModel` / `validateChatModelId` / `getModels` | Router catalog fetch + fail-closed admission + precedence resolver | | `createTraceBridge` | Map `RuntimeStreamEvent` → `agent-eval` `TraceEvent` | @@ -53,65 +51,54 @@ const result = await runAgentTask({ console.log(result.status, result.runRecords) ``` -## Durable chat turns +## Chat turns -A 15-minute agentic turn must survive a Cloudflare worker isolate dying. -`runDurableTurn` replays a *completed* turn from cache (worker died after -the turn finished). `runSupervisedTurn` closes the harder gap — a turn -interrupted *mid-stream* — by relocating the durability boundary off the -ephemeral worker: - -- The supervisor drains every event into the substrate's own ordered log - (`appendStreamEvent`, idempotent on `eventId`). -- It persists the substrate `RunHandle` the instant the sandbox yields it. -- A fresh supervisor reads the log for its cursor and resumes via - `adapter.attach(handle, cursor)` — no event lost, none delivered twice. - -The reconnect glue is one typed contract — `SandboxReconnectAdapter` — -implemented once per substrate, not per product. +`handleChatTurn` wraps a product `produce()` hook with the `session.run.*` +lifecycle envelope, drains the producer stream through the NDJSON line +protocol, and calls the persist / post-process hooks after drain. +Framework-neutral: takes already-resolved values, never a `Request` or +`Context`. ```ts -import { runSupervisedTurn, InMemoryDurableRunStore } from '@tangle-network/agent-runtime' - -const store = new InMemoryDurableRunStore() -const supervised = runSupervisedTurn({ - store, runId: `chat:${threadId}:${turnIndex}`, manifest, workerId, - adapter: mySandboxAdapter, +import { handleChatTurn } from '@tangle-network/agent-runtime' + +const result = handleChatTurn({ + identity: { tenantId: workspaceId, sessionId: threadId, userId, turnIndex }, + hooks: { + produce: () => ({ + stream: box.streamPrompt(prompt, sandboxOptions), + finalText: () => assembled, + }), + persistAssistantMessage: async ({ identity, finalText }) => db.insert(messages).values(...), + onTurnComplete: async ({ identity, finalText }) => extractProposals(finalText), + traceFlush: () => traceSink.flush(), + }, + waitUntil: ctx.waitUntil, }) -for await (const event of supervised.stream) sendToClient(event) -// supervised.mode() === 'fresh' | 'resumed' | 'replayed' +return new Response(result.body, { headers: { 'content-type': result.contentType } }) ``` -Full runnable: [`examples/durable-supervisor/`](./examples/durable-supervisor/). +## Execution continuity -### Cloudflare Durable Object host +Long-running execution durability — reconnect, replay, dedup — lives in +the substrate. `@tangle-network/sandbox`'s `box.streamPrompt` +auto-reconnects in-call (extracts `executionId` from the response and +replays via the runtime endpoint on drop). Cross-process reconnect — +worker dies, a fresh worker resumes the same execution — requires +either bypassing the SDK and POSTing directly with `X-Execution-ID` +(see `tax-agent/sessions.ts`) or a future SDK release that surfaces the +field on `PromptOptions`. -`SessionSupervisorDO` hosts the supervisor on a real DO — `fetch` streams the -turn, `alarm()` re-attaches a run a dropped response stream abandoned. +`deriveExecutionId` is the convention helper for the stable id the +product persists alongside its session row: ```ts -import { createSessionSupervisorDO } from '@tangle-network/agent-runtime' +import { deriveExecutionId } from '@tangle-network/agent-runtime' -export const SessionSupervisor = createSessionSupervisorDO({ - resolveRun(request, env, state) { /* return RunSupervisorOptions */ }, - resolveOrphan(runId, env, state) { /* same, for the alarm path */ }, - encodeEvent(event) { return `data: ${JSON.stringify(event)}\n\n` }, -}) -``` - -```toml -# wrangler.toml -[[durable_objects.bindings]] -name = "SESSION_SUPERVISOR" -class_name = "SessionSupervisor" -[[migrations]] -tag = "v1" -new_classes = ["SessionSupervisor"] +const executionId = deriveExecutionId({ projectId, sessionId, turnIndex }) +// pass as `X-Execution-ID` header when calling the orchestrator directly ``` -CF types are structural (`DurableObjectStateLike`) — no -`@cloudflare/workers-types` runtime dep. - ## Chat-model resolution One primitive every chat handler needs and was hand-rolling per repo: @@ -157,7 +144,7 @@ export const myAgent = defineAgent({ knowledge: { /* requirements + provider */ }, rubric: { /* dimensions + weights */ }, run: async (ctx) => { - /* product-specific run — typically wraps runSupervisedTurn or runAgentTaskStream */ + /* product-specific run — typically wraps handleChatTurn or runAgentTaskStream */ }, }) ``` @@ -213,9 +200,6 @@ for await (const event of runAgentTaskStream({ task, backend, input })) { | `BackendTransportError` | Backend HTTP / IPC call returned non-success | | `SessionMismatchError` | Resume requested against a different backend | | `RuntimeRunStateError` | `RuntimeRunHandle` lifecycle methods called out of order | -| `DurableRunLeaseHeldError` | Another worker holds a live lease on the run | -| `DurableRunInputMismatchError` | A `runId` exists with a different manifest hash | -| `DurableRunDivergenceError` | A step's intent changed across replays | All extend `AgentEvalError` (re-exported from `@tangle-network/agent-eval`) and carry a stable `code` so cross-package handlers pattern-match @@ -240,7 +224,7 @@ console.log(telemetry.events, telemetry.summary()) | Package | Owns | |---|---| -| `agent-runtime` | Lifecycle, adapters, backends, durable substrate, supervisor + DO, model resolution, trace bridge, `defineAgent` | +| `agent-runtime` | Task lifecycle, adapters, backends, chat-turn engine, execution-handle contract, model resolution, trace bridge, `defineAgent`. **Does not** own long-running execution state — that lives in `@tangle-network/sandbox` + orchestrator. | | `agent-runtime/platform` | Cross-site SSO (`PlatformAuthClient`) + integrations hub (`PlatformHubClient`) | | `agent-runtime/agent` | `defineAgent` + surfaces / outcome adapters | | `agent-runtime/analyst-loop` | `runAnalystLoop` — analyst registry driver | @@ -263,16 +247,14 @@ Runnable in [`examples/`](./examples/). Every example imports from - [`openai-stream-backend/`](./examples/openai-stream-backend/) — `createOpenAICompatibleBackend` - [`runtime-run/`](./examples/runtime-run/) — production-run row + cost ledger - [`model-resolution/`](./examples/model-resolution/) — router catalog + fail-closed admission -- [`durable-supervisor/`](./examples/durable-supervisor/) — cross-worker resume keystone - [`agent-into-reviewer/`](./examples/agent-into-reviewer/) — pipe one runtime's stream into a reviewer agent -- [`chat-handler/`](./examples/chat-handler/) — `DurableChatTurnEngine.runTurn` (the centerpiece production pattern) +- [`chat-handler/`](./examples/chat-handler/) — `handleChatTurn` (the centerpiece production pattern) - [`production-trace-sink/`](./examples/production-trace-sink/) — `createProductionTraceSink` data capture ## Tests ```bash -pnpm test # full Node suite (251 tests) -pnpm test:workers # real workerd DO integration test +pnpm test pnpm typecheck pnpm lint pnpm build diff --git a/docs/concepts.md b/docs/concepts.md index 98577974..538fc396 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -21,8 +21,15 @@ rest. Read this file once and the rest of the API falls into place. └───────────────────────────────────────┬─────────────────┘ │ ┌───────────────────────────────────────┴─────────────────┐ - │ Durability ─ runDurableTurn / runSupervisedTurn │ - │ + DurableRunStore + stream-event log + RunHandle │ + │ Chat-turn lifecycle ─ handleChatTurn(...) │ + │ NDJSON + session.run.* envelope + persist/trace hooks │ + └───────────────────────────────────────┬─────────────────┘ + │ + ┌───────────────────────────────────────┴─────────────────┐ + │ Execution continuity (substrate-owned) │ + │ box.streamPrompt — auto-reconnect in-call; X-Execution-ID + │ header for cross-process. deriveExecutionId is the + │ convention helper. │ └───────────────────────────────────────┬─────────────────┘ │ ┌───────────────────────────────────────┴─────────────────┐ @@ -34,7 +41,7 @@ rest. Read this file once and the rest of the API falls into place. Each layer composes the one below it. You can use the bottom layers alone (a raw backend + the model catalog), or the whole stack -(`defineAgent` → `runSupervisedTurn`) — they're the same primitives +(`defineAgent` → `handleChatTurn`) — they're the same primitives nested. ## The task lifecycle @@ -51,52 +58,33 @@ The adapter is *yours*. The lifecycle, the eval lift, the stop semantics, the cost ledger — all substrate. Streaming is the same shape: `runAgentTaskStream` yields `RuntimeStreamEvent`s as the loop progresses. -## Durability — three levels - -A turn that "completes" means the response reached the client AND the -side effects landed. A worker isolate can die anywhere in between. Pick -the level that matches your turn length and substrate: - -| Level | Survives | When to reach for it | -|---|---|---| -| `runAgentTask` / `runAgentTaskStream` | nothing — a worker crash re-runs from the top | sub-second turns, no sandbox | -| `runDurableTurn` | a worker crash *after* the turn finished (cached replay) | medium turns, no sandbox-reconnect | -| `runSupervisedTurn` + `SessionSupervisorDO` | a worker crash *during* the turn — a fresh supervisor re-attaches to the in-flight sandbox run | long sandbox-backed turns | - -`runSupervisedTurn` works because the sandbox container is -orchestrator-managed and **outlives** the worker. The supervisor: - -1. Drains every event into the substrate's own ordered log - (`appendStreamEvent`, idempotent on `eventId`). -2. Persists a `RunHandle` (`setRunHandle`) the moment the substrate - yields a run id. -3. Heartbeats the lease while attached. - -A fresh supervisor reads the log for its cursor and calls -`adapter.attach(handle, cursor)` to resume past it — events through -`cursor` are not re-delivered (the log's idempotency dedups the seam). - -## The reconnect adapter contract - -`SandboxReconnectAdapter` is one typed interface. Implement it **once -per substrate** (the Tangle sandbox SDK, an OpenAI Assistants thread, -whatever), never per product. - -```ts -interface SandboxReconnectAdapter { - start(): AsyncIterable> - attach(handle: RunHandle, afterEventId: string | undefined): - AsyncIterable> -} -``` - -`SupervisedEvent` carries an `eventId` (cursor + dedup key), a `payload` -(your event type), and an optional `handle` (carried on the first frame -once the substrate yields the run id). - -Conformance assertions live in `src/durable/tests/supervisor.test.ts` — -copy them into your adapter's tests so substrate quirks surface there, -not in a 15-minute production turn. +## Execution continuity — substrate-owned + +Long-running execution durability — reconnect, replay, dedup — is the +substrate's job, not agent-runtime's. The `@tangle-network/sandbox` +SDK + orchestrator already handle it: + +- **In-call reconnect**: `box.streamPrompt` extracts `executionId` from + the response's `execution.started` event and replays via the runtime + endpoint if the stream drops. Transparent — callers do nothing. +- **Cross-process reconnect**: a fresh Worker can resume a prior + Worker's execution by POSTing to the orchestrator's + `/agents/run/stream` with the `X-Execution-ID` header. The SDK's + public `PromptOptions` does not yet surface this; products bypass the + SDK and call the orchestrator directly when they need it (see + tax-agent's `sessions.ts`). +- The orchestrator's buffer is 10k events / 2-min post-completion. A + retry past that window gets `execution_not_found` and re-runs. + +agent-runtime owns one helper, `deriveExecutionId({ projectId, +sessionId, turnIndex })`, that produces the stable id the product +persists on its session row. + +What lives in the Worker: auth, access control, product DB writes, +prompt composition, routing. What lives in the substrate: the +long-running execution, event buffering, replay-on-reconnect, dedup. +The Worker stays a routing + persistence layer — it does not host +execution state. ## The agent manifest @@ -150,8 +138,8 @@ agents because nothing in this list is baked into it. 1. `examples/basic-task/` — the smallest end-to-end. 2. `examples/sandbox-stream-backend/` — what streaming looks like. -3. `examples/runtime-run/` — the production-run row + cost ledger. -4. `examples/model-resolution/` — pick + validate a model. -5. `examples/durable-supervisor/` — the cross-worker resume keystone. +3. `examples/chat-handler/` — `handleChatTurn` — the centerpiece chat handler. +4. `examples/runtime-run/` — the production-run row + cost ledger. +5. `examples/model-resolution/` — pick + validate a model. 6. `examples/agent-into-reviewer/` — pipe one runtime's stream into a reviewer agent. 7. The `README.md` entry-point table — every other primitive, one row each. diff --git a/examples/README.md b/examples/README.md index c869bc89..c966b058 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,9 +15,8 @@ which needs an `OPENAI_API_KEY`. | [`openai-stream-backend/`](./openai-stream-backend/) | `runAgentTaskStream` with `createOpenAICompatibleBackend` (real endpoint required) | | [`runtime-run/`](./runtime-run/) | `startRuntimeRun` + cost ledger + persistence adapter | | [`model-resolution/`](./model-resolution/) | `resolveChatModel` + `validateChatModelId` (fail-closed) + `getModels` | -| [`durable-supervisor/`](./durable-supervisor/) | `runSupervisedTurn` — cross-worker resume keystone (fresh / resumed / replayed) | | [`agent-into-reviewer/`](./agent-into-reviewer/) | Pipe one runtime's stream into a reviewer agent (the "2-runtime" pattern) | -| [`chat-handler/`](./chat-handler/) | `DurableChatTurnEngine.runTurn` — the centerpiece production chat handler (fresh / replay paths) | +| [`chat-handler/`](./chat-handler/) | `handleChatTurn` — the centerpiece production chat handler | | [`production-trace-sink/`](./production-trace-sink/) | `createProductionTraceSink` — production data capture (RunRecord + OTLP + feedback) | ## Conventions @@ -45,7 +44,6 @@ pnpm tsx examples/sse-stream/sse-stream.ts pnpm tsx examples/sandbox-stream-backend/sandbox-stream-backend.ts pnpm tsx examples/runtime-run/runtime-run.ts pnpm tsx examples/model-resolution/model-resolution.ts -pnpm tsx examples/durable-supervisor/durable-supervisor.ts pnpm tsx examples/agent-into-reviewer/agent-into-reviewer.ts pnpm tsx examples/chat-handler/chat-handler.ts pnpm tsx examples/production-trace-sink/production-trace-sink.ts diff --git a/examples/chat-handler/README.md b/examples/chat-handler/README.md index 91cf28c1..2610b0a5 100644 --- a/examples/chat-handler/README.md +++ b/examples/chat-handler/README.md @@ -1,28 +1,12 @@ -# Durable chat handler +# Chat handler -The centerpiece production pattern every product chat handler -implements. `DurableChatTurnEngine.runTurn` composes the substrate -stack: - -- Builds the durable manifest from the chat identity (`tenantId` / - `sessionId` / `turnIndex`). -- Drives `runDurableTurn` — checkpoint + replay. -- Emits `session.run.*` lifecycle events around the producer stream. -- Returns a `ReadableStream` of NDJSON-encoded `ChatStreamEvent`s — the - shape your HTTP/SSE route forwards verbatim. - -The example shows: - -- A fresh turn streaming events (the dots are `message.part.updated`). -- A second, different turn — a fresh `runId`, the producer runs again. -- A retry of turn 0 — same identity → same `runId` → the **replay path** - emits the cached final text without re-running the producer. +`handleChatTurn` wraps a product `produce()` hook with the `session.run.*` +lifecycle envelope, drains the producer stream through the NDJSON line +protocol, and calls the persist / post-process hooks after drain. In production, `produce()` is a thin wrapper over `runAgentTaskStream(...)` against a real backend (`createOpenAICompatibleBackend` / -`createSandboxPromptBackend`). For the cross-worker-during-turn case — -worker dies *while* streaming — use `runSupervisedTurn` (see -`examples/durable-supervisor/`). +`createSandboxPromptBackend`). ```bash pnpm tsx examples/chat-handler/chat-handler.ts diff --git a/examples/chat-handler/chat-handler.ts b/examples/chat-handler/chat-handler.ts index 3f4ecfdb..80bbe914 100644 --- a/examples/chat-handler/chat-handler.ts +++ b/examples/chat-handler/chat-handler.ts @@ -1,33 +1,20 @@ /** - * Full durable chat handler — the centerpiece production pattern every - * product chat handler implements. - * - * `DurableChatTurnEngine.runTurn` composes the substrate stack: it builds - * the durable manifest from the chat identity, drives `runDurableTurn`, - * emits `session.run.*` lifecycle events around the producer stream, - * checkpoints the final text into the run store, and returns a ready-to- - * pipe `ReadableStream`. A worker crash *after* the turn finishes replays - * the cached final text; a worker crash *during* the turn is what - * `runSupervisedTurn` is for (see `examples/durable-supervisor/`). + * Full chat handler — the centerpiece production pattern every product + * chat handler implements. `handleChatTurn` frames events with NDJSON + + * `session.run.*` envelope and calls product hooks after drain. * * In a real product, `produce()` calls `runAgentTaskStream({ task, - * backend, input })` with a real backend (`createOpenAICompatibleBackend` - * / `createSandboxPromptBackend`). Here we yield a small scripted stream - * so the example runs offline with no LLM. + * backend, input })` against a real backend + * (`createOpenAICompatibleBackend` / `createSandboxPromptBackend`). + * Here we yield a small scripted stream so the example runs offline. * * Run with: * pnpm tsx examples/chat-handler/chat-handler.ts */ -import type { ChatStreamEvent, DurableTurnProducer } from '@tangle-network/agent-runtime' -import { durableChatTurnEngine, InMemoryDurableRunStore } from '@tangle-network/agent-runtime' - -const store = new InMemoryDurableRunStore() +import { type ChatStreamEvent, type ChatTurnProducer, handleChatTurn } from '@tangle-network/agent-runtime' -// ── The product's `produce` hook — yields the turn's event stream + a -// finalText() once drained. In production this is a thin wrapper over -// `runAgentTaskStream(...)` against a real backend. ────────────────── -function produce(userMessage: string): DurableTurnProducer { +function produce(userMessage: string): ChatTurnProducer { let accumulated = '' const reply = userMessage.toLowerCase().includes('missing') ? 'The 2026 return is missing Schedule B and one W-2. Please upload them.' @@ -49,17 +36,16 @@ function produce(userMessage: string): DurableTurnProducer { } async function runTurn(userMessage: string, turnIndex: number): Promise { - const result = durableChatTurnEngine.runTurn({ - store, - identity: { tenantId: 'demo-tenant', sessionId: 'thread-42', turnIndex }, - userMessage, - projectId: 'demo-agent', - domain: 'demo', - hooks: { produce: () => produce(userMessage) }, + const result = handleChatTurn({ + identity: { tenantId: 'demo-tenant', sessionId: 'thread-42', userId: 'demo-user', turnIndex }, + hooks: { + produce: () => produce(userMessage), + persistAssistantMessage: async ({ finalText }) => { + console.log(`[persist ] turn=${turnIndex} chars=${finalText.length}`) + }, + }, }) - // The engine returns a ReadableStream of NDJSON-encoded ChatStreamEvent - // lines — exactly what an SSE/HTTP route forwards to the client. const reader = result.body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -74,7 +60,7 @@ async function runTurn(userMessage: string, turnIndex: number): Promise if (!line) continue const event = JSON.parse(line) as ChatStreamEvent if (event.type === 'message.part.updated') process.stdout.write('.') - if (event.type === 'result') final = String((event.data ?? {}).finalText ?? '') + if (event.type === 'result') final = String(event.data?.finalText ?? '') if (event.type === 'session.run.started') console.log(`[run started ] turn=${turnIndex}`) if (event.type === 'session.run.completed') console.log(`\n[run done ] turn=${turnIndex}`) } @@ -83,18 +69,11 @@ async function runTurn(userMessage: string, turnIndex: number): Promise } async function main() { - // Turn 1: fresh. const t1 = await runTurn('Where do I start with my 2026 return?', 0) console.log(`[turn 0 text ] ${t1}\n`) - // Turn 2: a different turn — fresh manifest, fresh runId. const t2 = await runTurn('What about the missing Schedule B?', 1) console.log(`[turn 1 text ] ${t2}\n`) - - // Turn 3: same identity as turn 0 — durable run hits the replay path - // because step 0 of `chat:thread-42:0` is already completed. - const t3 = await runTurn('Where do I start with my 2026 return?', 0) - console.log(`[turn 0 text ] ${t3} (replayed from durable store)`) } main().catch((err) => { diff --git a/examples/durable-supervisor/README.md b/examples/durable-supervisor/README.md deleted file mode 100644 index 589b47f9..00000000 --- a/examples/durable-supervisor/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Durable run supervisor - -The cross-worker resume keystone. Worker 1 drains part of a long turn, -its isolate "dies" mid-stream, Worker 2 picks the same `runId` up and -resumes from the substrate's event log + cursor — the caller sees the -complete sequence exactly once. - -What the example shows: - -- `runSupervisedTurn` — the platform-agnostic supervisor (drains into - the durable log, persists the `RunHandle`, heartbeats the lease). -- A toy `SandboxReconnectAdapter` — one typed contract: `start()` for a - fresh run, `attach(handle, afterEventId)` to resume past a cursor. -- The three resolution modes — `fresh` / `resumed` / `replayed` — and - the idempotent `appendStreamEvent` that dedups the reconnect seam. - -For the Cloudflare Durable Object host — `createSessionSupervisorDO` — -see the README; it's a ~100-line glue layer around this same primitive. - -```bash -pnpm tsx examples/durable-supervisor/durable-supervisor.ts -``` diff --git a/examples/durable-supervisor/durable-supervisor.ts b/examples/durable-supervisor/durable-supervisor.ts deleted file mode 100644 index 58b99dce..00000000 --- a/examples/durable-supervisor/durable-supervisor.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Durable run supervisor — the cross-worker resume keystone. - * - * Worker 1 drains a few events of a long turn, then its isolate "dies" - * (we stop pulling the generator and expire the lease). Worker 2 picks - * the same `runId` up: it re-yields the logged prefix from the substrate - * and resumes via `adapter.attach(handle, cursor)`. The caller sees the - * complete event sequence exactly once — no gap, no duplicate. - * - * Run with: - * pnpm tsx examples/durable-supervisor/durable-supervisor.ts - */ - -import { - type DurableRunManifest, - InMemoryDurableRunStore, - type RunHandle, - runSupervisedTurn, - type SandboxReconnectAdapter, - type SupervisedEvent, -} from '@tangle-network/agent-runtime' - -// ── Your scripted "sandbox" — pretend the events are streaming from -// somewhere durable (a sandbox container that outlives the worker). ──── -function scriptedAdapter(): SandboxReconnectAdapter { - const script = ['intro', 'analysis', 'finding-1', 'finding-2', 'conclusion'] - return { - async *start(): AsyncGenerator> { - for (const [i, payload] of script.entries()) { - // The first frame carries the handle — that is what the substrate - // uses to re-attach. - const handle: RunHandle | undefined = - i === 0 ? { kind: 'sandbox', runId: 'sbx-1', status: 'running' } : undefined - yield { eventId: `e${i}`, payload, handle } - } - }, - async *attach(_handle, afterEventId) { - const idx = afterEventId ? script.findIndex((_, i) => `e${i}` === afterEventId) : -1 - for (let i = idx + 1; i < script.length; i++) { - yield { eventId: `e${i}`, payload: script[i]! } - } - }, - } -} - -const manifest = { - projectId: 'demo', - scenarioId: 'persona-1', - task: { id: 'long-turn', intent: 'chat', domain: 'demo' }, - input: { q: 'analyse the brief' }, -} as unknown as DurableRunManifest - -async function main() { - const store = new InMemoryDurableRunStore() - const runId = 'turn-1' - - // ── Worker 1 — drains 2 of 5 events, then dies mid-stream ──────────── - const w1 = runSupervisedTurn({ - store, - runId, - manifest, - workerId: 'w1', - adapter: scriptedAdapter(), - }) - const partial: string[] = [] - for await (const event of w1.stream) { - partial.push(event) - if (partial.length >= 2) break // worker 1's isolate "dies" - } - console.log('worker 1 saw: ', partial) // ['intro', 'analysis'] - console.log('store has: ', (await store.readStreamEvents(runId)).length, 'events') // 2 - - // Worker 1 stopped heartbeating; its lease lapses. - store._expireLease(runId) - - // ── Worker 2 picks up the same runId ───────────────────────────────── - const w2 = runSupervisedTurn({ - store, - runId, - manifest, - workerId: 'w2', - adapter: scriptedAdapter(), - }) - const full: string[] = [] - for await (const event of w2.stream) full.push(event) - - console.log('worker 2 saw: ', full) // complete sequence, exactly once - console.log('worker 2 mode: ', w2.mode()) // 'resumed' - console.log('store has: ', (await store.readStreamEvents(runId)).length, 'events') // 5 - console.log('record status: ', w2.record()?.status) // 'completed' -} - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/package.json b/package.json index 6ad6437f..9cea776a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.15.1", + "version": "0.16.0", "description": "Reusable runtime lifecycle for domain-specific agents.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { @@ -48,7 +48,6 @@ "prepare": "tsup", "test": "vitest run", "test:watch": "vitest", - "test:workers": "vitest run -c vitest.workers.config.ts", "lint": "biome check src tests examples", "lint:fix": "biome check --write src tests examples", "typecheck": "tsc --noEmit" @@ -58,16 +57,11 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.0", - "@cloudflare/vitest-pool-workers": "^0.8.71", - "@cloudflare/workers-types": "^4.20260522.1", "@tangle-network/sandbox": "0.1.2", - "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.6.0", - "better-sqlite3": "^12.10.0", "tsup": "^8.0.0", "typescript": "^5.7.0", - "vitest": "^3.0.0", - "wrangler": "^4.94.0" + "vitest": "^3.0.0" }, "pnpm": { "minimumReleaseAge": 4320, @@ -75,9 +69,7 @@ "@tangle-network/agent-eval" ], "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild", - "workerd" + "esbuild" ] }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea8f04a0..577582d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,24 +15,12 @@ importers: '@biomejs/biome': specifier: ^2.4.0 version: 2.4.15 - '@cloudflare/vitest-pool-workers': - specifier: ^0.8.71 - version: 0.8.71(@cloudflare/workers-types@4.20260522.1)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/node@25.6.0)(yaml@2.8.4)) - '@cloudflare/workers-types': - specifier: ^4.20260522.1 - version: 4.20260522.1 '@tangle-network/sandbox': specifier: 0.1.2 version: 0.1.2(viem@2.48.8(typescript@5.9.3)(zod@4.4.2)) - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 '@types/node': specifier: ^25.6.0 version: 25.6.0 - better-sqlite3: - specifier: ^12.10.0 - version: 12.10.0 tsup: specifier: ^8.0.0 version: 8.5.1(postcss@8.5.13)(typescript@5.9.3)(yaml@2.8.4) @@ -42,9 +30,6 @@ importers: vitest: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.0)(yaml@2.8.4) - wrangler: - specifier: ^4.94.0 - version: 4.94.0(@cloudflare/workers-types@4.20260522.1) packages: @@ -118,565 +103,156 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/kv-asset-handler@0.4.0': - resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} - engines: {node: '>=18.0.0'} - - '@cloudflare/kv-asset-handler@0.5.0': - resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} - engines: {node: '>=22.0.0'} - - '@cloudflare/unenv-preset@2.16.1': - resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} - peerDependencies: - unenv: 2.0.0-rc.24 - workerd: '>1.20260305.0 <2.0.0-0' - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/unenv-preset@2.7.3': - resolution: {integrity: sha512-tsQQagBKjvpd9baa6nWVIv399ejiqcrUBBW6SZx6Z22+ymm+Odv5+cFimyuCsD/fC1fQTwfRmwXBNpzvHSeGCw==} - peerDependencies: - unenv: 2.0.0-rc.21 - workerd: ^1.20250828.1 - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/vitest-pool-workers@0.8.71': - resolution: {integrity: sha512-keu2HCLQfRNwbmLBCDXJgCFpANTaYnQpE01fBOo4CNwiWHUT7SZGN7w64RKiSWRHyYppStXBuE5Ng7F42+flpg==} - peerDependencies: - '@vitest/runner': 2.0.x - 3.2.x - '@vitest/snapshot': 2.0.x - 3.2.x - vitest: 2.0.x - 3.2.x - - '@cloudflare/workerd-darwin-64@1.20250906.0': - resolution: {integrity: sha512-E+X/YYH9BmX0ew2j/mAWFif2z05NMNuhCTlNYEGLkqMe99K15UewBqajL9pMcMUKxylnlrEoK3VNxl33DkbnPA==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-64@1.20260521.1': - resolution: {integrity: sha512-aiNdXmxlhwGjTSajL3I7uQPpN4lAOcXjvg5ZOlJKIywnevr798n9XCS6lvuqgniM3KjurBNWRRypMJntg/eSLg==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20250906.0': - resolution: {integrity: sha512-X5apsZ1SFW4FYTM19ISHf8005FJMPfrcf4U5rO0tdj+TeJgQgXuZ57IG0WeW7SpLVeBo8hM6WC8CovZh41AfnA==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20260521.1': - resolution: {integrity: sha512-ikN8aKSi4Ak28ndOkuSO5rq6lmV6wwDQu9F9Vu6J7EkwAOth74J/Hjn4j4EuFceW/npw2Ws0Y/muzA6WKHl4TA==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-linux-64@1.20250906.0': - resolution: {integrity: sha512-rlKzWgsLnlQ5Nt9W69YBJKcmTmZbOGu0edUsenXPmc6wzULUxoQpi7ZE9k3TfTonJx4WoQsQlzCUamRYFsX+0Q==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-64@1.20260521.1': - resolution: {integrity: sha512-D/gUhvQcG0pJr5aJl6yUoi2JxbFpjVtDq9xUJHPjfkAjL28TUVgCR/e5r8YGirepv4I1DK7ihuii9LZ2GGMJbw==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20250906.0': - resolution: {integrity: sha512-DdedhiQ+SeLzpg7BpcLrIPEZ33QKioJQ1wvL4X7nuLzEB9rWzS37NNNahQzc1+44rhG4fyiHbXBPOeox4B9XVA==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20260521.1': - resolution: {integrity: sha512-vhjWPIHenczegTakhRPwEmTeaavCpNqsuo3RlLCkUdU47HrwLvy/4QersGggs4+kF4Do+IE/EznCGyT40xYcLA==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20250906.0': - resolution: {integrity: sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cloudflare/workerd-windows-64@1.20260521.1': - resolution: {integrity: sha512-wBolYC/+lnGIEbkkPdzFtjTOWip2uQH6maeAP1ZV0kyxi5SGpsa83+wD5rH5OOle+sHE5qJMdwCKjwRwj+FKJg==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cloudflare/workers-types@4.20260522.1': - resolution: {integrity: sha512-UjKZprpYHAaBVipLfZA2GzTuWSTHyPXWRsaedwVEqyDe+VHwFi8RVtxGr1lceBDCwLDGRjlUwuwokX6O9GZY2A==} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@esbuild/aix-ppc64@0.25.4': - resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.4': - resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.4': - resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.4': - resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.4': - resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.4': - resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.4': - resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.4': - resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.4': - resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.4': - resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.4': - resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.4': - resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.4': - resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.4': - resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.4': - resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.4': - resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.4': - resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.4': - resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.4': - resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.4': - resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.4': - resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.4': - resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.4': - resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.4': - resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.4': - resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -689,250 +265,8 @@ packages: peerDependencies: hono: ^4 - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.33.5': - resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.33.5': - resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.0.4': - resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.0.4': - resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.0.4': - resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.0.5': - resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.0.4': - resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.0.4': - resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.0.4': - resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.0.4': - resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-arm64@0.33.5': - resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm@0.33.5': - resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - - '@img/sharp-linux-s390x@0.33.5': - resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-x64@0.33.5': - resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.33.5': - resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.33.5': - resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-wasm32@0.33.5': - resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.33.5': - resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.33.5': - resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} @@ -944,9 +278,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -971,15 +302,6 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} - '@poppinss/colors@4.1.6': - resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} - - '@poppinss/dumper@0.6.5': - resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} - - '@poppinss/exception@1.2.3': - resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@rollup/rollup-android-arm-eabi@4.60.2': resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] @@ -1123,13 +445,6 @@ packages: '@scure/bip39@2.2.0': resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==} - '@sindresorhus/is@7.2.0': - resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} - engines: {node: '>=18'} - - '@speed-highlight/core@1.2.15': - resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} - '@tangle-network/agent-eval@0.33.1': resolution: {integrity: sha512-VAbg1UkC480Xzfi2jqiFMQLYykWvDMO47UHx4bb2rOeiogN1zzM10kPst3OotM+k1B2lbu51uoVnKDBnqK8zcw==} engines: {node: '>=20'} @@ -1152,9 +467,6 @@ packages: engines: {node: '>=18'} hasBin: true - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1207,15 +519,6 @@ packages: zod: optional: true - acorn-walk@8.3.2: - resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} - engines: {node: '>=0.4.0'} - - acorn@8.14.0: - resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -1228,28 +531,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - better-sqlite3@12.10.0: - resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - - birpc@0.2.14: - resolution: {integrity: sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1272,26 +553,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - - color@4.2.3: - resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} - engines: {node: '>=12.5.0'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -1307,10 +568,6 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -1323,47 +580,13 @@ packages: supports-color: optional: true - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - defu@6.1.7: - resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - devalue@5.8.1: - resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - esbuild@0.25.4: - resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1375,21 +598,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - exit-hook@2.2.1: - resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} - engines: {node: '>=6'} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1399,42 +611,18 @@ packages: picomatch: optional: true - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - hono@4.12.16: resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==} engines: {node: '>=16.9.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - is-arrayish@0.3.4: - resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} - isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: @@ -1447,10 +635,6 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1468,31 +652,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - miniflare@4.20250906.0: - resolution: {integrity: sha512-T/RWn1sa0ien80s6NjU+Un/tj12gR6wqScZoiLeMJDD4/fK0UXfnbWXJDubnUED8Xjm7RPQ5ESYdE+mhPmMtuQ==} - engines: {node: '>=18.0.0'} - hasBin: true - - miniflare@4.20260521.0: - resolution: {integrity: sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg==} - engines: {node: '>=22.0.0'} - hasBin: true - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -1507,23 +666,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - node-abi@3.92.0: - resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} - engines: {node: '>=10'} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} @@ -1535,9 +681,6 @@ packages: typescript: optional: true - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1581,23 +724,6 @@ packages: resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} engines: {node: ^10 || ^12 || >=14} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1611,54 +737,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rosie-skills-darwin-arm64@0.6.4: - resolution: {integrity: sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg==} - cpu: [arm64] - os: [darwin] - - rosie-skills-freebsd-x64@0.6.4: - resolution: {integrity: sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng==} - cpu: [x64] - os: [freebsd] - - rosie-skills-linux-x64@0.6.4: - resolution: {integrity: sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng==} - cpu: [x64] - os: [linux] - - rosie-skills@0.6.4: - resolution: {integrity: sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA==} - engines: {node: '>=18'} - hasBin: true - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.33.5: - resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - simple-swizzle@0.2.4: - resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1673,17 +754,6 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - stoppable@1.1.0: - resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} - engines: {node: '>=4', npm: '>=6'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -1692,17 +762,6 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} - engines: {node: '>=18'} - - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1739,9 +798,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -1761,9 +817,6 @@ packages: typescript: optional: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1775,19 +828,6 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - undici@7.24.8: - resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} - engines: {node: '>=20.18.1'} - - unenv@2.0.0-rc.21: - resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} - - unenv@2.0.0-rc.24: - resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - viem@2.48.8: resolution: {integrity: sha512-Xj3Nrt66SKtn06kczU91ELn9Difr84ZM5A62BTlaisT5lpgt058i2mBkfMZCXHGb1ocOLjzC2ztPhD0Lvky7uQ==} peerDependencies: @@ -1874,627 +914,158 @@ packages: engines: {node: '>=8'} hasBin: true - workerd@1.20250906.0: - resolution: {integrity: sha512-ryVyEaqXPPsr/AxccRmYZZmDAkfQVjhfRqrNTlEeN8aftBk6Ca1u7/VqmfOayjCXrA+O547TauebU+J3IpvFXw==} - engines: {node: '>=16'} - hasBin: true - - workerd@1.20260521.1: - resolution: {integrity: sha512-HzIThcZ0ZVEuzVxpY2IYZ3yssSrTjtrWXAVfmOl5rVwyqcu7aeZXGMiwrEmi9MOcC3wjy+BNv+hFrMMY5OrjQQ==} - engines: {node: '>=16'} - hasBin: true - - wrangler@4.35.0: - resolution: {integrity: sha512-HbyXtbrh4Fi3mU8ussY85tVdQ74qpVS1vctUgaPc+bPrXBTqfDLkZ6VRtHAVF/eBhz4SFmhJtCQpN1caY2Ak8A==} - engines: {node: '>=18.0.0'} - hasBin: true + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} peerDependencies: - '@cloudflare/workers-types': ^4.20250906.0 + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - - wrangler@4.94.0: - resolution: {integrity: sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew==} - engines: {node: '>=22.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20260521.1 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: + bufferutil: optional: true utf-8-validate: optional: true yaml@2.8.4: resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} - engines: {node: '>= 14.6'} - hasBin: true - - youch-core@0.3.3: - resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} - - youch@4.1.0-beta.10: - resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - - zod@3.22.3: - resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - - zod@4.4.2: - resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} - -snapshots: - - '@adraffy/ens-normalize@1.11.1': {} - - '@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.2)': - dependencies: - openapi3-ts: 4.5.0 - zod: 4.4.2 - - '@ax-llm/ax@19.0.45(zod@4.4.2)': - dependencies: - '@opentelemetry/api': 1.9.1 - dayjs: 1.11.20 - optionalDependencies: - zod: 4.4.2 - - '@biomejs/biome@2.4.15': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.15 - '@biomejs/cli-darwin-x64': 2.4.15 - '@biomejs/cli-linux-arm64': 2.4.15 - '@biomejs/cli-linux-arm64-musl': 2.4.15 - '@biomejs/cli-linux-x64': 2.4.15 - '@biomejs/cli-linux-x64-musl': 2.4.15 - '@biomejs/cli-win32-arm64': 2.4.15 - '@biomejs/cli-win32-x64': 2.4.15 - - '@biomejs/cli-darwin-arm64@2.4.15': - optional: true - - '@biomejs/cli-darwin-x64@2.4.15': - optional: true - - '@biomejs/cli-linux-arm64-musl@2.4.15': - optional: true - - '@biomejs/cli-linux-arm64@2.4.15': - optional: true - - '@biomejs/cli-linux-x64-musl@2.4.15': - optional: true - - '@biomejs/cli-linux-x64@2.4.15': - optional: true - - '@biomejs/cli-win32-arm64@2.4.15': - optional: true - - '@biomejs/cli-win32-x64@2.4.15': - optional: true - - '@cloudflare/kv-asset-handler@0.4.0': - dependencies: - mime: 3.0.0 - - '@cloudflare/kv-asset-handler@0.5.0': {} - - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260521.1)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20260521.1 - - '@cloudflare/unenv-preset@2.7.3(unenv@2.0.0-rc.21)(workerd@1.20250906.0)': - dependencies: - unenv: 2.0.0-rc.21 - optionalDependencies: - workerd: 1.20250906.0 - - '@cloudflare/vitest-pool-workers@0.8.71(@cloudflare/workers-types@4.20260522.1)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/node@25.6.0)(yaml@2.8.4))': - dependencies: - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - birpc: 0.2.14 - cjs-module-lexer: 1.4.3 - devalue: 5.8.1 - miniflare: 4.20250906.0 - semver: 7.8.0 - vitest: 3.2.4(@types/node@25.6.0)(yaml@2.8.4) - wrangler: 4.35.0(@cloudflare/workers-types@4.20260522.1) - zod: 3.25.76 - transitivePeerDependencies: - - '@cloudflare/workers-types' - - bufferutil - - utf-8-validate - - '@cloudflare/workerd-darwin-64@1.20250906.0': - optional: true - - '@cloudflare/workerd-darwin-64@1.20260521.1': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20250906.0': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20260521.1': - optional: true - - '@cloudflare/workerd-linux-64@1.20250906.0': - optional: true - - '@cloudflare/workerd-linux-64@1.20260521.1': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20250906.0': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20260521.1': - optional: true - - '@cloudflare/workerd-windows-64@1.20250906.0': - optional: true - - '@cloudflare/workerd-windows-64@1.20260521.1': - optional: true - - '@cloudflare/workers-types@4.20260522.1': {} - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.25.4': - optional: true - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.25.4': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm@0.25.4': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-x64@0.25.4': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.25.4': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.25.4': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.25.4': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.25.4': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.25.4': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm@0.25.4': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.25.4': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.25.4': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.25.4': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - - '@esbuild/linux-ppc64@0.25.4': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - - '@esbuild/linux-riscv64@0.25.4': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - - '@esbuild/linux-s390x@0.25.4': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - - '@esbuild/linux-x64@0.25.4': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - - '@esbuild/netbsd-arm64@0.25.4': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - - '@esbuild/netbsd-x64@0.25.4': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - - '@esbuild/openbsd-arm64@0.25.4': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - - '@esbuild/openbsd-x64@0.25.4': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - - '@esbuild/sunos-x64@0.25.4': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - - '@esbuild/win32-arm64@0.25.4': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - - '@esbuild/win32-ia32@0.25.4': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true + engines: {node: '>= 14.6'} + hasBin: true - '@esbuild/win32-x64@0.25.4': - optional: true + zod@4.4.2: + resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} - '@esbuild/win32-x64@0.27.3': - optional: true +snapshots: - '@esbuild/win32-x64@0.27.7': - optional: true + '@adraffy/ens-normalize@1.11.1': {} - '@hono/node-server@2.0.1(hono@4.12.16)': + '@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.2)': dependencies: - hono: 4.12.16 - - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.0.4 - optional: true + openapi3-ts: 4.5.0 + zod: 4.4.2 - '@img/sharp-darwin-arm64@0.34.5': + '@ax-llm/ax@19.0.45(zod@4.4.2)': + dependencies: + '@opentelemetry/api': 1.9.1 + dayjs: 1.11.20 optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true + zod: 4.4.2 - '@img/sharp-darwin-x64@0.33.5': + '@biomejs/biome@2.4.15': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.0.4 - optional: true + '@biomejs/cli-darwin-arm64': 2.4.15 + '@biomejs/cli-darwin-x64': 2.4.15 + '@biomejs/cli-linux-arm64': 2.4.15 + '@biomejs/cli-linux-arm64-musl': 2.4.15 + '@biomejs/cli-linux-x64': 2.4.15 + '@biomejs/cli-linux-x64-musl': 2.4.15 + '@biomejs/cli-win32-arm64': 2.4.15 + '@biomejs/cli-win32-x64': 2.4.15 - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@biomejs/cli-darwin-arm64@2.4.15': optional: true - '@img/sharp-libvips-darwin-arm64@1.0.4': + '@biomejs/cli-darwin-x64@2.4.15': optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@biomejs/cli-linux-arm64-musl@2.4.15': optional: true - '@img/sharp-libvips-darwin-x64@1.0.4': + '@biomejs/cli-linux-arm64@2.4.15': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@biomejs/cli-linux-x64-musl@2.4.15': optional: true - '@img/sharp-libvips-linux-arm64@1.0.4': + '@biomejs/cli-linux-x64@2.4.15': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@biomejs/cli-win32-arm64@2.4.15': optional: true - '@img/sharp-libvips-linux-arm@1.0.5': + '@biomejs/cli-win32-x64@2.4.15': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@esbuild/aix-ppc64@0.27.7': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@esbuild/android-arm64@0.27.7': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@esbuild/android-arm@0.27.7': optional: true - '@img/sharp-libvips-linux-s390x@1.0.4': + '@esbuild/android-x64@0.27.7': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@esbuild/darwin-arm64@0.27.7': optional: true - '@img/sharp-libvips-linux-x64@1.0.4': + '@esbuild/darwin-x64@0.27.7': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + '@esbuild/freebsd-x64@0.27.7': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@esbuild/linux-arm64@0.27.7': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.0.4': + '@esbuild/linux-arm@0.27.7': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@esbuild/linux-ia32@0.27.7': optional: true - '@img/sharp-linux-arm64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.0.4 + '@esbuild/linux-loong64@0.27.7': optional: true - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@esbuild/linux-mips64el@0.27.7': optional: true - '@img/sharp-linux-arm@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.0.5 + '@esbuild/linux-ppc64@0.27.7': optional: true - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@esbuild/linux-riscv64@0.27.7': optional: true - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@esbuild/linux-s390x@0.27.7': optional: true - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@esbuild/linux-x64@0.27.7': optional: true - '@img/sharp-linux-s390x@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.0.4 + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@esbuild/netbsd-x64@0.27.7': optional: true - '@img/sharp-linux-x64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.0.4 + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@esbuild/openbsd-x64@0.27.7': optional: true - '@img/sharp-linuxmusl-arm64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@esbuild/sunos-x64@0.27.7': optional: true - '@img/sharp-linuxmusl-x64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@esbuild/win32-arm64@0.27.7': optional: true - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@esbuild/win32-ia32@0.27.7': optional: true - '@img/sharp-wasm32@0.33.5': - dependencies: - '@emnapi/runtime': 1.10.0 + '@esbuild/win32-x64@0.27.7': optional: true - '@img/sharp-wasm32@0.34.5': + '@hono/node-server@2.0.1(hono@4.12.16)': dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.33.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.33.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true + hono: 4.12.16 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -2510,11 +1081,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.1': @@ -2531,18 +1097,6 @@ snapshots: '@opentelemetry/api@1.9.1': {} - '@poppinss/colors@4.1.6': - dependencies: - kleur: 4.1.5 - - '@poppinss/dumper@0.6.5': - dependencies: - '@poppinss/colors': 4.1.6 - '@sindresorhus/is': 7.2.0 - supports-color: 10.2.2 - - '@poppinss/exception@1.2.3': {} - '@rollup/rollup-android-arm-eabi@4.60.2': optional: true @@ -2644,10 +1198,6 @@ snapshots: '@noble/hashes': 2.2.0 '@scure/base': 2.2.0 - '@sindresorhus/is@7.2.0': {} - - '@speed-highlight/core@1.2.15': {} - '@tangle-network/agent-eval@0.33.1(typescript@5.9.3)': dependencies: '@asteasolutions/zod-to-openapi': 8.5.0(zod@4.4.2) @@ -2681,10 +1231,6 @@ snapshots: - utf-8-validate - zod - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 25.6.0 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2745,42 +1291,12 @@ snapshots: typescript: 5.9.3 zod: 4.4.2 - acorn-walk@8.3.2: {} - - acorn@8.14.0: {} - acorn@8.16.0: {} any-promise@1.3.0: {} assertion-error@2.0.1: {} - base64-js@1.5.1: {} - - better-sqlite3@12.10.0: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - - birpc@0.2.14: {} - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - blake3-wasm@2.1.5: {} - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -2802,26 +1318,6 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@1.1.4: {} - - cjs-module-lexer@1.4.3: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - color-string@1.9.1: - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.4 - - color@4.2.3: - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 - commander@14.0.3: {} commander@4.1.1: {} @@ -2830,93 +1326,16 @@ snapshots: consola@3.4.2: {} - cookie@1.1.1: {} - dayjs@1.11.20: {} debug@4.4.3: dependencies: ms: 2.1.3 - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - deep-eql@5.0.2: {} - deep-extend@0.6.0: {} - - defu@6.1.7: {} - - detect-libc@2.1.2: {} - - devalue@5.8.1: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - error-stack-parser-es@1.0.5: {} - es-module-lexer@1.7.0: {} - esbuild@0.25.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.4 - '@esbuild/android-arm': 0.25.4 - '@esbuild/android-arm64': 0.25.4 - '@esbuild/android-x64': 0.25.4 - '@esbuild/darwin-arm64': 0.25.4 - '@esbuild/darwin-x64': 0.25.4 - '@esbuild/freebsd-arm64': 0.25.4 - '@esbuild/freebsd-x64': 0.25.4 - '@esbuild/linux-arm': 0.25.4 - '@esbuild/linux-arm64': 0.25.4 - '@esbuild/linux-ia32': 0.25.4 - '@esbuild/linux-loong64': 0.25.4 - '@esbuild/linux-mips64el': 0.25.4 - '@esbuild/linux-ppc64': 0.25.4 - '@esbuild/linux-riscv64': 0.25.4 - '@esbuild/linux-s390x': 0.25.4 - '@esbuild/linux-x64': 0.25.4 - '@esbuild/netbsd-arm64': 0.25.4 - '@esbuild/netbsd-x64': 0.25.4 - '@esbuild/openbsd-arm64': 0.25.4 - '@esbuild/openbsd-x64': 0.25.4 - '@esbuild/sunos-x64': 0.25.4 - '@esbuild/win32-arm64': 0.25.4 - '@esbuild/win32-ia32': 0.25.4 - '@esbuild/win32-x64': 0.25.4 - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -2952,45 +1371,23 @@ snapshots: eventemitter3@5.0.1: {} - exit-hook@2.2.1: {} - - expand-template@2.0.3: {} - expect-type@1.3.0: {} - exsolve@1.0.8: {} - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 - file-uri-to-path@1.0.0: {} - fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.2 rollup: 4.60.2 - fs-constants@1.0.0: {} - fsevents@2.3.3: optional: true - github-from-package@0.0.0: {} - - glob-to-regexp@0.4.1: {} - hono@4.12.16: {} - ieee754@1.2.1: {} - - inherits@2.0.4: {} - - ini@1.3.8: {} - - is-arrayish@0.3.4: {} - isows@1.0.7(ws@8.18.3): dependencies: ws: 8.18.3 @@ -2999,8 +1396,6 @@ snapshots: js-tokens@9.0.1: {} - kleur@4.1.5: {} - lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -3013,44 +1408,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - mime@3.0.0: {} - - mimic-response@3.1.0: {} - - miniflare@4.20250906.0: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - acorn: 8.14.0 - acorn-walk: 8.3.2 - exit-hook: 2.2.1 - glob-to-regexp: 0.4.1 - sharp: 0.33.5 - stoppable: 1.1.0 - undici: 7.24.8 - workerd: 1.20250906.0 - ws: 8.18.0 - youch: 4.1.0-beta.10 - zod: 3.22.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - miniflare@4.20260521.0: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.24.8 - workerd: 1.20260521.1 - ws: 8.20.1 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - minimist@1.2.8: {} - - mkdirp-classic@0.5.3: {} - mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -3068,20 +1425,8 @@ snapshots: nanoid@3.3.12: {} - napi-build-utils@2.0.0: {} - - node-abi@3.92.0: - dependencies: - semver: 7.8.0 - object-assign@4.1.1: {} - ohash@2.0.11: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - openapi3-ts@4.5.0: dependencies: yaml: 2.8.4 @@ -3101,8 +1446,6 @@ snapshots: transitivePeerDependencies: - zod - path-to-regexp@6.3.0: {} - pathe@2.0.3: {} pathval@2.0.1: {} @@ -3132,39 +1475,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.92.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - readdirp@4.1.2: {} resolve-from@5.0.0: {} @@ -3200,96 +1510,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 - rosie-skills-darwin-arm64@0.6.4: - optional: true - - rosie-skills-freebsd-x64@0.6.4: - optional: true - - rosie-skills-linux-x64@0.6.4: - optional: true - - rosie-skills@0.6.4: - optionalDependencies: - rosie-skills-darwin-arm64: 0.6.4 - rosie-skills-freebsd-x64: 0.6.4 - rosie-skills-linux-x64: 0.6.4 - - safe-buffer@5.2.1: {} - - semver@7.8.0: {} - - sharp@0.33.5: - dependencies: - color: 4.2.3 - detect-libc: 2.1.2 - semver: 7.8.0 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.33.5 - '@img/sharp-darwin-x64': 0.33.5 - '@img/sharp-libvips-darwin-arm64': 1.0.4 - '@img/sharp-libvips-darwin-x64': 1.0.4 - '@img/sharp-libvips-linux-arm': 1.0.5 - '@img/sharp-libvips-linux-arm64': 1.0.4 - '@img/sharp-libvips-linux-s390x': 1.0.4 - '@img/sharp-libvips-linux-x64': 1.0.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 - '@img/sharp-libvips-linuxmusl-x64': 1.0.4 - '@img/sharp-linux-arm': 0.33.5 - '@img/sharp-linux-arm64': 0.33.5 - '@img/sharp-linux-s390x': 0.33.5 - '@img/sharp-linux-x64': 0.33.5 - '@img/sharp-linuxmusl-arm64': 0.33.5 - '@img/sharp-linuxmusl-x64': 0.33.5 - '@img/sharp-wasm32': 0.33.5 - '@img/sharp-win32-ia32': 0.33.5 - '@img/sharp-win32-x64': 0.33.5 - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.0 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - siginfo@2.0.0: {} - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - - simple-swizzle@0.2.4: - dependencies: - is-arrayish: 0.3.4 - source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -3298,14 +1520,6 @@ snapshots: std-env@3.10.0: {} - stoppable@1.1.0: {} - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-json-comments@2.0.1: {} - strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -3320,23 +1534,6 @@ snapshots: tinyglobby: 0.2.16 ts-interface-checker: 0.1.13 - supports-color@10.2.2: {} - - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -3364,9 +1561,6 @@ snapshots: ts-interface-checker@0.1.13: {} - tslib@2.8.1: - optional: true - tsup@8.5.1(postcss@8.5.13)(typescript@5.9.3)(yaml@2.8.4): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) @@ -3395,32 +1589,12 @@ snapshots: - tsx - yaml - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - typescript@5.9.3: {} ufo@1.6.4: {} undici-types@7.19.2: {} - undici@7.24.8: {} - - unenv@2.0.0-rc.21: - dependencies: - defu: 6.1.7 - exsolve: 1.0.8 - ohash: 2.0.11 - pathe: 2.0.3 - ufo: 1.6.4 - - unenv@2.0.0-rc.24: - dependencies: - pathe: 2.0.3 - - util-deprecate@1.0.2: {} - viem@2.48.8(typescript@5.9.3)(zod@4.4.2): dependencies: '@noble/curves': 1.9.1 @@ -3518,82 +1692,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - workerd@1.20250906.0: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20250906.0 - '@cloudflare/workerd-darwin-arm64': 1.20250906.0 - '@cloudflare/workerd-linux-64': 1.20250906.0 - '@cloudflare/workerd-linux-arm64': 1.20250906.0 - '@cloudflare/workerd-windows-64': 1.20250906.0 - - workerd@1.20260521.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260521.1 - '@cloudflare/workerd-darwin-arm64': 1.20260521.1 - '@cloudflare/workerd-linux-64': 1.20260521.1 - '@cloudflare/workerd-linux-arm64': 1.20260521.1 - '@cloudflare/workerd-windows-64': 1.20260521.1 - - wrangler@4.35.0(@cloudflare/workers-types@4.20260522.1): - dependencies: - '@cloudflare/kv-asset-handler': 0.4.0 - '@cloudflare/unenv-preset': 2.7.3(unenv@2.0.0-rc.21)(workerd@1.20250906.0) - blake3-wasm: 2.1.5 - esbuild: 0.25.4 - miniflare: 4.20250906.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.21 - workerd: 1.20250906.0 - optionalDependencies: - '@cloudflare/workers-types': 4.20260522.1 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - wrangler@4.94.0(@cloudflare/workers-types@4.20260522.1): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260521.1) - blake3-wasm: 2.1.5 - esbuild: 0.27.3 - miniflare: 4.20260521.0 - path-to-regexp: 6.3.0 - rosie-skills: 0.6.4 - unenv: 2.0.0-rc.24 - workerd: 1.20260521.1 - optionalDependencies: - '@cloudflare/workers-types': 4.20260522.1 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - wrappy@1.0.2: {} - - ws@8.18.0: {} - ws@8.18.3: {} - ws@8.20.1: {} - yaml@2.8.4: {} - youch-core@0.3.3: - dependencies: - '@poppinss/exception': 1.2.3 - error-stack-parser-es: 1.0.5 - - youch@4.1.0-beta.10: - dependencies: - '@poppinss/colors': 4.1.6 - '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.15 - cookie: 1.1.1 - youch-core: 0.3.3 - - zod@3.22.3: {} - - zod@3.25.76: {} - zod@4.4.2: {} diff --git a/src/durable/chat-engine.ts b/src/durable/chat-engine.ts index 5df4d9fa..57336a2f 100644 --- a/src/durable/chat-engine.ts +++ b/src/durable/chat-engine.ts @@ -1,41 +1,29 @@ /** - * `DurableChatTurnEngine` — the framework-neutral chat-turn orchestrator every - * product chat handler routes through. It owns the parts that were copy-pasted - * across legal / gtm / creative / tax: durable checkpointing, the NDJSON - * `StreamEvent` line protocol, the `session.run.*` lifecycle vocabulary, the - * runtime-run cost ledger, and trace flush. Everything genuinely - * product-specific is a hook the product supplies. + * `handleChatTurn` — framework-neutral chat-turn HTTP orchestrator. + * Owns the NDJSON `ChatStreamEvent` line protocol, the `session.run.*` + * lifecycle vocabulary, and the persist / post-process / trace-flush + * hook order. Returns a `ReadableStream` body the product hands to its + * platform `Response`. * - * What the engine owns: - * - durable turn (`runDurableTurn`): completed turns replay free, no re-bill - * - the `session.run.started` / `session.run.completed` / `session.run.failed` - * event envelope around the producer's events - * - NDJSON encoding into a `ReadableStream` (the body every - * product returns, React Router or Hono alike) - * - calling the product's persist / post-process hooks in the right order, - * after the stream drains, with the assembled final text - * - never throwing into the HTTP layer — a producer failure becomes an - * `error` + `session.run.failed` event pair, the stream still closes + * Execution durability is the substrate's concern: `box.streamPrompt` + * auto-reconnects in-call; cross-process reconnect via `X-Execution-ID` + * is the product's job. The producer this engine wraps already speaks + * that protocol — the engine just frames the events. * - * What the product supplies (`ChatTurnHooks`): - * - `produce` — build the backend stream for this turn (sandbox / router - * / tcloud / runtime — the engine does not care which) - * - `persistAssistantMessage` — write the assistant turn to the product DB - * - `onTurnComplete` (optional) — post-process (proposals, citations, …) - * - `onEvent` (optional) — per-event side-channel (e.g. DO broadcast) - * - `transformFinalText` (optional) — pre-persist transform (e.g. PII redact) + * Hooks (`ChatTurnHooks`): + * - `produce` — build the backend event stream + * - `persistAssistantMessage` — write the assistant turn to the product DB + * - `onTurnComplete?` — post-process (proposals, citations, …) + * - `onEvent?` — per-event side channel (e.g. DO broadcast) + * - `transformFinalText?` — pre-persist transform (e.g. PII redact) + * - `traceFlush?` — handed to waitUntil so OTLP export lands * - * Framework neutrality: the engine takes already-resolved values - * (`userId`, identity tuple, parsed message, a `DurableRunStore`, a - * `waitUntil`), never a `Request` or a `Context`. The product's thin route - * adapter does auth + parse + access-control, then calls `engine.runTurn(...)` - * and returns `result.body` as its platform `Response`. + * Framework neutrality: takes already-resolved values (`identity` tuple, + * a `waitUntil`), never a `Request` or a `Context`. The product's thin + * route adapter does auth + parse + access-control, then calls + * `handleChatTurn(...)` and returns `result.body` as its platform `Response`. */ -import { deriveWorkerId } from './identity' -import { type DurableTurnProducer, runDurableTurn } from './turn' -import type { DurableRunManifest, DurableRunStore, RunRecord } from './types' - /** The NDJSON line protocol every product chat client already speaks. */ export interface ChatStreamEvent { type: string @@ -46,75 +34,56 @@ export interface ChatStreamEvent { * scoped products and the user id for session-scoped products. */ export interface ChatTurnIdentity { tenantId: string - /** Thread / session id — the durable run is keyed on this + `turnIndex`. */ + /** Thread / session id. */ sessionId: string userId: string /** Monotonic 0-based turn index within the session. */ turnIndex: number } +/** The live side of a turn — what the product's `produce` hook returns. */ +export interface ChatTurnProducer { + /** The turn's event stream. Forwarded verbatim to the caller. */ + stream: AsyncGenerator + /** The turn's final assistant text. Read once, after `stream` drains. */ + finalText(): string +} + export interface ChatTurnHooks { - /** - * Build the backend stream for this turn. The engine never inspects which - * backend this is — sandbox container, tcloud router, direct runtime, a - * test double — it only forwards the events and reads `finalText()`. - */ - produce(): DurableTurnProducer - /** - * Persist the completed assistant message to the product's own store. - * Called once, after the stream drains, on a fresh (non-replay) run. - * Receives the assembled (and `transformFinalText`-transformed) text. - */ + /** Build the backend stream. The engine forwards events verbatim and + * reads `finalText()` once the stream drains. */ + produce(): ChatTurnProducer + /** Persist the assistant message to the product's own store. Called + * once, after drain, with the assembled (transform-applied) text. */ persistAssistantMessage(input: { identity: ChatTurnIdentity finalText: string - record: RunRecord | undefined }): Promise - /** - * Optional post-processing after persistence — proposal extraction, - * citation validation, credit metering, etc. Product policy; the engine - * has no shared logic here. Errors are swallowed + logged (post-process - * must never fail the turn that already streamed successfully). - */ + /** Optional post-processing (proposals, citations, credit metering …). + * Errors are swallowed + logged — post-process must never fail a turn + * that already streamed successfully. */ onTurnComplete?(input: { identity: ChatTurnIdentity; finalText: string }): Promise - /** - * Optional per-event side channel (e.g. Durable Object broadcast). Runs - * for every event the engine emits, lifecycle envelope included. Errors - * are swallowed — a broadcast failure must not break the chat stream. - */ + /** Optional per-event side channel (e.g. DO broadcast). Runs for every + * emitted event, lifecycle envelope included. Errors swallowed — a + * broadcast failure must not break the chat stream. */ onEvent?(event: ChatStreamEvent): void | Promise - /** - * Optional pre-persist transform of the final text (e.g. PII redaction). - * Affects only what is persisted; the live stream is never altered. - */ + /** Optional pre-persist transform of the final text (e.g. PII + * redaction). Affects only what is persisted; the live stream is + * never altered. */ transformFinalText?(text: string): string | Promise - /** - * Optional trace flush — resolves when OTLP export completes. The engine - * hands it to `waitUntil` so the worker isolate stays alive for the POST. - */ + /** Optional trace flush — resolves when OTLP export completes. Handed + * to `waitUntil` so the worker isolate stays alive for the POST. */ traceFlush?(): Promise } export interface RunChatTurnInput { identity: ChatTurnIdentity - /** The user's message for this turn. Hashed into the durable run identity. */ - userMessage: string - /** Product id for telemetry / the durable manifest (`legal-agent`, …). */ - projectId: string - /** Domain tag for the task spec (`legal`, `gtm`, …). */ - domain: string - /** Model id, when known — recorded on the manifest. */ - model?: string - store: DurableRunStore hooks: ChatTurnHooks - /** Worker liveness hook (`ctx.waitUntil` / `executionCtx.waitUntil`). When - * omitted, trace flush is awaited inline before the stream closes. */ + /** Worker liveness hook. When omitted, trace flush is awaited inline + * before the stream closes. */ waitUntil?: (p: Promise) => void - /** Stable per-isolate worker id. Defaults to a fresh `deriveWorkerId()`. */ - workerId?: string - /** Lease window in ms. Default 60_000. */ - leaseMs?: number - /** Optional structured logger for swallowed hook errors. */ + /** Structured logger for swallowed hook errors. Defaults to + * `console.error` so failures surface without product wiring. */ log?: (message: string, meta?: Record) => void } @@ -131,169 +100,97 @@ function encodeLine(event: ChatStreamEvent): Uint8Array { return encoder.encode(`${JSON.stringify(event)}\n`) } +function defaultLog(message: string, meta?: Record): void { + if (meta) console.error(message, meta) + else console.error(message) +} + /** - * The engine. One instance is stateless and reusable across requests — all - * per-turn state lives in `runTurn`'s closure. + * Run one chat turn. Returns immediately with a `ReadableStream` body; + * the turn executes as the body is pulled. Never rejects — backend + * failures surface as `error` + `session.run.failed` events. */ -export class DurableChatTurnEngine { - /** - * Run one durable chat turn. Returns immediately with a `ReadableStream` - * body; the turn executes as the body is pulled. Never rejects — backend - * failures surface as `error` + `session.run.failed` events. - */ - runTurn(input: RunChatTurnInput): ChatTurnResult { - const workerId = input.workerId ?? deriveWorkerId() - const log = input.log ?? (() => undefined) - const { identity } = input - const runId = `chat:${identity.sessionId}:${identity.turnIndex}` - - const manifest: DurableRunManifest = { - projectId: input.projectId, - scenarioId: identity.sessionId, - task: { - id: `${input.projectId}:chat:${identity.sessionId}:${identity.turnIndex}`, - intent: `Run a ${input.domain} chat turn with workspace context.`, - domain: input.domain, - requiredKnowledge: [], - metadata: { - tenantId: identity.tenantId, - sessionId: identity.sessionId, - turnIndex: identity.turnIndex, - }, - }, - input: { - userMessage: input.userMessage, - model: input.model ?? null, - }, - tags: { - session_id: identity.sessionId, - tenant_id: identity.tenantId, - }, - } - - const body = new ReadableStream({ - start: async (controller) => { - const emit = async (event: ChatStreamEvent): Promise => { - controller.enqueue(encodeLine(event)) - if (input.hooks.onEvent) { - try { - await input.hooks.onEvent(event) - } catch (err) { - log('[chat-engine] onEvent hook threw', { - error: err instanceof Error ? err.message : String(err), - }) - } +export function handleChatTurn(input: RunChatTurnInput): ChatTurnResult { + const log = input.log ?? defaultLog + const { identity, hooks } = input + + const body = new ReadableStream({ + start: async (controller) => { + const emit = async (event: ChatStreamEvent): Promise => { + controller.enqueue(encodeLine(event)) + if (hooks.onEvent) { + try { + await hooks.onEvent(event) + } catch (err) { + log('[chat-engine] onEvent hook threw', { + error: err instanceof Error ? err.message : String(err), + }) } } + } + + try { + await emit({ + type: 'session.run.started', + data: { + sessionId: identity.sessionId, + tenantId: identity.tenantId, + turnIndex: identity.turnIndex, + }, + }) + + const producer = hooks.produce() + for await (const event of producer.stream) { + await emit(event) + } + const rawFinal = producer.finalText() + const finalText = hooks.transformFinalText + ? await hooks.transformFinalText(rawFinal) + : rawFinal - let turnFailed = false try { - await emit({ - type: 'session.run.started', - data: { - sessionId: identity.sessionId, - tenantId: identity.tenantId, - turnIndex: identity.turnIndex, - }, - }) - - const turn = runDurableTurn({ - store: input.store, - runId, - manifest, - workerId, - leaseMs: input.leaseMs, - intent: `chat:turn-${identity.turnIndex}`, - produce: input.hooks.produce, - replayEvent: (finalText) => ({ type: 'result', data: { finalText } }), - accumulate: (event, current) => { - // Accumulate from the same event shapes products already emit: - // `message.part.updated` text deltas and a trailing `result`. - if (event.type === 'message.part.updated') { - const data = event.data ?? {} - const delta = typeof data.delta === 'string' ? data.delta : '' - const part = data.part as { type?: string; text?: string } | undefined - if (delta) return current + delta - if (part?.type === 'text' && typeof part.text === 'string') return part.text - return undefined - } - if (event.type === 'result') { - const data = event.data ?? {} - if (typeof data.finalText === 'string') return data.finalText - } - return undefined - }, - }) - - for await (const event of turn.stream) { - await emit(event) - } - - const rawFinal = turn.finalText() - const finalText = input.hooks.transformFinalText - ? await input.hooks.transformFinalText(rawFinal) - : rawFinal - - // Persist + post-process only on a fresh run. On replay the - // assistant message + side effects already landed on the first - // (completed) attempt — re-persisting would double-write. - if (!turn.replayed()) { - try { - await input.hooks.persistAssistantMessage({ - identity, - finalText, - record: turn.record(), - }) - } catch (err) { - log('[chat-engine] persistAssistantMessage threw', { - error: err instanceof Error ? err.message : String(err), - }) - } - if (input.hooks.onTurnComplete) { - try { - await input.hooks.onTurnComplete({ identity, finalText }) - } catch (err) { - log('[chat-engine] onTurnComplete threw', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - } - - await emit({ - type: 'session.run.completed', - data: { sessionId: identity.sessionId, replayed: turn.replayed() }, - }) + await hooks.persistAssistantMessage({ identity, finalText }) } catch (err) { - turnFailed = true - const message = err instanceof Error ? err.message : String(err) - log('[chat-engine] turn failed', { error: message }) - await emit({ type: 'error', data: { message } }) - await emit({ - type: 'session.run.failed', - data: { sessionId: identity.sessionId, message }, + log('[chat-engine] persistAssistantMessage threw', { + error: err instanceof Error ? err.message : String(err), }) - } finally { - // Trace flush: hand to waitUntil so the isolate survives the POST; - // await inline when no waitUntil is available (local / tests). - if (input.hooks.traceFlush) { - const flush = input.hooks.traceFlush().catch((err) => - log('[chat-engine] traceFlush threw', { - error: err instanceof Error ? err.message : String(err), - }), - ) - if (input.waitUntil) input.waitUntil(flush) - else await flush + } + if (hooks.onTurnComplete) { + try { + await hooks.onTurnComplete({ identity, finalText }) + } catch (err) { + log('[chat-engine] onTurnComplete threw', { + error: err instanceof Error ? err.message : String(err), + }) } - controller.close() - void turnFailed } - }, - }) - return { body, contentType: 'application/x-ndjson' } - } -} + await emit({ + type: 'session.run.completed', + data: { sessionId: identity.sessionId }, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + log('[chat-engine] turn failed', { error: message }) + await emit({ type: 'error', data: { message } }) + await emit({ + type: 'session.run.failed', + data: { sessionId: identity.sessionId, message }, + }) + } finally { + if (hooks.traceFlush) { + const flush = hooks.traceFlush().catch((err) => + log('[chat-engine] traceFlush threw', { + error: err instanceof Error ? err.message : String(err), + }), + ) + if (input.waitUntil) input.waitUntil(flush) + else await flush + } + controller.close() + } + }, + }) -/** Convenience singleton — the engine is stateless, one instance is enough. */ -export const durableChatTurnEngine = new DurableChatTurnEngine() + return { body, contentType: 'application/x-ndjson' } +} diff --git a/src/durable/d1-store.ts b/src/durable/d1-store.ts deleted file mode 100644 index 7137f1f1..00000000 --- a/src/durable/d1-store.ts +++ /dev/null @@ -1,531 +0,0 @@ -/** - * D1DurableRunStore — the production path for Cloudflare Workers. Backed by - * a D1 (SQLite-compatible) database via the binding the worker already holds. - * - * Apply `./schema.sql` once before use; the store itself does not run DDL. - * Migration version is recorded in `durable_schema_info`; consumers can - * inspect `getSchemaVersion()` if they ship a migration tool. - * - * Why structural typing: agent-runtime stays Cloudflare-free at the dep - * level. Consumers pass their `D1Database` binding — TypeScript matches the - * minimal `D1DatabaseLike` surface below. Tests use the same interface with - * a fake. - */ - -import { manifestHash } from './identity' -import { - DurableRunDivergenceError, - DurableRunInputMismatchError, - DurableRunLeaseHeldError, - type DurableRunManifest, - type DurableRunStore, - type EventRecord, - type RunHandle, - type RunOutcome, - type RunRecord, - type StepError, - type StepKind, - type StepRecord, - type StreamEventRecord, -} from './types' - -const DEFAULT_LEASE_MS = 30_000 - -/** - * Minimal D1 surface this store uses. Compatible with Cloudflare's - * `D1Database` from `@cloudflare/workers-types`. Defined locally so - * agent-runtime does not depend on workers-types at the package level. - */ -export interface D1DatabaseLike { - prepare(query: string): D1PreparedStatementLike - batch(statements: D1PreparedStatementLike[]): Promise -} - -export interface D1PreparedStatementLike { - bind(...values: unknown[]): D1PreparedStatementLike - first(): Promise - all(): Promise<{ results: T[] }> - run(): Promise<{ success: boolean; meta?: { changes?: number } }> -} - -interface RunRow { - run_id: string - manifest_hash: string - project_id: string - scenario_id: string | null - status: RunRecord['status'] - created_at: string - updated_at: string - completed_at: string | null - lease_holder_id: string | null - lease_expires_at: string | null - outcome_json: string | null - step_count: number - handle_json: string | null -} - -interface StreamEventRow { - run_id: string - seq: number - event_id: string - payload_json: string | null - appended_at: string -} - -interface StepRow { - run_id: string - step_index: number - intent: string - kind: string - input_hash: string - status: StepRecord['status'] - attempts: number - result_json: string | null - error_json: string | null - started_at: string | null - completed_at: string | null -} - -interface EventRow { - run_id: string - key: string - payload_json: string | null - emitted_at: string -} - -export class D1DurableRunStore implements DurableRunStore { - constructor(private readonly db: D1DatabaseLike) {} - - /** Override for tests — defaults to Date.now(). */ - public now: () => number = () => Date.now() - - async startOrResume(input: { - runId: string - manifest: DurableRunManifest - workerId: string - leaseMs?: number - }): ReturnType { - const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS - const hash = manifestHash(input.manifest) - const nowMs = this.now() - const nowIso = new Date(nowMs).toISOString() - const leaseExpiresAt = new Date(nowMs + leaseMs).toISOString() - - const existing = await this.db - .prepare('SELECT * FROM durable_runs WHERE run_id = ?') - .bind(input.runId) - .first() - - if (!existing) { - await this.db - .prepare( - `INSERT INTO durable_runs - (run_id, manifest_hash, project_id, scenario_id, status, - created_at, updated_at, lease_holder_id, lease_expires_at, step_count) - VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, 0)`, - ) - .bind( - input.runId, - hash, - input.manifest.projectId, - input.manifest.scenarioId ?? null, - nowIso, - nowIso, - input.workerId, - leaseExpiresAt, - ) - .run() - const record: RunRecord = { - runId: input.runId, - manifestHash: hash, - projectId: input.manifest.projectId, - scenarioId: input.manifest.scenarioId, - status: 'running', - createdAt: nowIso, - updatedAt: nowIso, - leaseHolderId: input.workerId, - leaseExpiresAt, - stepCount: 0, - } - return { run: record, completedSteps: [], leaseExpiresAt } - } - - if (existing.manifest_hash !== hash) { - throw new DurableRunInputMismatchError( - `runId ${input.runId} exists with a different manifest hash; refuse to corrupt prior steps`, - ) - } - // Lease takeover — conditional UPDATE. - const claim = await this.db - .prepare( - `UPDATE durable_runs - SET lease_holder_id = ?, - lease_expires_at = ?, - updated_at = ?, - status = CASE WHEN status IN ('completed','failed') THEN status ELSE 'running' END - WHERE run_id = ? - AND ( - lease_holder_id = ? OR - lease_holder_id IS NULL OR - lease_expires_at IS NULL OR - lease_expires_at < ? - )`, - ) - .bind(input.workerId, leaseExpiresAt, nowIso, input.runId, input.workerId, nowIso) - .run() - const changes = claim.meta?.changes ?? 0 - if (changes === 0) { - throw new DurableRunLeaseHeldError( - `runId ${input.runId} leased by ${existing.lease_holder_id} until ${existing.lease_expires_at}`, - ) - } - const completedSteps = await this.readSteps(input.runId, 'completed') - const record: RunRecord = rowToRunRecord({ - ...existing, - lease_holder_id: input.workerId, - lease_expires_at: leaseExpiresAt, - updated_at: nowIso, - status: - existing.status === 'completed' || existing.status === 'failed' - ? existing.status - : 'running', - }) - return { run: record, completedSteps, leaseExpiresAt } - } - - async renewLease(input: { - runId: string - workerId: string - leaseMs?: number - }): Promise<{ ok: boolean; leaseExpiresAt?: string }> { - const nowMs = this.now() - const nowIso = new Date(nowMs).toISOString() - const leaseExpiresAt = new Date(nowMs + (input.leaseMs ?? DEFAULT_LEASE_MS)).toISOString() - const res = await this.db - .prepare( - `UPDATE durable_runs - SET lease_expires_at = ?, updated_at = ? - WHERE run_id = ? - AND (lease_holder_id = ? OR lease_expires_at IS NULL OR lease_expires_at < ?)`, - ) - .bind(leaseExpiresAt, nowIso, input.runId, input.workerId, nowIso) - .run() - const ok = (res.meta?.changes ?? 0) > 0 - return ok ? { ok: true, leaseExpiresAt } : { ok: false } - } - - async loadStep(runId: string, stepIndex: number): Promise { - const row = await this.db - .prepare('SELECT * FROM durable_steps WHERE run_id = ? AND step_index = ?') - .bind(runId, stepIndex) - .first() - return row ? rowToStepRecord(row) : undefined - } - - async beginStep(input: { - runId: string - stepIndex: number - intent: string - kind: StepKind - inputHash: string - }): Promise { - const nowIso = new Date(this.now()).toISOString() - const prior = await this.loadStep(input.runId, input.stepIndex) - if (prior) { - if (prior.intent !== input.intent) { - throw new DurableRunDivergenceError( - `step ${input.stepIndex}: intent changed ('${prior.intent}' -> '${input.intent}')`, - ) - } - await this.db - .prepare( - `UPDATE durable_steps - SET status='running', attempts = attempts + 1, started_at = ?, error_json = NULL - WHERE run_id = ? AND step_index = ?`, - ) - .bind(nowIso, input.runId, input.stepIndex) - .run() - await this.bumpUpdated(input.runId, nowIso) - return { - ...prior, - attempts: prior.attempts + 1, - status: 'running', - startedAt: nowIso, - error: undefined, - } - } - await this.db - .prepare( - `INSERT INTO durable_steps - (run_id, step_index, intent, kind, input_hash, status, attempts, started_at) - VALUES (?, ?, ?, ?, ?, 'running', 1, ?)`, - ) - .bind(input.runId, input.stepIndex, input.intent, input.kind, input.inputHash, nowIso) - .run() - await this.db - .prepare( - `UPDATE durable_runs - SET step_count = MAX(step_count, ?), updated_at = ? - WHERE run_id = ?`, - ) - .bind(input.stepIndex + 1, nowIso, input.runId) - .run() - return { - runId: input.runId, - stepIndex: input.stepIndex, - intent: input.intent, - kind: input.kind, - inputHash: input.inputHash, - status: 'running', - attempts: 1, - startedAt: nowIso, - } - } - - async completeStep(input: { - runId: string - stepIndex: number - result: unknown - }): Promise { - const nowIso = new Date(this.now()).toISOString() - await this.db - .prepare( - `UPDATE durable_steps - SET status='completed', result_json = ?, completed_at = ?, error_json = NULL - WHERE run_id = ? AND step_index = ?`, - ) - .bind(JSON.stringify(input.result ?? null), nowIso, input.runId, input.stepIndex) - .run() - await this.bumpUpdated(input.runId, nowIso) - const row = await this.loadStep(input.runId, input.stepIndex) - if (!row) { - throw new Error(`durable-runs: completeStep cannot find step ${input.stepIndex}`) - } - return row - } - - async failStep(input: { - runId: string - stepIndex: number - error: StepError - }): Promise { - const nowIso = new Date(this.now()).toISOString() - await this.db - .prepare( - `UPDATE durable_steps - SET status='failed', error_json = ?, completed_at = ? - WHERE run_id = ? AND step_index = ?`, - ) - .bind(JSON.stringify(input.error), nowIso, input.runId, input.stepIndex) - .run() - await this.bumpUpdated(input.runId, nowIso) - const row = await this.loadStep(input.runId, input.stepIndex) - if (!row) { - throw new Error(`durable-runs: failStep cannot find step ${input.stepIndex}`) - } - return row - } - - async endRun(input: { - runId: string - workerId: string - status: 'completed' | 'failed' - outcome?: RunOutcome - }): Promise { - const nowIso = new Date(this.now()).toISOString() - await this.db - .prepare( - `UPDATE durable_runs - SET status = ?, completed_at = ?, updated_at = ?, - outcome_json = ?, - lease_holder_id = CASE WHEN lease_holder_id = ? THEN NULL ELSE lease_holder_id END, - lease_expires_at = CASE WHEN lease_holder_id = ? THEN NULL ELSE lease_expires_at END - WHERE run_id = ?`, - ) - .bind( - input.status, - nowIso, - nowIso, - input.outcome ? JSON.stringify(input.outcome) : null, - input.workerId, - input.workerId, - input.runId, - ) - .run() - const row = await this.db - .prepare('SELECT * FROM durable_runs WHERE run_id = ?') - .bind(input.runId) - .first() - if (!row) throw new Error(`durable-runs: endRun cannot find run ${input.runId}`) - return rowToRunRecord(row) - } - - async emitEvent(input: { - runId: string - key: string - payload: unknown - }): ReturnType { - const nowIso = new Date(this.now()).toISOString() - // INSERT OR IGNORE — first emit wins; subsequent inserts no-op. - const res = await this.db - .prepare( - `INSERT OR IGNORE INTO durable_events (run_id, key, payload_json, emitted_at) - VALUES (?, ?, ?, ?)`, - ) - .bind(input.runId, input.key, JSON.stringify(input.payload ?? null), nowIso) - .run() - const accepted = (res.meta?.changes ?? 0) > 0 - const row = await this.db - .prepare('SELECT * FROM durable_events WHERE run_id = ? AND key = ?') - .bind(input.runId, input.key) - .first() - if (!row) throw new Error('durable-runs: emitEvent failed to persist or read back') - return { - accepted, - record: rowToEventRecord(row), - } - } - - async loadEvent(runId: string, key: string): Promise { - const row = await this.db - .prepare('SELECT * FROM durable_events WHERE run_id = ? AND key = ?') - .bind(runId, key) - .first() - return row ? rowToEventRecord(row) : undefined - } - - async appendStreamEvent(input: { - runId: string - eventId: string - payload: unknown - }): ReturnType { - const nowIso = new Date(this.now()).toISOString() - // INSERT OR IGNORE — the UNIQUE(run_id, event_id) index makes a re-append - // a no-op; seq is the next monotonic value, computed atomically with the - // insert. A new event_id never collides on the (run_id, seq) PK. - const res = await this.db - .prepare( - `INSERT OR IGNORE INTO durable_stream_events (run_id, seq, event_id, payload_json, appended_at) - VALUES ( - ?, - (SELECT COALESCE(MAX(seq), -1) + 1 FROM durable_stream_events WHERE run_id = ?), - ?, ?, ? - )`, - ) - .bind(input.runId, input.runId, input.eventId, JSON.stringify(input.payload ?? null), nowIso) - .run() - const accepted = (res.meta?.changes ?? 0) > 0 - const row = await this.db - .prepare('SELECT * FROM durable_stream_events WHERE run_id = ? AND event_id = ?') - .bind(input.runId, input.eventId) - .first() - if (!row) throw new Error('durable-runs: appendStreamEvent failed to persist or read back') - return { accepted, record: rowToStreamEventRecord(row) } - } - - async readStreamEvents( - runId: string, - afterSeq?: number, - ): Promise> { - const { results } = await this.db - .prepare('SELECT * FROM durable_stream_events WHERE run_id = ? AND seq > ? ORDER BY seq') - .bind(runId, afterSeq ?? -1) - .all() - return results.map(rowToStreamEventRecord) - } - - async setRunHandle(input: { runId: string; handle: RunHandle }): Promise { - const nowIso = new Date(this.now()).toISOString() - await this.db - .prepare('UPDATE durable_runs SET handle_json = ?, updated_at = ? WHERE run_id = ?') - .bind(JSON.stringify(input.handle), nowIso, input.runId) - .run() - } - - async close(): Promise { - // D1 binding lifecycle is owned by the runtime; no-op. - } - - /** Inspect the currently-applied schema version. */ - async getSchemaVersion(): Promise { - const row = await this.db - .prepare('SELECT MAX(version) AS version FROM durable_schema_info') - .first<{ version: number | null }>() - return row?.version ?? undefined - } - - // ── internals ────────────────────────────────────────────────────── - - private async readSteps( - runId: string, - status: StepRecord['status'], - ): Promise> { - const { results } = await this.db - .prepare('SELECT * FROM durable_steps WHERE run_id = ? AND status = ? ORDER BY step_index') - .bind(runId, status) - .all() - return results.map(rowToStepRecord) - } - - private async bumpUpdated(runId: string, nowIso: string): Promise { - await this.db - .prepare('UPDATE durable_runs SET updated_at = ? WHERE run_id = ?') - .bind(nowIso, runId) - .run() - } -} - -// ── row → record helpers ─────────────────────────────────────────────── - -function rowToRunRecord(row: RunRow): RunRecord { - return { - runId: row.run_id, - manifestHash: row.manifest_hash, - projectId: row.project_id, - scenarioId: row.scenario_id ?? undefined, - status: row.status, - createdAt: row.created_at, - updatedAt: row.updated_at, - completedAt: row.completed_at ?? undefined, - leaseHolderId: row.lease_holder_id ?? undefined, - leaseExpiresAt: row.lease_expires_at ?? undefined, - outcome: row.outcome_json ? (JSON.parse(row.outcome_json) as RunOutcome) : undefined, - stepCount: row.step_count, - handle: row.handle_json ? (JSON.parse(row.handle_json) as RunHandle) : undefined, - } -} - -function rowToStreamEventRecord(row: StreamEventRow): StreamEventRecord { - return { - runId: row.run_id, - seq: row.seq, - eventId: row.event_id, - payload: row.payload_json ? JSON.parse(row.payload_json) : null, - appendedAt: row.appended_at, - } -} - -function rowToStepRecord(row: StepRow): StepRecord { - return { - runId: row.run_id, - stepIndex: row.step_index, - intent: row.intent, - kind: row.kind as StepKind, - inputHash: row.input_hash, - status: row.status, - attempts: row.attempts, - result: row.result_json ? JSON.parse(row.result_json) : undefined, - error: row.error_json ? (JSON.parse(row.error_json) as StepError) : undefined, - startedAt: row.started_at ?? undefined, - completedAt: row.completed_at ?? undefined, - } -} - -function rowToEventRecord(row: EventRow): EventRecord { - return { - runId: row.run_id, - key: row.key, - payload: row.payload_json ? JSON.parse(row.payload_json) : null, - emittedAt: row.emitted_at, - } -} diff --git a/src/durable/execution-handle.ts b/src/durable/execution-handle.ts new file mode 100644 index 00000000..cb278274 --- /dev/null +++ b/src/durable/execution-handle.ts @@ -0,0 +1,26 @@ +/** + * Derive a stable executionId from the run identity. The same + * `(projectId, sessionId, turnIndex)` tuple yields the same id — so a + * client retry of the same turn lands on the same substrate execution + * and the orchestrator's buffer replays instead of starting a second + * prompt. + * + * Format is readable, not hashed: operators grepping orchestrator logs + * for `gtm-agent:thread-abc:3` find the run without translating an + * opaque id. Substrate executionIds are not a secrecy boundary. + * + * Wire integration: + * - `@tangle-network/sandbox@0.1.x` PromptOptions does not yet expose + * `executionId`. The SDK auto-reconnects in-call by extracting it + * from the response `execution.started` event; products do nothing. + * - For cross-process reconnect today, bypass the SDK and POST to the + * orchestrator's `/agents/run/stream` directly with this id in the + * `X-Execution-ID` header (see tax-agent's `sessions.ts`). + */ +export function deriveExecutionId(input: { + projectId: string + sessionId: string + turnIndex: number +}): string { + return `${input.projectId}:${input.sessionId}:${input.turnIndex}` +} diff --git a/src/durable/file-system-store.ts b/src/durable/file-system-store.ts deleted file mode 100644 index bd71592d..00000000 --- a/src/durable/file-system-store.ts +++ /dev/null @@ -1,426 +0,0 @@ -/** - * FileSystemDurableRunStore — durable-run substrate backed by a directory - * tree under a single root. One subdir per run: - * - * // - * run.json — RunRecord (rewritten on every mutation; the only - * scalar fields are status/lease, so this stays small) - * steps.jsonl — append-only StepRecord stream; one JSON per line - * events.jsonl — append-only EventRecord stream - * lease.json — current leaseholder + deadline (separate from - * run.json so renewLease writes one tiny file - * instead of round-tripping the whole run record) - * - * Concurrency: the eval harness is single-process — we rely on Node's - * append-mode semantics for atomicity of step / event writes (single-line - * writes < PIPE_BUF are atomic on POSIX). For run.json / lease.json we write - * to a `.tmp` then `rename` to make replacement atomic. This is - * sufficient for the single-process eval harness use case. Multi-process - * concurrency on the SAME filesystem requires a flock-based extension; - * for that path use D1DurableRunStore. - */ - -import { existsSync, mkdirSync } from 'node:fs' -import { appendFile, readdir, readFile, rename, writeFile } from 'node:fs/promises' -import { join } from 'node:path' - -import { manifestHash } from './identity' -import { - DurableRunDivergenceError, - DurableRunInputMismatchError, - DurableRunLeaseHeldError, - type DurableRunManifest, - type DurableRunStore, - type EventRecord, - type RunHandle, - type RunOutcome, - type RunRecord, - type StepError, - type StepKind, - type StepRecord, - type StreamEventRecord, -} from './types' - -const DEFAULT_LEASE_MS = 30_000 - -interface LeaseFile { - workerId: string - leaseExpiresAt: string -} - -export class FileSystemDurableRunStore implements DurableRunStore { - constructor(private readonly root: string) { - mkdirSync(root, { recursive: true }) - } - - /** Override for tests — defaults to Date.now(). */ - public now: () => number = () => Date.now() - - async startOrResume(input: { - runId: string - manifest: DurableRunManifest - workerId: string - leaseMs?: number - }): ReturnType { - const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS - const hash = manifestHash(input.manifest) - const nowMs = this.now() - const nowIso = new Date(nowMs).toISOString() - const leaseExpiresAt = new Date(nowMs + leaseMs).toISOString() - const dir = this.runDir(input.runId) - - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - const record: RunRecord = { - runId: input.runId, - manifestHash: hash, - projectId: input.manifest.projectId, - scenarioId: input.manifest.scenarioId, - status: 'running', - createdAt: nowIso, - updatedAt: nowIso, - leaseHolderId: input.workerId, - leaseExpiresAt, - stepCount: 0, - } - await this.writeRun(record) - await this.writeLease(input.runId, { workerId: input.workerId, leaseExpiresAt }) - // Touch the jsonl files so listing them later doesn't ENOENT. - await appendFile(join(dir, 'steps.jsonl'), '', 'utf8') - await appendFile(join(dir, 'events.jsonl'), '', 'utf8') - await appendFile(join(dir, 'stream-events.jsonl'), '', 'utf8') - return { run: record, completedSteps: [], leaseExpiresAt } - } - - const record = await this.readRun(input.runId) - if (record.manifestHash !== hash) { - throw new DurableRunInputMismatchError( - `runId ${input.runId} exists with a different manifest hash; refuse to corrupt prior steps`, - ) - } - const lease = await this.readLeaseSafe(input.runId) - const leaseStillLive = - lease && lease.workerId !== input.workerId && new Date(lease.leaseExpiresAt).getTime() > nowMs - if (leaseStillLive) { - throw new DurableRunLeaseHeldError( - `runId ${input.runId} leased by ${lease.workerId} until ${lease.leaseExpiresAt}`, - ) - } - const completedSteps = await this.readSteps(input.runId) - const nextRecord: RunRecord = { - ...record, - status: - record.status === 'completed' || record.status === 'failed' ? record.status : 'running', - updatedAt: nowIso, - leaseHolderId: input.workerId, - leaseExpiresAt, - } - await this.writeRun(nextRecord) - await this.writeLease(input.runId, { workerId: input.workerId, leaseExpiresAt }) - return { run: nextRecord, completedSteps, leaseExpiresAt } - } - - async renewLease(input: { - runId: string - workerId: string - leaseMs?: number - }): Promise<{ ok: boolean; leaseExpiresAt?: string }> { - const lease = await this.readLeaseSafe(input.runId) - const nowMs = this.now() - if (lease && lease.workerId !== input.workerId) { - if (new Date(lease.leaseExpiresAt).getTime() > nowMs) { - return { ok: false } - } - } - const leaseExpiresAt = new Date(nowMs + (input.leaseMs ?? DEFAULT_LEASE_MS)).toISOString() - await this.writeLease(input.runId, { workerId: input.workerId, leaseExpiresAt }) - return { ok: true, leaseExpiresAt } - } - - async loadStep(runId: string, stepIndex: number): Promise { - const steps = await this.readSteps(runId, { includeFailed: true, includeRunning: true }) - return steps.find((s) => s.stepIndex === stepIndex) - } - - async beginStep(input: { - runId: string - stepIndex: number - intent: string - kind: StepKind - inputHash: string - }): Promise { - const all = await this.readSteps(input.runId, { includeFailed: true, includeRunning: true }) - const prior = all.find((s) => s.stepIndex === input.stepIndex) - const nowIso = new Date(this.now()).toISOString() - if (prior) { - if (prior.intent !== input.intent) { - throw new DurableRunDivergenceError( - `step ${input.stepIndex}: intent changed ('${prior.intent}' -> '${input.intent}')`, - ) - } - const rec: StepRecord = { - ...prior, - attempts: prior.attempts + 1, - status: 'running', - startedAt: nowIso, - error: undefined, - } - await this.appendStep(input.runId, rec) - await this.bumpRunUpdated(input.runId, nowIso) - return rec - } - const rec: StepRecord = { - runId: input.runId, - stepIndex: input.stepIndex, - intent: input.intent, - kind: input.kind, - inputHash: input.inputHash, - status: 'running', - attempts: 1, - startedAt: nowIso, - } - await this.appendStep(input.runId, rec) - // Bump run.stepCount opportunistically. - const record = await this.readRun(input.runId) - record.stepCount = Math.max(record.stepCount, input.stepIndex + 1) - record.updatedAt = nowIso - await this.writeRun(record) - return rec - } - - async completeStep(input: { - runId: string - stepIndex: number - result: unknown - }): Promise { - const all = await this.readSteps(input.runId, { includeFailed: true, includeRunning: true }) - const prior = all.find((s) => s.stepIndex === input.stepIndex) - if (!prior) { - throw new Error( - `durable-runs: completeStep called before beginStep (step ${input.stepIndex})`, - ) - } - const nowIso = new Date(this.now()).toISOString() - const rec: StepRecord = { - ...prior, - status: 'completed', - result: input.result, - completedAt: nowIso, - error: undefined, - } - await this.appendStep(input.runId, rec) - await this.bumpRunUpdated(input.runId, nowIso) - return rec - } - - async failStep(input: { - runId: string - stepIndex: number - error: StepError - }): Promise { - const all = await this.readSteps(input.runId, { includeFailed: true, includeRunning: true }) - const prior = all.find((s) => s.stepIndex === input.stepIndex) - if (!prior) { - throw new Error(`durable-runs: failStep called before beginStep (step ${input.stepIndex})`) - } - const nowIso = new Date(this.now()).toISOString() - const rec: StepRecord = { - ...prior, - status: 'failed', - error: input.error, - completedAt: nowIso, - } - await this.appendStep(input.runId, rec) - await this.bumpRunUpdated(input.runId, nowIso) - return rec - } - - async endRun(input: { - runId: string - workerId: string - status: 'completed' | 'failed' - outcome?: RunOutcome - }): Promise { - const record = await this.readRun(input.runId) - const nowIso = new Date(this.now()).toISOString() - record.status = input.status - record.outcome = input.outcome - record.completedAt = nowIso - record.updatedAt = nowIso - const lease = await this.readLeaseSafe(input.runId) - if (lease && lease.workerId === input.workerId) { - record.leaseHolderId = undefined - record.leaseExpiresAt = undefined - await this.writeLease(input.runId, null) - } - await this.writeRun(record) - return record - } - - async emitEvent(input: { - runId: string - key: string - payload: unknown - }): ReturnType { - const existing = await this.loadEvent(input.runId, input.key) - if (existing) return { accepted: false, record: existing } - const rec: EventRecord = { - runId: input.runId, - key: input.key, - payload: input.payload, - emittedAt: new Date(this.now()).toISOString(), - } - await appendFile( - join(this.runDir(input.runId), 'events.jsonl'), - `${JSON.stringify(rec)}\n`, - 'utf8', - ) - return { accepted: true, record: rec } - } - - async loadEvent(runId: string, key: string): Promise { - const path = join(this.runDir(runId), 'events.jsonl') - if (!existsSync(path)) return undefined - const content = await readFile(path, 'utf8') - for (const line of content.split('\n').reverse()) { - if (!line) continue - const rec = JSON.parse(line) as EventRecord - if (rec.key === key) return rec - } - return undefined - } - - async appendStreamEvent(input: { - runId: string - eventId: string - payload: unknown - }): ReturnType { - const existing = await this.readStreamEventsRaw(input.runId) - const dup = existing.find((e) => e.eventId === input.eventId) - if (dup) return { accepted: false, record: dup } - const rec: StreamEventRecord = { - runId: input.runId, - seq: existing.length, - eventId: input.eventId, - payload: input.payload, - appendedAt: new Date(this.now()).toISOString(), - } - await appendFile( - join(this.runDir(input.runId), 'stream-events.jsonl'), - `${JSON.stringify(rec)}\n`, - 'utf8', - ) - return { accepted: true, record: rec } - } - - async readStreamEvents( - runId: string, - afterSeq?: number, - ): Promise> { - const cutoff = afterSeq ?? -1 - return (await this.readStreamEventsRaw(runId)).filter((e) => e.seq > cutoff) - } - - async setRunHandle(input: { runId: string; handle: RunHandle }): Promise { - const record = await this.readRun(input.runId) - record.handle = input.handle - record.updatedAt = new Date(this.now()).toISOString() - await this.writeRun(record) - } - - async close(): Promise { - // No persistent handles to close. - } - - private async readStreamEventsRaw(runId: string): Promise { - const path = join(this.runDir(runId), 'stream-events.jsonl') - if (!existsSync(path)) return [] - const content = await readFile(path, 'utf8') - const out: StreamEventRecord[] = [] - for (const line of content.split('\n')) { - if (!line) continue - out.push(JSON.parse(line) as StreamEventRecord) - } - return out.sort((a, b) => a.seq - b.seq) - } - - /** @internal — used by tests to list runs in the store. */ - async _listRunIds(): Promise { - if (!existsSync(this.root)) return [] - const entries = await readdir(this.root, { withFileTypes: true }) - return entries.filter((e) => e.isDirectory()).map((e) => e.name) - } - - // ── internals ────────────────────────────────────────────────────── - - private runDir(runId: string): string { - return join(this.root, runId) - } - - private async readRun(runId: string): Promise { - const path = join(this.runDir(runId), 'run.json') - const content = await readFile(path, 'utf8') - return JSON.parse(content) as RunRecord - } - - private async writeRun(record: RunRecord): Promise { - const dir = this.runDir(record.runId) - const path = join(dir, 'run.json') - const tmp = `${path}.tmp` - await writeFile(tmp, JSON.stringify(record, null, 2), 'utf8') - await rename(tmp, path) - } - - private async readLeaseSafe(runId: string): Promise { - const path = join(this.runDir(runId), 'lease.json') - if (!existsSync(path)) return undefined - try { - const content = await readFile(path, 'utf8') - if (!content.trim()) return undefined - return JSON.parse(content) as LeaseFile - } catch { - return undefined - } - } - - private async writeLease(runId: string, lease: LeaseFile | null): Promise { - const path = join(this.runDir(runId), 'lease.json') - const tmp = `${path}.tmp` - await writeFile(tmp, lease ? JSON.stringify(lease) : '', 'utf8') - await rename(tmp, path) - } - - private async readSteps( - runId: string, - opts: { includeFailed?: boolean; includeRunning?: boolean } = {}, - ): Promise { - const path = join(this.runDir(runId), 'steps.jsonl') - if (!existsSync(path)) return [] - const content = await readFile(path, 'utf8') - // Append-only log: later writes for the same stepIndex override earlier - // ones. Walk forward and keep the latest per index. - const latest = new Map() - for (const line of content.split('\n')) { - if (!line) continue - const rec = JSON.parse(line) as StepRecord - latest.set(rec.stepIndex, rec) - } - const out = [...latest.values()].sort((a, b) => a.stepIndex - b.stepIndex) - return out.filter((s) => { - if (s.status === 'completed') return true - if (s.status === 'failed') return opts.includeFailed ?? false - if (s.status === 'running') return opts.includeRunning ?? false - return false - }) - } - - private async appendStep(runId: string, rec: StepRecord): Promise { - await appendFile(join(this.runDir(runId), 'steps.jsonl'), `${JSON.stringify(rec)}\n`, 'utf8') - } - - private async bumpRunUpdated(runId: string, nowIso: string): Promise { - const record = await this.readRun(runId) - record.updatedAt = nowIso - await this.writeRun(record) - } -} diff --git a/src/durable/identity.ts b/src/durable/identity.ts deleted file mode 100644 index ecae7740..00000000 --- a/src/durable/identity.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Identity + canonical-hash helpers for the durable-runs substrate. - * - * Two boundary disciplines: - * - * 1. **Manifest hash** — sha256 over a sorted-key JSON of (projectId, - * scenarioId, task.id, task.intent, task.domain, input). Same hash = - * same run identity. Used to detect "same runId, different inputs." - * - * 2. **Step input hash** — sha256 over a sorted-key JSON of the step's - * input fingerprint. Used to detect drift across replays. - * - * Sorted-key JSON makes hashes deterministic regardless of object insertion - * order. NaN / Infinity / undefined / functions / symbols / class instances - * are rejected — pure JSON only at the boundary, so the hash matches whatever - * the store round-trips. - */ - -import { createHash } from 'node:crypto' - -import type { DurableRunManifest } from './types' - -/** sha256-hex over a JSON-canonicalized value. */ -export function canonicalHash(value: unknown): string { - const json = canonicalJson(value) - return createHash('sha256').update(json).digest('hex') -} - -/** Canonical JSON: object keys sorted lexicographically; arrays preserved. */ -export function canonicalJson(value: unknown): string { - return JSON.stringify(canonicalize(value)) -} - -function canonicalize(value: unknown): unknown { - if (value === null) return null - if (Array.isArray(value)) return value.map(canonicalize) - const t = typeof value - if (t === 'string' || t === 'boolean') return value - if (t === 'number') { - if (!Number.isFinite(value as number)) { - throw new TypeError(`canonicalJson: non-finite number ${String(value)} not serializable`) - } - return value - } - if (t === 'undefined' || t === 'function' || t === 'symbol') { - throw new TypeError(`canonicalJson: ${t} is not JSON-serializable`) - } - if (t === 'bigint') { - // BigInts have no JSON representation. Encode as string; tag for - // disambiguation so consumers can choose to reject or decode. - return { __bigint: String(value) } - } - if (t === 'object') { - const obj = value as Record - // Reject class instances — Date / Error / Map / Set / TypedArray — to - // force callers to project to plain JSON at the boundary. Errors round- - // tripped silently are the #1 source of "looks the same but differs" - // bugs. - const proto = Object.getPrototypeOf(obj) - if (proto !== null && proto !== Object.prototype) { - const ctor = obj.constructor?.name ?? 'unknown' - throw new TypeError( - `canonicalJson: class instance (${ctor}) is not JSON-serializable. Project to plain { ... } at the boundary.`, - ) - } - const keys = Object.keys(obj).sort() - const out: Record = {} - for (const k of keys) out[k] = canonicalize(obj[k]) - return out - } - throw new TypeError(`canonicalJson: unsupported type ${t}`) -} - -/** Hash a DurableRunManifest into the run identity component. */ -export function manifestHash(manifest: DurableRunManifest): string { - return canonicalHash({ - projectId: manifest.projectId, - scenarioId: manifest.scenarioId ?? null, - taskId: manifest.task.id, - taskIntent: manifest.task.intent, - taskDomain: manifest.task.domain, - input: manifest.input, - }) -} - -/** Stable per-step identifier — hash of (runId, position, intent). */ -export function stepId(runId: string, stepIndex: number, intent: string): string { - return canonicalHash({ runId, stepIndex, intent }) -} - -let counter = 0 -/** - * Stable worker id for a single process. Format: `host:pid:rand`. Random - * suffix prevents collisions when the host/pid pair is short-lived (e.g., - * Cloudflare isolates that recycle fast). - */ -export function deriveWorkerId(): string { - const host = process.env.HOSTNAME ?? 'host' - const pid = process.pid ?? 0 - const rand = Math.random().toString(36).slice(2, 10) - counter += 1 - return `${host}:${pid}:${rand}:${counter}` -} diff --git a/src/durable/in-memory-store.ts b/src/durable/in-memory-store.ts deleted file mode 100644 index e51fa321..00000000 --- a/src/durable/in-memory-store.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * In-memory DurableRunStore for dev + tests. Single-process. All state lives - * in maps. Lease enforcement is real (Date.now() vs lease deadline) so the - * crash-recovery + multi-worker race tests run identically against this and - * the file-system / D1 stores. - */ - -import { manifestHash } from './identity' -import type { - DurableRunManifest, - DurableRunStore, - EventRecord, - RunHandle, - RunOutcome, - RunRecord, - StepError, - StepKind, - StepRecord, - StreamEventRecord, -} from './types' -import { - DurableRunDivergenceError, - DurableRunInputMismatchError, - DurableRunLeaseHeldError, -} from './types' - -const DEFAULT_LEASE_MS = 30_000 - -interface RunState { - record: RunRecord - steps: Map - events: Map - /** Ordered, replayable event-stream log — `seq` is the array index. */ - streamEvents: StreamEventRecord[] -} - -export class InMemoryDurableRunStore implements DurableRunStore { - private readonly runs = new Map() - /** Override for tests — defaults to Date.now(). */ - public now: () => number = () => Date.now() - - async startOrResume(input: { - runId: string - manifest: DurableRunManifest - workerId: string - leaseMs?: number - }): ReturnType { - const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS - const hash = manifestHash(input.manifest) - const nowMs = this.now() - const nowIso = new Date(nowMs).toISOString() - const leaseExpiresMs = nowMs + leaseMs - const leaseExpiresAt = new Date(leaseExpiresMs).toISOString() - - let state = this.runs.get(input.runId) - if (!state) { - const record: RunRecord = { - runId: input.runId, - manifestHash: hash, - projectId: input.manifest.projectId, - scenarioId: input.manifest.scenarioId, - status: 'running', - createdAt: nowIso, - updatedAt: nowIso, - leaseHolderId: input.workerId, - leaseExpiresAt, - stepCount: 0, - } - state = { record, steps: new Map(), events: new Map(), streamEvents: [] } - this.runs.set(input.runId, state) - return { run: { ...record }, completedSteps: [], leaseExpiresAt } - } - - if (state.record.manifestHash !== hash) { - throw new DurableRunInputMismatchError( - `runId ${input.runId} exists with a different manifest hash; refuse to corrupt prior steps`, - ) - } - // Lease check — held by another worker with a non-expired lease. - const leaseStillLive = - state.record.leaseHolderId !== undefined && - state.record.leaseHolderId !== input.workerId && - state.record.leaseExpiresAt !== undefined && - new Date(state.record.leaseExpiresAt).getTime() > nowMs - if (leaseStillLive) { - throw new DurableRunLeaseHeldError( - `runId ${input.runId} is leased by ${state.record.leaseHolderId} until ${state.record.leaseExpiresAt}`, - ) - } - // Acquire / renew. - state.record.leaseHolderId = input.workerId - state.record.leaseExpiresAt = leaseExpiresAt - state.record.status = - state.record.status === 'completed' || state.record.status === 'failed' - ? state.record.status - : 'running' - state.record.updatedAt = nowIso - const completed = [...state.steps.values()] - .filter((s) => s.status === 'completed') - .sort((a, b) => a.stepIndex - b.stepIndex) - return { - run: { ...state.record }, - completedSteps: completed.map((s) => ({ ...s })), - leaseExpiresAt, - } - } - - async renewLease(input: { - runId: string - workerId: string - leaseMs?: number - }): Promise<{ ok: boolean; leaseExpiresAt?: string }> { - const state = this.runs.get(input.runId) - if (!state) return { ok: false } - const nowMs = this.now() - if (state.record.leaseHolderId !== input.workerId) { - // Lease lapsed — another worker may have taken over. - if (state.record.leaseExpiresAt && new Date(state.record.leaseExpiresAt).getTime() > nowMs) { - return { ok: false } - } - } - const leaseExpiresMs = nowMs + (input.leaseMs ?? DEFAULT_LEASE_MS) - const leaseExpiresAt = new Date(leaseExpiresMs).toISOString() - state.record.leaseHolderId = input.workerId - state.record.leaseExpiresAt = leaseExpiresAt - state.record.updatedAt = new Date(nowMs).toISOString() - return { ok: true, leaseExpiresAt } - } - - async loadStep(runId: string, stepIndex: number): Promise { - const state = this.runs.get(runId) - return state ? cloneStep(state.steps.get(stepIndex)) : undefined - } - - async beginStep(input: { - runId: string - stepIndex: number - intent: string - kind: StepKind - inputHash: string - }): Promise { - const state = this.requireRun(input.runId) - const nowIso = new Date(this.now()).toISOString() - const prior = state.steps.get(input.stepIndex) - if (prior) { - if (prior.intent !== input.intent) { - throw new DurableRunDivergenceError( - `step ${input.stepIndex}: intent changed across replays ('${prior.intent}' -> '${input.intent}')`, - ) - } - // Begin called again — bump attempts. A prior failed or running step - // can re-execute; a prior completed step would be filtered before begin - // is called (the runner short-circuits on cached results). - prior.attempts += 1 - prior.status = 'running' - prior.startedAt = nowIso - prior.error = undefined - state.record.updatedAt = nowIso - return cloneStep(prior)! - } - const rec: StepRecord = { - runId: input.runId, - stepIndex: input.stepIndex, - intent: input.intent, - kind: input.kind, - inputHash: input.inputHash, - status: 'running', - attempts: 1, - startedAt: nowIso, - } - state.steps.set(input.stepIndex, rec) - state.record.stepCount = Math.max(state.record.stepCount, input.stepIndex + 1) - state.record.updatedAt = nowIso - return cloneStep(rec)! - } - - async completeStep(input: { - runId: string - stepIndex: number - result: unknown - }): Promise { - const state = this.requireRun(input.runId) - const rec = state.steps.get(input.stepIndex) - if (!rec) { - throw new Error( - `durable-runs: completeStep called before beginStep (step ${input.stepIndex})`, - ) - } - const nowIso = new Date(this.now()).toISOString() - rec.status = 'completed' - rec.result = input.result - rec.completedAt = nowIso - rec.error = undefined - state.record.updatedAt = nowIso - return cloneStep(rec)! - } - - async failStep(input: { - runId: string - stepIndex: number - error: StepError - }): Promise { - const state = this.requireRun(input.runId) - const rec = state.steps.get(input.stepIndex) - if (!rec) { - throw new Error(`durable-runs: failStep called before beginStep (step ${input.stepIndex})`) - } - const nowIso = new Date(this.now()).toISOString() - rec.status = 'failed' - rec.error = input.error - rec.completedAt = nowIso - state.record.updatedAt = nowIso - return cloneStep(rec)! - } - - async endRun(input: { - runId: string - workerId: string - status: 'completed' | 'failed' - outcome?: RunOutcome - }): Promise { - const state = this.requireRun(input.runId) - const nowIso = new Date(this.now()).toISOString() - state.record.status = input.status - state.record.outcome = input.outcome - state.record.completedAt = nowIso - state.record.updatedAt = nowIso - // Release lease iff caller still holds it. - if (state.record.leaseHolderId === input.workerId) { - state.record.leaseHolderId = undefined - state.record.leaseExpiresAt = undefined - } - return { ...state.record } - } - - async emitEvent(input: { - runId: string - key: string - payload: unknown - }): ReturnType { - const state = this.requireRun(input.runId) - const existing = state.events.get(input.key) - if (existing) { - return { accepted: false, record: { ...existing } } - } - const rec: EventRecord = { - runId: input.runId, - key: input.key, - payload: input.payload, - emittedAt: new Date(this.now()).toISOString(), - } - state.events.set(input.key, rec) - return { accepted: true, record: { ...rec } } - } - - async loadEvent(runId: string, key: string): Promise { - const state = this.runs.get(runId) - if (!state) return undefined - const rec = state.events.get(key) - return rec ? { ...rec } : undefined - } - - async appendStreamEvent(input: { - runId: string - eventId: string - payload: unknown - }): ReturnType { - const state = this.requireRun(input.runId) - const existing = state.streamEvents.find((e) => e.eventId === input.eventId) - if (existing) return { accepted: false, record: { ...existing } } - const rec: StreamEventRecord = { - runId: input.runId, - seq: state.streamEvents.length, - eventId: input.eventId, - payload: input.payload, - appendedAt: new Date(this.now()).toISOString(), - } - state.streamEvents.push(rec) - return { accepted: true, record: { ...rec } } - } - - async readStreamEvents( - runId: string, - afterSeq?: number, - ): Promise> { - const state = this.runs.get(runId) - if (!state) return [] - const cutoff = afterSeq ?? -1 - return state.streamEvents.filter((e) => e.seq > cutoff).map((e) => ({ ...e })) - } - - async setRunHandle(input: { runId: string; handle: RunHandle }): Promise { - const state = this.requireRun(input.runId) - state.record.handle = { ...input.handle } - state.record.updatedAt = new Date(this.now()).toISOString() - } - - async close(): Promise { - this.runs.clear() - } - - // ── test helpers ─────────────────────────────────────────────────── - /** @internal — used by tests to inspect lease metadata. */ - _inspect(runId: string): RunRecord | undefined { - const s = this.runs.get(runId) - return s ? { ...s.record } : undefined - } - - /** @internal — used by tests to simulate lease expiry. */ - _expireLease(runId: string): void { - const s = this.runs.get(runId) - if (s) { - s.record.leaseHolderId = undefined - s.record.leaseExpiresAt = undefined - } - } - - private requireRun(runId: string): RunState { - const s = this.runs.get(runId) - if (!s) { - throw new Error(`durable-runs: run ${runId} not found (must call startOrResume first)`) - } - return s - } -} - -function cloneStep(rec: StepRecord | undefined): StepRecord | undefined { - if (!rec) return undefined - return { ...rec, error: rec.error ? { ...rec.error } : undefined } -} diff --git a/src/durable/index.ts b/src/durable/index.ts index a89e7aae..8f862aa9 100644 --- a/src/durable/index.ts +++ b/src/durable/index.ts @@ -1,92 +1,25 @@ /** - * Durable-run substrate for `@tangle-network/agent-runtime`. + * Turn-lifecycle helpers for `@tangle-network/agent-runtime`. * - * Public surface — what consumers import. Implementations live in - * sibling files (in-memory / file-system / D1). Runner + ctx live in - * runner.ts. Identity helpers live in identity.ts. + * Execution state — long-running execution, reconnect, replay, dedup — + * lives in the substrate (`@tangle-network/sandbox` + orchestrator). + * agent-runtime owns: * - * See `./types.ts` for the full type contract and concurrency model. + * - `handleChatTurn` — framework-neutral turn lifecycle: NDJSON framing, + * `session.run.*` envelope, persist / post-process / trace-flush + * hook ordering. + * - `deriveExecutionId` — convention helper for the stable id products + * persist so a retry of the same turn lands on the same execution. */ -// ── Durable chat-turn engine ────────────────────────────────────────── -// Framework-neutral chat-turn orchestrator: durable turn + NDJSON -// streaming + session.run.* lifecycle + product persist/post-process -// hooks. Every product chat handler routes through this. +export { deriveExecutionId } from './execution-handle' + export type { ChatStreamEvent, ChatTurnHooks, ChatTurnIdentity, + ChatTurnProducer, ChatTurnResult, RunChatTurnInput, } from './chat-engine' -export { DurableChatTurnEngine, durableChatTurnEngine } from './chat-engine' -export type { D1DatabaseLike, D1PreparedStatementLike } from './d1-store' -export { D1DurableRunStore } from './d1-store' -export { FileSystemDurableRunStore } from './file-system-store' -export { canonicalHash, canonicalJson, deriveWorkerId, manifestHash, stepId } from './identity' -export { InMemoryDurableRunStore } from './in-memory-store' -export type { DurableContext, RunDurableInput, RunDurableResult } from './runner' -export { runDurable } from './runner' -// Canonical D1 schema string + current version. Consumers wire via -// await env.DB.exec(DURABLE_SCHEMA_SQL) -// during one-time bootstrap. `src/durable/schema.sql` is the source of -// truth; `schema.ts` is the build-bundled string that ships in dist/. -export { DURABLE_SCHEMA_SQL, DURABLE_SCHEMA_VERSION } from './schema' -// ── Durable-run supervisor — cross-worker / cross-DO durability ─────── -// Relocates the durability boundary off the ephemeral worker isolate: the -// supervisor drains a run's event stream into the substrate's own log, and -// a fresh supervisor re-attaches from the persisted cursor instead of -// re-prompting. SessionSupervisorDO hosts it on a Cloudflare Durable Object. -export type { - DurableObjectStateLike, - DurableObjectStorageLike, - SessionSupervisorDO, - SupervisorHostConfig, -} from './session-supervisor-do' -export { createSessionSupervisorDO } from './session-supervisor-do' -export type { - RunSupervisorOptions, - SandboxReconnectAdapter, - SupervisedEvent, - SupervisedRunHandle, - SupervisedRunMode, -} from './supervisor' -export { runSupervisedTurn } from './supervisor' -// ── Durable turn primitive ──────────────────────────────────────────── -// Streaming, backend-agnostic, checkpoint+replay durable turn. The single -// reusable primitive every product chat handler routes through. -export type { - DurableTurnHandle, - DurableTurnProducer, - RunDurableTurnOptions, -} from './turn' -export { runDurableTurn } from './turn' -export type { - DurableRunManifest, - DurableRunStore, - EventRecord, - RunHandle, - RunOutcome, - RunRecord, - RunStatus, - StepError, - StepKind, - StepRecord, - StepStatus, - StreamEventRecord, -} from './types' -export { - DurableAwaitEventTimeoutError, - DurableRunDivergenceError, - DurableRunError, - DurableRunInputMismatchError, - DurableRunLeaseHeldError, -} from './types' - -// ── Cloudflare Workflows integration ────────────────────────────────── -export type { - RunOnWorkflowStepInput, - WorkflowStepConfig, - WorkflowStepLike, -} from './workflows' -export { runOnWorkflowStep } from './workflows' +export { handleChatTurn } from './chat-engine' diff --git a/src/durable/runner.ts b/src/durable/runner.ts deleted file mode 100644 index 1b4da645..00000000 --- a/src/durable/runner.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Durable runner — wraps a user-supplied async function in checkpoint / - * resume / lease semantics. The user writes plain async code, awaiting - * `ctx.step(intent, fn)` boundaries. On worker crash, the next caller with - * the same `runId` skips completed steps and resumes from the first unfinished - * one. - * - * Invariants: - * - * - Step positions are derived from a monotonic counter on the ctx. The - * same intent at position N is the same step across replays. If the user - * reorders steps, position N changes intent and we raise - * DurableRunDivergenceError fail-loud. - * - * - `ctx.now()` and `ctx.uuid()` are checkpointed as zero-input logic steps - * with kind='deterministic'. On replay they return the recorded value. - * - * - `awaitEvent` writes a 'event' step that records the event payload on - * first awaited completion. On replay, the cached payload returns - * synchronously. If the event has not been emitted and the runner is in - * a fresh execution, it polls the store until timeout. - * - * - Lease renewal happens on a wall-clock interval (every leaseMs/3). If - * the store reports a lost lease, the runner aborts the current step - * execution and throws — letting whichever worker holds the lease pick - * up. Committed steps survive. - */ - -import { canonicalHash, deriveWorkerId } from './identity' -import type { - DurableRunManifest, - DurableRunStore, - RunOutcome, - RunRecord, - StepKind, - StepRecord, -} from './types' -import { DurableAwaitEventTimeoutError, DurableRunDivergenceError } from './types' - -export interface DurableContext { - readonly runId: string - readonly projectId: string - readonly scenarioId?: string - - /** - * Execute a checkpointed step. The step is identified by its **position** - * (monotonic counter on this ctx); `intent` is a human-readable label that - * must stay stable across replays. - * - * On first execution: runs `fn`, records the result, returns it. - * On replay: returns the recorded result WITHOUT calling `fn`. - * - * The `inputFingerprint` (optional) lets the runner detect "same intent, - * different inputs" — it gets hashed and compared. If you don't supply - * one, drift is allowed (input not checked). - */ - step( - intent: string, - fn: () => Promise, - opts?: { kind?: StepKind; inputFingerprint?: unknown }, - ): Promise - - /** Race-free first-emit-wins event wait. */ - awaitEvent(key: string, opts?: { timeoutMs?: number; pollMs?: number }): Promise - - /** Emit an event. First emit wins. Subsequent emits no-op. */ - emitEvent(key: string, payload: unknown): Promise<{ accepted: boolean }> - - /** Deterministic clock — checkpointed once per call. */ - now(): Promise - - /** Deterministic uuid — checkpointed once per call. */ - uuid(): Promise -} - -const DEFAULT_LEASE_MS = 30_000 -const DEFAULT_AWAIT_POLL_MS = 250 - -export interface RunDurableInput { - runId: string - manifest: DurableRunManifest - store: DurableRunStore - workerId?: string - leaseMs?: number - /** Total time budget for the run. Used for awaitEvent timeouts; runner - * itself doesn't kill long-running steps (the step fn must respect - * AbortSignal if it cares). */ - signal?: AbortSignal - taskFn: (ctx: DurableContext) => Promise - /** Default outcome on successful completion. */ - defaultOutcome?: RunOutcome -} - -export interface RunDurableResult { - result: TResult - record: RunRecord - /** All steps captured this run (replayed + freshly executed). */ - steps: ReadonlyArray -} - -export async function runDurable( - input: RunDurableInput, -): Promise> { - const workerId = input.workerId ?? deriveWorkerId() - const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS - const { completedSteps } = await input.store.startOrResume({ - runId: input.runId, - manifest: input.manifest, - workerId, - leaseMs, - }) - - // Pre-compute a position-indexed view of prior steps for O(1) replay - // lookups. We DON'T trust that the store returned them in order — sort. - const priorByIndex = new Map() - for (const s of completedSteps) priorByIndex.set(s.stepIndex, s) - - const collected: StepRecord[] = [...completedSteps].sort((a, b) => a.stepIndex - b.stepIndex) - - // Lease renewal heartbeat — best-effort. Errors are surfaced via - // `leaseLost` so steps can short-circuit. - let leaseLost = false - const heartbeatIntervalMs = Math.max(1_000, Math.floor(leaseMs / 3)) - const heartbeat = setInterval(() => { - void input.store - .renewLease({ runId: input.runId, workerId, leaseMs }) - .then((res) => { - if (!res.ok) leaseLost = true - }) - .catch(() => { - leaseLost = true - }) - }, heartbeatIntervalMs) - // unref() so the heartbeat doesn't keep the process alive on test exit. - if (typeof heartbeat.unref === 'function') heartbeat.unref() - - let positionCounter = 0 - - const ctx: DurableContext = { - runId: input.runId, - projectId: input.manifest.projectId, - scenarioId: input.manifest.scenarioId, - async step( - intent: string, - fn: () => Promise, - opts?: { kind?: StepKind; inputFingerprint?: unknown }, - ): Promise { - checkAbortAndLease(input.signal, leaseLost) - const stepIndex = positionCounter++ - const prior = priorByIndex.get(stepIndex) - const inputHash = - opts?.inputFingerprint !== undefined ? canonicalHash(opts.inputFingerprint) : '' - if (prior && prior.status === 'completed') { - // Replay path — return cached result. - if (prior.intent !== intent) { - throw new DurableRunDivergenceError( - `step ${stepIndex}: intent changed across replays ('${prior.intent}' -> '${intent}')`, - ) - } - return prior.result as T - } - // Begin a fresh attempt (either net-new or retry of a failed step). - const begun = await input.store.beginStep({ - runId: input.runId, - stepIndex, - intent, - kind: opts?.kind ?? 'logic', - inputHash, - }) - try { - const result = await fn() - const completed = await input.store.completeStep({ - runId: input.runId, - stepIndex, - result, - }) - upsertCollected(collected, completed) - priorByIndex.set(stepIndex, completed) - return result - } catch (err) { - const failed = await input.store.failStep({ - runId: input.runId, - stepIndex, - error: toStepError(err), - }) - upsertCollected(collected, failed) - priorByIndex.set(stepIndex, failed) - throw err - } finally { - // The `begun` reference suppresses "unused" warnings without an - // eslint-disable comment. - void begun - } - }, - async awaitEvent(key: string, opts?: { timeoutMs?: number; pollMs?: number }): Promise { - const stepIndex = positionCounter++ - const prior = priorByIndex.get(stepIndex) - if (prior && prior.status === 'completed') { - if (prior.intent !== `event:${key}`) { - throw new DurableRunDivergenceError( - `step ${stepIndex}: awaitEvent key changed across replays`, - ) - } - return prior.result as T - } - const beginAt = Date.now() - const timeoutMs = opts?.timeoutMs ?? 60_000 - const pollMs = opts?.pollMs ?? DEFAULT_AWAIT_POLL_MS - await input.store.beginStep({ - runId: input.runId, - stepIndex, - intent: `event:${key}`, - kind: 'event', - inputHash: '', - }) - try { - for (;;) { - checkAbortAndLease(input.signal, leaseLost) - const evt = await input.store.loadEvent(input.runId, key) - if (evt) { - const completed = await input.store.completeStep({ - runId: input.runId, - stepIndex, - result: evt.payload, - }) - upsertCollected(collected, completed) - priorByIndex.set(stepIndex, completed) - return evt.payload as T - } - if (Date.now() - beginAt > timeoutMs) { - const err = new DurableAwaitEventTimeoutError( - `awaitEvent('${key}') timed out after ${timeoutMs}ms`, - ) - const failed = await input.store.failStep({ - runId: input.runId, - stepIndex, - error: toStepError(err), - }) - upsertCollected(collected, failed) - priorByIndex.set(stepIndex, failed) - throw err - } - await sleep(pollMs, input.signal) - } - } catch (err) { - // Any non-timeout error: mark failed (if not already), surface. - if (!(err instanceof DurableAwaitEventTimeoutError)) { - const existing = priorByIndex.get(stepIndex) - if (!existing || existing.status !== 'failed') { - const failed = await input.store.failStep({ - runId: input.runId, - stepIndex, - error: toStepError(err), - }) - upsertCollected(collected, failed) - priorByIndex.set(stepIndex, failed) - } - } - throw err - } - }, - async emitEvent(key: string, payload: unknown): Promise<{ accepted: boolean }> { - const res = await input.store.emitEvent({ runId: input.runId, key, payload }) - return { accepted: res.accepted } - }, - async now(): Promise { - const v = await this.step(`deterministic:now`, async () => new Date().toISOString(), { - kind: 'deterministic', - }) - return new Date(v) - }, - async uuid(): Promise { - return this.step(`deterministic:uuid`, async () => cryptoRandomUuid(), { - kind: 'deterministic', - }) - }, - } - - try { - const result = await input.taskFn(ctx) - const finalRecord = await input.store.endRun({ - runId: input.runId, - workerId, - status: 'completed', - outcome: input.defaultOutcome, - }) - return { result, record: finalRecord, steps: collected } - } catch (err) { - const finalRecord = await input.store.endRun({ - runId: input.runId, - workerId, - status: 'failed', - outcome: { - ...input.defaultOutcome, - notes: err instanceof Error ? err.message : String(err), - }, - }) - void finalRecord - throw err - } finally { - clearInterval(heartbeat) - } -} - -function checkAbortAndLease(signal: AbortSignal | undefined, leaseLost: boolean): void { - if (signal?.aborted) throw signal.reason ?? new Error('aborted') - if (leaseLost) throw new Error('durable-runs: lease lost; another worker has taken over this run') -} - -function upsertCollected(list: StepRecord[], rec: StepRecord): void { - const i = list.findIndex((s) => s.stepIndex === rec.stepIndex) - if (i === -1) list.push(rec) - else list[i] = rec -} - -function toStepError(err: unknown): { message: string; code?: string; stack?: string } { - if (err instanceof Error) { - return { - message: err.message, - code: (err as { code?: string }).code, - stack: err.stack, - } - } - return { message: String(err) } -} - -function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason ?? new Error('aborted')) - return - } - const t = setTimeout(() => { - signal?.removeEventListener('abort', onAbort) - resolve() - }, ms) - const onAbort = () => { - clearTimeout(t) - reject(signal?.reason ?? new Error('aborted')) - } - signal?.addEventListener('abort', onAbort, { once: true }) - }) -} - -function cryptoRandomUuid(): string { - // Defensive: globalThis.crypto.randomUUID may not be available in older - // Node — fall back to a manual v4 derivation. - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID() - } - const bytes = new Uint8Array(16) - for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256) - bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 - bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} diff --git a/src/durable/schema.sql b/src/durable/schema.sql deleted file mode 100644 index 1cc8aabc..00000000 --- a/src/durable/schema.sql +++ /dev/null @@ -1,96 +0,0 @@ --- Durable-run substrate — versioned schema for D1 / SQLite. --- --- Apply once per database. Subsequent migrations append; never rewrite a --- prior version. See `durable_schema_info` for the migration trail. --- --- Concurrency notes for D1: --- - SQLite supports UNIQUE constraints for first-emit-wins (`durable_events` --- PK is (run_id, key) — duplicate insert raises, caller treats as "already --- emitted"). --- - Lease takeover happens via a conditional UPDATE: we only claim the lease --- if (lease_holder_id IS NULL OR lease_expires_at < :now) — atomic under --- SQLite's row-level locking. --- - All timestamps stored as ISO-8601 TEXT for cross-platform consistency. --- - `result_json` / `error_json` / `outcome_json` / `payload_json` are --- JSON-encoded TEXT; the application enforces canonical-JSON discipline at --- the boundary so the store stays type-agnostic. - -CREATE TABLE IF NOT EXISTS durable_schema_info ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS durable_runs ( - run_id TEXT PRIMARY KEY, - manifest_hash TEXT NOT NULL, - project_id TEXT NOT NULL, - scenario_id TEXT, - status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed','suspended')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - lease_holder_id TEXT, - lease_expires_at TEXT, - outcome_json TEXT, - step_count INTEGER NOT NULL DEFAULT 0 -); - -CREATE INDEX IF NOT EXISTS idx_durable_runs_project_status ON durable_runs(project_id, status); -CREATE INDEX IF NOT EXISTS idx_durable_runs_lease_expires ON durable_runs(lease_expires_at); - -CREATE TABLE IF NOT EXISTS durable_steps ( - run_id TEXT NOT NULL, - step_index INTEGER NOT NULL, - intent TEXT NOT NULL, - kind TEXT NOT NULL, - input_hash TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed')), - attempts INTEGER NOT NULL DEFAULT 0, - result_json TEXT, - error_json TEXT, - started_at TEXT, - completed_at TEXT, - PRIMARY KEY (run_id, step_index) -); - -CREATE INDEX IF NOT EXISTS idx_durable_steps_status ON durable_steps(run_id, status); - -CREATE TABLE IF NOT EXISTS durable_events ( - run_id TEXT NOT NULL, - key TEXT NOT NULL, - payload_json TEXT, - emitted_at TEXT NOT NULL, - PRIMARY KEY (run_id, key) -); - -INSERT OR IGNORE INTO durable_schema_info (version, applied_at) -VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); - --- ── Migration v2 — durable event-stream log + run handle ─────────────── --- Run once on a database created at v1. `ALTER TABLE` is not idempotent; the --- version trail in `durable_schema_info` is how migrations are sequenced — --- never by blind re-execution of this block. --- --- - `durable_stream_events` is the ordered, replayable per-run event log. --- `seq` is the store-assigned monotonic cursor; the UNIQUE index on --- (run_id, event_id) makes appends idempotent — a reconnecting adapter --- that re-yields a boundary event cannot double-log it. --- - `durable_runs.handle_json` is the pointer (sandbox + substrate run id + --- cursor) a fresh supervisor re-attaches by. - -ALTER TABLE durable_runs ADD COLUMN handle_json TEXT; - -CREATE TABLE IF NOT EXISTS durable_stream_events ( - run_id TEXT NOT NULL, - seq INTEGER NOT NULL, - event_id TEXT NOT NULL, - payload_json TEXT, - appended_at TEXT NOT NULL, - PRIMARY KEY (run_id, seq) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_durable_stream_events_event_id - ON durable_stream_events(run_id, event_id); - -INSERT OR IGNORE INTO durable_schema_info (version, applied_at) -VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); diff --git a/src/durable/schema.ts b/src/durable/schema.ts deleted file mode 100644 index d4a43d70..00000000 --- a/src/durable/schema.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * The durable-runs SQL schema as a string constant. Inlined so consumers - * (Cloudflare Workers via D1) can apply it without bundling a `.sql` file: - * - * import { DURABLE_SCHEMA_SQL } from '@tangle-network/agent-runtime' - * await env.DB.exec(DURABLE_SCHEMA_SQL) - * - * The canonical source is `src/durable/schema.sql` — this string MUST stay - * byte-identical to it. The build does not copy `.sql` files into `dist/`, - * so the constant is the only path consumers have. A unit test asserts the - * two stay in sync (`durable-schema.test.ts`). - * - * `DURABLE_SCHEMA_VERSION` reflects the latest migration version applied by - * this string. Bump it on every backwards-incompatible change AND add a new - * migration entry to durable_schema_info instead of mutating prior rows. - */ - -export const DURABLE_SCHEMA_VERSION = 2 - -export const DURABLE_SCHEMA_SQL = `-- Durable-run substrate — versioned schema for D1 / SQLite. --- --- Apply once per database. Subsequent migrations append; never rewrite a --- prior version. See \`durable_schema_info\` for the migration trail. --- --- Concurrency notes for D1: --- - SQLite supports UNIQUE constraints for first-emit-wins (\`durable_events\` --- PK is (run_id, key) — duplicate insert raises, caller treats as "already --- emitted"). --- - Lease takeover happens via a conditional UPDATE: we only claim the lease --- if (lease_holder_id IS NULL OR lease_expires_at < :now) — atomic under --- SQLite's row-level locking. --- - All timestamps stored as ISO-8601 TEXT for cross-platform consistency. --- - \`result_json\` / \`error_json\` / \`outcome_json\` / \`payload_json\` are --- JSON-encoded TEXT; the application enforces canonical-JSON discipline at --- the boundary so the store stays type-agnostic. - -CREATE TABLE IF NOT EXISTS durable_schema_info ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS durable_runs ( - run_id TEXT PRIMARY KEY, - manifest_hash TEXT NOT NULL, - project_id TEXT NOT NULL, - scenario_id TEXT, - status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed','suspended')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - lease_holder_id TEXT, - lease_expires_at TEXT, - outcome_json TEXT, - step_count INTEGER NOT NULL DEFAULT 0 -); - -CREATE INDEX IF NOT EXISTS idx_durable_runs_project_status ON durable_runs(project_id, status); -CREATE INDEX IF NOT EXISTS idx_durable_runs_lease_expires ON durable_runs(lease_expires_at); - -CREATE TABLE IF NOT EXISTS durable_steps ( - run_id TEXT NOT NULL, - step_index INTEGER NOT NULL, - intent TEXT NOT NULL, - kind TEXT NOT NULL, - input_hash TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed')), - attempts INTEGER NOT NULL DEFAULT 0, - result_json TEXT, - error_json TEXT, - started_at TEXT, - completed_at TEXT, - PRIMARY KEY (run_id, step_index) -); - -CREATE INDEX IF NOT EXISTS idx_durable_steps_status ON durable_steps(run_id, status); - -CREATE TABLE IF NOT EXISTS durable_events ( - run_id TEXT NOT NULL, - key TEXT NOT NULL, - payload_json TEXT, - emitted_at TEXT NOT NULL, - PRIMARY KEY (run_id, key) -); - -INSERT OR IGNORE INTO durable_schema_info (version, applied_at) -VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); - --- ── Migration v2 — durable event-stream log + run handle ─────────────── --- Run once on a database created at v1. \`ALTER TABLE\` is not idempotent; the --- version trail in \`durable_schema_info\` is how migrations are sequenced — --- never by blind re-execution of this block. --- --- - \`durable_stream_events\` is the ordered, replayable per-run event log. --- \`seq\` is the store-assigned monotonic cursor; the UNIQUE index on --- (run_id, event_id) makes appends idempotent — a reconnecting adapter --- that re-yields a boundary event cannot double-log it. --- - \`durable_runs.handle_json\` is the pointer (sandbox + substrate run id + --- cursor) a fresh supervisor re-attaches by. - -ALTER TABLE durable_runs ADD COLUMN handle_json TEXT; - -CREATE TABLE IF NOT EXISTS durable_stream_events ( - run_id TEXT NOT NULL, - seq INTEGER NOT NULL, - event_id TEXT NOT NULL, - payload_json TEXT, - appended_at TEXT NOT NULL, - PRIMARY KEY (run_id, seq) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_durable_stream_events_event_id - ON durable_stream_events(run_id, event_id); - -INSERT OR IGNORE INTO durable_schema_info (version, applied_at) -VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); -` diff --git a/src/durable/session-supervisor-do.ts b/src/durable/session-supervisor-do.ts deleted file mode 100644 index e1f6a8ab..00000000 --- a/src/durable/session-supervisor-do.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * `SessionSupervisorDO` — the Cloudflare Durable Object host for - * `runSupervisedTurn`. - * - * A stateless Worker isolate is the wrong place to own a 15-minute run: it - * dies on a deploy roll or CPU limit. A Durable Object is addressable by - * session id and survives across requests — it is the right home for the - * supervisor. This host is deliberately thin: all the durability logic lives - * in the platform-agnostic `runSupervisedTurn`; the DO only hosts it and - * uses an `alarm()` to re-attach a run the response stream abandoned. - * - * - `fetch` resolves the run, records it, arms the orphan-check alarm, and - * streams the supervised events back. If the client disconnects, the - * supervisor stops being pulled and its short lease lapses. - * - `alarm()` is the recovery mechanism: it finds a recorded-but-unfinished - * run and re-drives `runSupervisedTurn` headlessly to completion (events - * land in the durable log; a later `fetch` replays them). A run still held - * by a live `fetch` raises `DurableRunLeaseHeldError` — not orphaned, so - * the alarm just re-arms. - * - * Structural CF types (`DurableObjectStateLike`) are defined locally so - * agent-runtime keeps no dependency on `@cloudflare/workers-types` — the same - * discipline as `D1DatabaseLike` in `d1-store.ts`. - */ - -import type { RunSupervisorOptions } from './supervisor' -import { runSupervisedTurn } from './supervisor' -import { DurableRunLeaseHeldError } from './types' - -/** Minimal Durable Object storage surface this host uses. Compatible with - * Cloudflare's `DurableObjectStorage`. */ -export interface DurableObjectStorageLike { - get(key: string): Promise - put(key: string, value: T): Promise - delete(key: string): Promise - /** Schedule the next `alarm()` invocation at an epoch-ms time. */ - setAlarm(scheduledTime: number): Promise -} - -/** Minimal Durable Object state surface — the `state` ctor argument. */ -export interface DurableObjectStateLike { - storage: DurableObjectStorageLike -} - -/** - * Product-supplied wiring for the host. `resolveRun` / `resolveOrphan` build - * the supervisor inputs (store, adapter, manifest) — the host owns no - * product policy. - */ -export interface SupervisorHostConfig { - /** Build supervisor inputs for an incoming request. `undefined` → 404. */ - resolveRun( - request: Request, - env: TEnv, - state: DurableObjectStateLike, - ): Promise | undefined> - /** Rebuild supervisor inputs for an orphan re-attach, from the recorded - * runId. `undefined` → the run is untrackable; the host stops tracking it. */ - resolveOrphan( - runId: string, - env: TEnv, - state: DurableObjectStateLike, - ): Promise | undefined> - /** Serialize one event into a response-stream chunk (an SSE or NDJSON - * line — the product owns the framing). */ - encodeEvent(event: TEvent): string - /** Delay before the orphan-check alarm fires. Default 60_000. */ - orphanCheckMs?: number - /** Time source — tests pin this. */ - now?: () => number -} - -/** The host instance surface — what a Cloudflare DO runtime invokes. */ -export interface SessionSupervisorDO { - fetch(request: Request): Promise - alarm(): Promise -} - -/** DO-storage key under which the host records the in-flight run id, so the - * orphan-check `alarm()` can find a run a dropped response stream left behind. */ -export const ACTIVE_RUN_KEY = 'agent-runtime:active-run-id' - -/** Drain a supervised stream without a consumer — events land in the durable - * log via the supervisor; nothing forwards them. */ -async function drainHeadless(stream: AsyncGenerator): Promise { - let next = await stream.next() - while (!next.done) next = await stream.next() -} - -/** - * Build the `SessionSupervisorDO` class for a product. Export the result from - * the Worker entrypoint and bind it in `wrangler.toml`: - * - * export const SessionSupervisor = createSessionSupervisorDO(config) - * - * # wrangler.toml - * [[durable_objects.bindings]] - * name = "SESSION_SUPERVISOR" - * class_name = "SessionSupervisor" - * [[migrations]] - * tag = "v1" - * new_classes = ["SessionSupervisor"] - */ -export function createSessionSupervisorDO( - config: SupervisorHostConfig, -): new ( - state: DurableObjectStateLike, - env: TEnv, -) => SessionSupervisorDO { - const orphanCheckMs = config.orphanCheckMs ?? 60_000 - const now = config.now ?? (() => Date.now()) - - return class implements SessionSupervisorDO { - constructor( - private readonly state: DurableObjectStateLike, - private readonly env: TEnv, - ) {} - - async fetch(request: Request): Promise { - const opts = await config.resolveRun(request, this.env, this.state) - if (!opts) return new Response('no run for this request', { status: 404 }) - - // Record the run + arm the orphan check before streaming, so a crash - // mid-stream still leaves a recovery trail. - await this.state.storage.put(ACTIVE_RUN_KEY, opts.runId) - await this.state.storage.setAlarm(now() + orphanCheckMs) - - const supervised = runSupervisedTurn(opts) - const storage = this.state.storage - const encoder = new TextEncoder() - const body = new ReadableStream({ - async pull(controller) { - try { - const next = await supervised.stream.next() - if (next.done) { - await storage.delete(ACTIVE_RUN_KEY) - controller.close() - return - } - controller.enqueue(encoder.encode(config.encodeEvent(next.value))) - } catch (err) { - controller.error(err instanceof Error ? err : new Error(String(err))) - } - }, - }) - return new Response(body, { - headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, - }) - } - - async alarm(): Promise { - const runId = await this.state.storage.get(ACTIVE_RUN_KEY) - if (!runId) return - - const opts = await config.resolveOrphan(runId, this.env, this.state) - if (!opts) { - await this.state.storage.delete(ACTIVE_RUN_KEY) - return - } - - try { - // Re-drive the run headlessly. startOrResume inside resolves the - // path: replay (already done), resume (orphaned + in-flight), or a - // fresh run. Events land in the durable log regardless. - await drainHeadless(runSupervisedTurn(opts).stream) - await this.state.storage.delete(ACTIVE_RUN_KEY) - } catch (err) { - if (err instanceof DurableRunLeaseHeldError) { - // A live fetch still owns the run — not orphaned. Re-check later. - await this.state.storage.setAlarm(now() + orphanCheckMs) - return - } - // The run failed for real — stop tracking it. - await this.state.storage.delete(ACTIVE_RUN_KEY) - } - } - } -} diff --git a/src/durable/supervisor.ts b/src/durable/supervisor.ts deleted file mode 100644 index ee28f45a..00000000 --- a/src/durable/supervisor.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * `runSupervisedTurn` — relocates the durability boundary off the ephemeral - * worker isolate. - * - * `runDurableTurn` replays a *completed* turn; an interrupted turn re-runs. - * `runSupervisedTurn` closes that gap for sandbox-backed runs: the sandbox - * container is orchestrator-managed and outlives the worker, so instead of - * re-prompting, a fresh supervisor re-attaches to the in-flight substrate run - * and resumes draining its event stream. - * - * Durability is owned by the substrate, not hoped-for from the sandbox. The - * supervisor drains every event into the store's stream log as it flows - * (`appendStreamEvent`), persists the reconnect pointer the instant the - * substrate yields it (`setRunHandle`), and heartbeats the lease. A fresh - * supervisor reads the log for its cursor and calls `adapter.attach` to - * resume strictly after it — the append's idempotency on `eventId` dedups - * the reconnect seam, so no event is lost and none is delivered twice. - * - * The platform-agnostic core is here; `SessionSupervisorDO` hosts it on a - * Cloudflare Durable Object. The reconnect glue is one typed contract — - * `SandboxReconnectAdapter` — implemented once per substrate, never per - * product. - */ - -import { canonicalHash } from './identity' -import type { DurableRunManifest, DurableRunStore, RunHandle, RunRecord } from './types' - -/** One event drained from a supervised run. */ -export interface SupervisedEvent { - /** Stable substrate id — the dedup key and the reconnect cursor. */ - eventId: string - payload: TEvent - /** - * The substrate run handle, carried on the first frame(s) once the run id - * is known. The supervisor persists it so a fresh supervisor can re-attach. - * Omit on later frames; the last non-undefined handle wins. - */ - handle?: RunHandle -} - -/** - * Product-supplied glue to a reconnectable substrate run. The dangerous - * reconnect logic — re-attaching to a live distributed run — lives behind - * this one typed contract: implement it once per substrate (the sandbox SDK, - * etc.), never per product. - * - * Conformance (asserted by `supervisor.test.ts`): - * - `start()` yields the run's events; at least one early event carries a - * `handle` with `status: 'running'` and a defined `runId`. - * - `attach(handle, afterEventId)` yields only events strictly after - * `afterEventId`, and terminates cleanly when the run has no more. - * - `eventId`s are unique within a run. - */ -export interface SandboxReconnectAdapter { - /** Begin a fresh substrate run. */ - start(): AsyncIterable> - /** - * Re-attach to an in-flight run, resuming strictly after `afterEventId` - * (`undefined` → from the first event). - */ - attach( - handle: RunHandle, - afterEventId: string | undefined, - ): AsyncIterable> -} - -/** How the supervised turn resolved. */ -export type SupervisedRunMode = 'fresh' | 'resumed' | 'replayed' - -export interface RunSupervisorOptions { - store: DurableRunStore - /** Stable per-turn run id — the same id on a retry is what enables both - * replay (completed turn) and resume (in-flight turn). */ - runId: string - manifest: DurableRunManifest - /** Stable per-isolate worker id. */ - workerId: string - adapter: SandboxReconnectAdapter - /** Lease window in ms. Default 60_000 — deliberately short: the heartbeat - * keeps an actively-draining supervisor's lease alive, so an abandoned - * supervisor's lease lapses fast and a fresh supervisor can take over. */ - leaseMs?: number - /** Renew the lease at most this often while draining. Default 30_000 — - * must be below `leaseMs` or an active drain loses its own lease. */ - heartbeatMs?: number - /** Human-readable step label. Default `turn`. */ - intent?: string - /** Time source override — tests pin this for deterministic heartbeats. */ - now?: () => number -} - -export interface SupervisedRunHandle { - /** Drop-in stream. Fresh forwards live events; resumed re-yields the logged - * prefix then forwards live events; replayed re-yields the full log. */ - stream: AsyncGenerator - /** Which path ran. Valid after `stream` drains. */ - mode(): SupervisedRunMode - /** The durable RunRecord for the turn. Valid after `stream` drains. */ - record(): RunRecord | undefined -} - -const TURN_STEP = 0 - -export function runSupervisedTurn( - options: RunSupervisorOptions, -): SupervisedRunHandle { - const { store, runId, manifest, workerId, adapter } = options - const leaseMs = options.leaseMs ?? 60_000 - const heartbeatMs = options.heartbeatMs ?? 30_000 - const intent = options.intent ?? 'turn' - const now = options.now ?? (() => Date.now()) - const inputHash = canonicalHash(manifest.input) - - let mode: SupervisedRunMode = 'fresh' - let finalRecord: RunRecord | undefined - let currentHandle: RunHandle | undefined - // Set when the heartbeat finds the lease taken by another supervisor. The - // catch block then skips failStep/endRun — this worker no longer owns the - // run, and must not write its terminal state. - let leaseLost = false - - async function* drain( - source: AsyncIterable>, - ): AsyncGenerator { - let lastRenew = now() - for await (const event of source) { - // Persist the reconnect pointer the instant the substrate yields it, - // before the event itself — a crash between the two must leave a - // resumable handle, never an event a fresh supervisor cannot place. - if (event.handle) { - currentHandle = event.handle - await store.setRunHandle({ runId, handle: event.handle }) - } - // Drain into the durable log. Idempotent on eventId: a boundary event - // re-yielded across the reconnect seam returns accepted=false and is - // not re-forwarded — no duplicate reaches the caller. - const { accepted } = await store.appendStreamEvent({ - runId, - eventId: event.eventId, - payload: event.payload, - }) - if (now() - lastRenew >= heartbeatMs) { - const renewed = await store.renewLease({ runId, workerId, leaseMs }) - if (!renewed.ok) { - leaseLost = true - throw new Error(`durable-runs: lease lost on ${runId} — another supervisor took over`) - } - lastRenew = now() - } - if (accepted) yield event.payload - } - } - - async function* stream(): AsyncGenerator { - const { run, completedSteps } = await store.startOrResume({ - runId, - manifest, - workerId, - leaseMs, - }) - - // ── Replayed — the turn already completed ─────────────────────────── - if (completedSteps.some((s) => s.stepIndex === TURN_STEP && s.status === 'completed')) { - mode = 'replayed' - for (const e of await store.readStreamEvents(runId)) yield e.payload as TEvent - finalRecord = await store.endRun({ runId, workerId, status: 'completed' }) - return - } - - // ── Decide fresh vs resume ────────────────────────────────────────── - const logged = await store.readStreamEvents(runId) - const priorHandle = run.handle - const resumable = - priorHandle !== undefined && - priorHandle.status === 'running' && - typeof priorHandle.runId === 'string' - - let source: AsyncIterable> - if (resumable && priorHandle) { - mode = 'resumed' - currentHandle = priorHandle - // Re-yield the logged prefix so the caller sees the whole stream, then - // attach strictly after the last logged event. - for (const e of logged) yield e.payload as TEvent - const cursor = logged.length > 0 ? logged[logged.length - 1]!.eventId : priorHandle.cursor - source = adapter.attach(priorHandle, cursor) - } else { - mode = 'fresh' - source = adapter.start() - } - - await store.beginStep({ runId, stepIndex: TURN_STEP, intent, kind: 'llm', inputHash }) - try { - yield* drain(source) - const eventCount = (await store.readStreamEvents(runId)).length - // completeStep first — it is the "turn finished" signal the replay path - // keys on. A crash after it leaves a cleanly replayable run; flipping - // the handle first and crashing before completeStep would instead make - // a finished run look fresh (handle not `running`, step not completed). - await store.completeStep({ runId, stepIndex: TURN_STEP, result: { eventCount } }) - if (currentHandle && currentHandle.status === 'running') { - await store.setRunHandle({ runId, handle: { ...currentHandle, status: 'completed' } }) - } - finalRecord = await store.endRun({ - runId, - workerId, - status: 'completed', - outcome: { notes: intent, metadata: { events: eventCount, mode } }, - }) - } catch (err) { - if (!leaseLost) { - // The handle stays `running` — exactly what a resuming retry needs. - await store.failStep({ - runId, - stepIndex: TURN_STEP, - error: { message: err instanceof Error ? err.message : String(err) }, - }) - finalRecord = await store.endRun({ runId, workerId, status: 'failed' }) - } - throw err - } - } - - return { - stream: stream(), - mode: () => mode, - record: () => finalRecord, - } -} diff --git a/src/durable/tests/chat-engine.test.ts b/src/durable/tests/chat-engine.test.ts index dc23af83..7d90b6ce 100644 --- a/src/durable/tests/chat-engine.test.ts +++ b/src/durable/tests/chat-engine.test.ts @@ -1,30 +1,7 @@ -/** - * `DurableChatTurnEngine` tests — the orchestration contract every product - * chat handler depends on. Run against all three stores so the engine is - * proven on every backend. Covers: lifecycle envelope, NDJSON encoding, - * replay-skips-persist (no double-write), hook ordering, the failure - * envelope, the per-event side channel, and the pre-persist transform. - */ +import { describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { type ChatStreamEvent, handleChatTurn } from '../chat-engine' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - type ChatStreamEvent, - D1DurableRunStore, - DurableChatTurnEngine, - type DurableRunStore, - FileSystemDurableRunStore, - InMemoryDurableRunStore, -} from '../index' -import { createSqliteD1 } from './sqlite-d1-adapter' - -const SCHEMA_SQL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') - -/** Drain an NDJSON ReadableStream into parsed events. */ async function drain(body: ReadableStream): Promise { const reader = body.getReader() const decoder = new TextDecoder() @@ -46,7 +23,6 @@ async function drain(body: ReadableStream): Promise void) { return () => { onConstruct?.() @@ -58,221 +34,150 @@ function textProducer(text: string, onConstruct?: () => void) { } } -const storeKinds = [ - { - name: 'InMemoryDurableRunStore', - factory: () => ({ store: new InMemoryDurableRunStore(), cleanup: () => undefined }), - }, - { - name: 'FileSystemDurableRunStore', - factory: () => { - const dir = mkdtempSync(join(tmpdir(), 'chat-engine-test-')) - return { - store: new FileSystemDurableRunStore(dir), - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } - }, - }, - { - name: 'D1DurableRunStore (better-sqlite3)', - factory: () => { - const handle = createSqliteD1() - handle.raw.exec(SCHEMA_SQL) - return { - store: new D1DurableRunStore(handle.db), - cleanup: () => handle.close(), - } - }, - }, -] as const - const IDENTITY = { tenantId: 'ws-1', sessionId: 'thread-1', userId: 'user-1', turnIndex: 0 } -for (const kind of storeKinds) { - describe(`DurableChatTurnEngine / ${kind.name}`, () => { - let store: DurableRunStore - let cleanup: () => void - const engine = new DurableChatTurnEngine() - - beforeEach(() => { - const made = kind.factory() - store = made.store - cleanup = made.cleanup - }) - - afterEach(async () => { - await store.close() - cleanup() +describe('handleChatTurn', () => { + // intentionally exercises the public function via the same name used by + // consumers + it('wraps the turn in the session.run.* lifecycle envelope', async () => { + const persisted: string[] = [] + const { body, contentType } = handleChatTurn({ + identity: IDENTITY, + hooks: { + produce: textProducer('Hi there.'), + persistAssistantMessage: async ({ finalText }) => { + persisted.push(finalText) + }, + }, }) + expect(contentType).toBe('application/x-ndjson') + const events = await drain(body) + + expect(events[0]?.type).toBe('session.run.started') + expect(events.at(-1)?.type).toBe('session.run.completed') + expect(events.some((e) => e.type === 'message.part.updated')).toBe(true) + expect(events.some((e) => e.type === 'result')).toBe(true) + expect(persisted).toEqual(['Hi there.']) + }) - it('wraps the turn in the session.run.* lifecycle envelope', async () => { - const persisted: string[] = [] - const { body, contentType } = engine.runTurn({ - identity: IDENTITY, - userMessage: 'hello', - projectId: 'legal-agent', - domain: 'legal', - store, - hooks: { - produce: textProducer('Hi there.'), - persistAssistantMessage: async ({ finalText }) => { - persisted.push(finalText) - }, + it('runs hooks in order: persist → onTurnComplete, after the stream', async () => { + const order: string[] = [] + const { body } = handleChatTurn({ + identity: IDENTITY, + hooks: { + produce: textProducer('answer'), + persistAssistantMessage: async () => { + order.push('persist') }, - }) - expect(contentType).toBe('application/x-ndjson') - const events = await drain(body) - - expect(events[0]?.type).toBe('session.run.started') - expect(events.at(-1)?.type).toBe('session.run.completed') - expect(events.some((e) => e.type === 'message.part.updated')).toBe(true) - expect(events.some((e) => e.type === 'result')).toBe(true) - expect(persisted).toEqual(['Hi there.']) + onTurnComplete: async () => { + order.push('postProcess') + }, + }, }) + await drain(body) + expect(order).toEqual(['persist', 'postProcess']) + }) - it('runs hooks in order: persist → onTurnComplete, after the stream', async () => { - const order: string[] = [] - const { body } = engine.runTurn({ - identity: IDENTITY, - userMessage: 'q', - projectId: 'gtm-agent', - domain: 'gtm', - store, - hooks: { - produce: textProducer('answer'), - persistAssistantMessage: async () => { - order.push('persist') - }, - onTurnComplete: async () => { - order.push('postProcess') - }, + it('a producer failure becomes error + session.run.failed, stream still closes', async () => { + const { body } = handleChatTurn({ + identity: IDENTITY, + log: () => undefined, // silence the default console.error in tests + hooks: { + produce: () => { + async function* stream(): AsyncGenerator { + yield { type: 'message.part.updated', data: { delta: 'partial' } } + throw new Error('backend exploded') + } + return { stream: stream(), finalText: () => '' } }, - }) - await drain(body) - expect(order).toEqual(['persist', 'postProcess']) + persistAssistantMessage: async () => undefined, + }, }) + const events = await drain(body) + expect(events[0]?.type).toBe('session.run.started') + const err = events.find((e) => e.type === 'error') + expect(err?.data?.message).toBe('backend exploded') + expect(events.at(-1)?.type).toBe('session.run.failed') + }) - it('replay skips the producer AND skips persist/post-process (no double-write)', async () => { - let constructs = 0 - let persists = 0 - let postProcesses = 0 - const hooks = { - produce: textProducer('cached', () => (constructs += 1)), - persistAssistantMessage: async () => { - persists += 1 - }, - onTurnComplete: async () => { - postProcesses += 1 + it('onEvent side channel receives every emitted event', async () => { + const broadcast: string[] = [] + const { body } = handleChatTurn({ + identity: IDENTITY, + hooks: { + produce: textProducer('x'), + persistAssistantMessage: async () => undefined, + onEvent: (event) => { + broadcast.push(event.type) }, - } - // First attempt — fresh. - await drain( - engine.runTurn({ - identity: IDENTITY, - userMessage: 'q', - projectId: 'creative-agent', - domain: 'creative', - store, - hooks, - }).body, - ) - // Second attempt — same identity → replay. - const replayEvents = await drain( - engine.runTurn({ - identity: IDENTITY, - userMessage: 'q', - projectId: 'creative-agent', - domain: 'creative', - store, - hooks, - }).body, - ) - - expect(constructs).toBe(1) // producer built once - expect(persists).toBe(1) // assistant message persisted once - expect(postProcesses).toBe(1) // post-process ran once - expect(replayEvents.some((e) => e.type === 'result')).toBe(true) - const completed = replayEvents.find((e) => e.type === 'session.run.completed') - expect(completed?.data?.replayed).toBe(true) + }, }) + const events = await drain(body) + expect(broadcast).toEqual(events.map((e) => e.type)) + }) - it('a producer failure becomes error + session.run.failed, stream still closes', async () => { - const { body } = engine.runTurn({ - identity: IDENTITY, - userMessage: 'q', - projectId: 'tax-agent', - domain: 'tax', - store, - hooks: { - produce: () => { - async function* stream(): AsyncGenerator { - yield { type: 'message.part.updated', data: { delta: 'partial' } } - throw new Error('backend exploded') - } - return { stream: stream(), finalText: () => '' } - }, - persistAssistantMessage: async () => undefined, + it('transformFinalText alters what is persisted, not the live stream', async () => { + let persisted = '' + const { body } = handleChatTurn({ + identity: IDENTITY, + hooks: { + produce: textProducer('SSN 123-45-6789'), + transformFinalText: (t) => t.replace(/\d{3}-\d{2}-\d{4}/, '[REDACTED]'), + persistAssistantMessage: async ({ finalText }) => { + persisted = finalText }, - }) - const events = await drain(body) - expect(events[0]?.type).toBe('session.run.started') - const err = events.find((e) => e.type === 'error') - expect(err?.data?.message).toBe('backend exploded') - expect(events.at(-1)?.type).toBe('session.run.failed') + }, }) + const events = await drain(body) + const result = events.find((e) => e.type === 'result') + expect(result?.data?.finalText).toBe('SSN 123-45-6789') + expect(persisted).toBe('SSN [REDACTED]') + }) - it('onEvent side channel receives every emitted event', async () => { - const broadcast: string[] = [] - const { body } = engine.runTurn({ - identity: IDENTITY, - userMessage: 'q', - projectId: 'gtm-agent', - domain: 'gtm', - store, - hooks: { - produce: textProducer('x'), - persistAssistantMessage: async () => undefined, - onEvent: (event) => { - broadcast.push(event.type) - }, + it('a throwing persist hook is swallowed — the turn still completes', async () => { + const { body } = handleChatTurn({ + identity: IDENTITY, + log: () => undefined, + hooks: { + produce: textProducer('ok'), + persistAssistantMessage: async () => { + throw new Error('db down') }, - }) - const events = await drain(body) - // Side channel saw exactly what the client saw. - expect(broadcast).toEqual(events.map((e) => e.type)) + }, }) + const events = await drain(body) + expect(events.at(-1)?.type).toBe('session.run.completed') + }) - it('transformFinalText alters what is persisted, not the live stream', async () => { - let persisted = '' - const { body } = engine.runTurn({ - identity: IDENTITY, - userMessage: 'q', - projectId: 'legal-agent', - domain: 'legal', - store, - hooks: { - produce: textProducer('SSN 123-45-6789'), - transformFinalText: (t) => t.replace(/\d{3}-\d{2}-\d{4}/, '[REDACTED]'), - persistAssistantMessage: async ({ finalText }) => { - persisted = finalText - }, + it('traceFlush is handed to waitUntil so the worker isolate survives the POST', async () => { + let flushAwaited = false + let waitUntilCalled = false + const { body } = handleChatTurn({ + identity: IDENTITY, + waitUntil: (p) => { + waitUntilCalled = true + void p.then(() => { + flushAwaited = true + }) + }, + hooks: { + produce: textProducer('ok'), + persistAssistantMessage: async () => undefined, + traceFlush: async () => { + flushAwaited = true }, - }) - const events = await drain(body) - // Live stream still carries the raw text. - const result = events.find((e) => e.type === 'result') - expect(result?.data?.finalText).toBe('SSN 123-45-6789') - // Persisted copy is redacted. - expect(persisted).toBe('SSN [REDACTED]') + }, }) + await drain(body) + expect(waitUntilCalled).toBe(true) + expect(flushAwaited).toBe(true) + }) - it('a throwing persist hook is swallowed — the turn still completes', async () => { - const { body } = engine.runTurn({ + it('swallowed hook errors are logged to console.error by default', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + const { body } = handleChatTurn({ identity: IDENTITY, - userMessage: 'q', - projectId: 'legal-agent', - domain: 'legal', - store, hooks: { produce: textProducer('ok'), persistAssistantMessage: async () => { @@ -280,9 +185,12 @@ for (const kind of storeKinds) { }, }, }) - const events = await drain(body) - // Persist failure must not turn into session.run.failed. - expect(events.at(-1)?.type).toBe('session.run.completed') - }) + await drain(body) + expect(spy).toHaveBeenCalled() + const messages = spy.mock.calls.map((c) => c[0]) + expect(messages.some((m) => String(m).includes('persistAssistantMessage'))).toBe(true) + } finally { + spy.mockRestore() + } }) -} +}) diff --git a/src/durable/tests/durable-runs.test.ts b/src/durable/tests/durable-runs.test.ts deleted file mode 100644 index 15383568..00000000 --- a/src/durable/tests/durable-runs.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * Durable-run substrate tests — crash recovery, lease semantics, event races, - * divergence detection. The tests run identically against the in-memory store - * and the file-system store (same `DurableRunStore` contract), so a single - * matrix proves both implementations. - */ - -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - D1DurableRunStore, - DurableAwaitEventTimeoutError, - DurableRunDivergenceError, - DurableRunInputMismatchError, - DurableRunLeaseHeldError, - type DurableRunManifest, - type DurableRunStore, - FileSystemDurableRunStore, - InMemoryDurableRunStore, - manifestHash, - runDurable, -} from '../index' -import { createSqliteD1 } from './sqlite-d1-adapter' - -const SCHEMA_SQL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') - -function makeManifest(overrides?: Partial): DurableRunManifest { - return { - projectId: 'test-project', - scenarioId: 'scenario-1', - task: { - id: 'task-1', - intent: 'unit-test', - domain: 'test', - requiredKnowledge: [], - metadata: {}, - }, - input: { x: 1 }, - ...overrides, - } -} - -const storeKinds = [ - { - name: 'InMemoryDurableRunStore', - factory: () => ({ store: new InMemoryDurableRunStore(), cleanup: () => undefined }), - }, - { - name: 'FileSystemDurableRunStore', - factory: () => { - const dir = mkdtempSync(join(tmpdir(), 'durable-runs-test-')) - return { - store: new FileSystemDurableRunStore(dir), - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } - }, - }, - { - name: 'D1DurableRunStore (better-sqlite3)', - factory: () => { - const handle = createSqliteD1() - handle.raw.exec(SCHEMA_SQL) - return { - store: new D1DurableRunStore(handle.db), - cleanup: () => handle.close(), - } - }, - }, -] as const - -for (const kind of storeKinds) { - describe(`durable-runs / ${kind.name}`, () => { - let store: DurableRunStore - let cleanup: () => void - - beforeEach(() => { - const made = kind.factory() - store = made.store - cleanup = made.cleanup - }) - - afterEach(async () => { - await store.close() - cleanup() - }) - - it('executes a fresh run end-to-end and returns the result', async () => { - const { result, record, steps } = await runDurable({ - runId: 'r1', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - const a = await ctx.step('add', async () => 1 + 1) - const b = await ctx.step('multiply', async () => a * 5) - return b - }, - }) - expect(result).toBe(10) - expect(record.status).toBe('completed') - expect(steps.map((s) => ({ idx: s.stepIndex, intent: s.intent, status: s.status }))).toEqual([ - { idx: 0, intent: 'add', status: 'completed' }, - { idx: 1, intent: 'multiply', status: 'completed' }, - ]) - }) - - it('replays completed steps without re-executing fn', async () => { - let calls = 0 - // First run — abort mid-stream. - try { - await runDurable({ - runId: 'r2', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - await ctx.step('first', async () => { - calls += 1 - return 'one' - }) - await ctx.step('boom', async () => { - throw new Error('forced failure') - }) - return 'never' - }, - }) - } catch (e) { - expect((e as Error).message).toBe('forced failure') - } - expect(calls).toBe(1) - - // Second run — fix the failing step. Verify 'first' is NOT re-executed. - const { result } = await runDurable({ - runId: 'r2', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - const a = await ctx.step('first', async () => { - calls += 1 - return 'one' - }) - const b = await ctx.step('boom', async () => 'fixed') - return `${a}-${b}` - }, - }) - expect(result).toBe('one-fixed') - // First-step callback called exactly once across the two runs. - expect(calls).toBe(1) - }) - - it('rejects manifest mismatch on resume', async () => { - await runDurable({ - runId: 'r3', - manifest: makeManifest({ input: { x: 1 } }), - store, - taskFn: async (ctx) => ctx.step('s', async () => 'ok'), - }) - await expect( - runDurable({ - runId: 'r3', - manifest: makeManifest({ input: { x: 2 } }), - store, - taskFn: async () => 'noop', - }), - ).rejects.toBeInstanceOf(DurableRunInputMismatchError) - }) - - it('rejects step divergence (intent change at same position)', async () => { - try { - await runDurable({ - runId: 'r4', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - await ctx.step('first', async () => 1) - await ctx.step('second', async () => { - throw new Error('boom') - }) - return 'never' - }, - }) - } catch { - /* expected */ - } - await expect( - runDurable({ - runId: 'r4', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - await ctx.step('first', async () => 1) - await ctx.step('DIFFERENT', async () => 2) // diverges at idx 1 - return 'noop' - }, - }), - ).rejects.toBeInstanceOf(DurableRunDivergenceError) - }) - - it('refuses concurrent acquisition while lease is live', async () => { - const { run } = await store.startOrResume({ - runId: 'r5', - manifest: makeManifest(), - workerId: 'workerA', - leaseMs: 60_000, - }) - expect(run.leaseHolderId).toBe('workerA') - await expect( - store.startOrResume({ - runId: 'r5', - manifest: makeManifest(), - workerId: 'workerB', - leaseMs: 60_000, - }), - ).rejects.toBeInstanceOf(DurableRunLeaseHeldError) - }) - - it('allows takeover after lease expires', async () => { - const { run } = await store.startOrResume({ - runId: 'r6', - manifest: makeManifest(), - workerId: 'workerA', - leaseMs: 50, - }) - expect(run.leaseHolderId).toBe('workerA') - // Wait past lease expiry. - await new Promise((r) => setTimeout(r, 80)) - const taken = await store.startOrResume({ - runId: 'r6', - manifest: makeManifest(), - workerId: 'workerB', - leaseMs: 60_000, - }) - expect(taken.run.leaseHolderId).toBe('workerB') - }) - - it('awaitEvent receives the first emit and is replayed thereafter', async () => { - // Concurrently emit the event while runDurable is awaiting it — this - // mirrors the production pattern (external system fires a webhook / - // tool callback while the agent task is suspended). - const emitSoon = new Promise((resolve) => { - setTimeout(() => { - void store - .emitEvent({ runId: 'r7', key: 'shipment', payload: { tracking: 'X1' } }) - .then(() => resolve()) - }, 40) - }) - - const { result } = await runDurable({ - runId: 'r7', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - const ev = await ctx.awaitEvent<{ tracking: string }>('shipment', { - timeoutMs: 2_000, - pollMs: 10, - }) - return ev.tracking - }, - }) - await emitSoon - expect(result).toBe('X1') - - // Replay the same run — awaitEvent returns from cache. - const replay = await runDurable({ - runId: 'r7', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - const ev = await ctx.awaitEvent<{ tracking: string }>('shipment', { timeoutMs: 1_000 }) - return ev.tracking - }, - }) - expect(replay.result).toBe('X1') - }) - - it('awaitEvent times out cleanly', async () => { - await expect( - runDurable({ - runId: 'r8', - manifest: makeManifest(), - store, - taskFn: async (ctx) => ctx.awaitEvent('never', { timeoutMs: 50, pollMs: 10 }), - }), - ).rejects.toBeInstanceOf(DurableAwaitEventTimeoutError) - }) - - it('emitEvent enforces first-emit-wins', async () => { - await store.startOrResume({ runId: 'r9', manifest: makeManifest(), workerId: 'w1' }) - const first = await store.emitEvent({ runId: 'r9', key: 'k', payload: { v: 1 } }) - expect(first.accepted).toBe(true) - const second = await store.emitEvent({ runId: 'r9', key: 'k', payload: { v: 2 } }) - expect(second.accepted).toBe(false) - expect((second.record.payload as { v: number }).v).toBe(1) - }) - - it('ctx.now and ctx.uuid are stable across replay', async () => { - const first = await runDurable({ - runId: 'r10', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - const t = await ctx.now() - const id = await ctx.uuid() - return { t: t.toISOString(), id } - }, - }) - // Force a "fresh" replay by manually re-running. The fact that the - // run is already 'completed' shouldn't matter for cached step replay. - const replay = await runDurable({ - runId: 'r10', - manifest: makeManifest(), - store, - taskFn: async (ctx) => { - const t = await ctx.now() - const id = await ctx.uuid() - return { t: t.toISOString(), id } - }, - }) - expect(replay.result).toEqual(first.result) - }) - }) -} - -describe('manifestHash', () => { - it('is stable across object insertion order', () => { - const a = makeManifest({ input: { a: 1, b: 2 } }) - const b = makeManifest({ input: { b: 2, a: 1 } }) - expect(manifestHash(a)).toBe(manifestHash(b)) - }) -}) diff --git a/src/durable/tests/durable-turn.test.ts b/src/durable/tests/durable-turn.test.ts deleted file mode 100644 index bfe3c5bf..00000000 --- a/src/durable/tests/durable-turn.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * `runDurableTurn` tests — fresh run, replay, mid-stream crash re-run, - * concurrent-attempt rejection. Run identically against all three stores - * (InMemory / FileSystem / D1-over-sqlite) via the shared matrix, so the - * turn primitive is proven on every backend a product could use. - */ - -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - D1DurableRunStore, - DurableRunLeaseHeldError, - type DurableRunManifest, - type DurableRunStore, - FileSystemDurableRunStore, - InMemoryDurableRunStore, - runDurableTurn, -} from '../index' -import { createSqliteD1 } from './sqlite-d1-adapter' - -const SCHEMA_SQL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') - -interface FakeEvent { - type: string - text?: string -} - -function makeManifest(turnIndex = 0): DurableRunManifest { - return { - projectId: 'test-product', - scenarioId: 'thread-1', - task: { - id: `chat:thread-1:${turnIndex}`, - intent: 'unit-test chat turn', - domain: 'test', - requiredKnowledge: [], - metadata: { turnIndex }, - }, - input: { userMessage: `q-${turnIndex}` }, - } -} - -/** A producer that yields N text deltas then a final event. Records whether - * it was constructed so tests can prove the replay path skips it. */ -function fakeProducer(opts: { chunks: string[]; onConstruct?: () => void; throwAfter?: number }) { - opts.onConstruct?.() - let assembled = '' - async function* stream(): AsyncGenerator { - let emitted = 0 - for (const chunk of opts.chunks) { - if (opts.throwAfter !== undefined && emitted >= opts.throwAfter) { - throw new Error('producer exploded mid-stream') - } - assembled += chunk - yield { type: 'delta', text: chunk } - emitted += 1 - } - yield { type: 'result', text: assembled } - } - return { - stream: stream(), - finalText: () => assembled, - } -} - -const storeKinds = [ - { - name: 'InMemoryDurableRunStore', - factory: () => ({ store: new InMemoryDurableRunStore(), cleanup: () => undefined }), - }, - { - name: 'FileSystemDurableRunStore', - factory: () => { - const dir = mkdtempSync(join(tmpdir(), 'durable-turn-test-')) - return { - store: new FileSystemDurableRunStore(dir), - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } - }, - }, - { - name: 'D1DurableRunStore (better-sqlite3)', - factory: () => { - const handle = createSqliteD1() - handle.raw.exec(SCHEMA_SQL) - return { - store: new D1DurableRunStore(handle.db), - cleanup: () => handle.close(), - } - }, - }, -] as const - -for (const kind of storeKinds) { - describe(`runDurableTurn / ${kind.name}`, () => { - let store: DurableRunStore - let cleanup: () => void - - beforeEach(() => { - const made = kind.factory() - store = made.store - cleanup = made.cleanup - }) - - afterEach(async () => { - await store.close() - cleanup() - }) - - it('fresh run forwards producer events live and checkpoints final text', async () => { - let constructed = 0 - const handle = runDurableTurn({ - store, - runId: 'chat:thread-1:0', - manifest: makeManifest(0), - workerId: 'worker-a', - produce: () => - fakeProducer({ chunks: ['Hello', ', ', 'world'], onConstruct: () => (constructed += 1) }), - replayEvent: (text) => ({ type: 'result', text }), - }) - - const events: FakeEvent[] = [] - for await (const e of handle.stream) events.push(e) - - expect(constructed).toBe(1) - expect(handle.replayed()).toBe(false) - expect(events).toEqual([ - { type: 'delta', text: 'Hello' }, - { type: 'delta', text: ', ' }, - { type: 'delta', text: 'world' }, - { type: 'result', text: 'Hello, world' }, - ]) - expect(handle.finalText()).toBe('Hello, world') - expect(handle.record()?.status).toBe('completed') - }) - - it('replay emits one synthetic event and never constructs the producer', async () => { - // First attempt — completes, checkpoints. - const first = runDurableTurn({ - store, - runId: 'chat:thread-1:1', - manifest: makeManifest(1), - workerId: 'worker-a', - produce: () => fakeProducer({ chunks: ['cached ', 'answer'] }), - replayEvent: (text) => ({ type: 'result', text }), - }) - for await (const _ of first.stream) { - /* drain */ - } - expect(first.replayed()).toBe(false) - - // Second attempt — same runId. Producer must NOT be constructed. - let constructed = 0 - const replay = runDurableTurn({ - store, - runId: 'chat:thread-1:1', - manifest: makeManifest(1), - workerId: 'worker-b', - produce: () => fakeProducer({ chunks: ['x'], onConstruct: () => (constructed += 1) }), - replayEvent: (text) => ({ type: 'result', text }), - }) - const events: FakeEvent[] = [] - for await (const e of replay.stream) events.push(e) - - expect(constructed).toBe(0) - expect(replay.replayed()).toBe(true) - expect(replay.finalText()).toBe('cached answer') - expect(events).toEqual([{ type: 'result', text: 'cached answer' }]) - }) - - it('mid-stream crash re-runs the turn from the top (no partial replay)', async () => { - // First attempt explodes after 1 event. - let firstConstructs = 0 - const first = runDurableTurn({ - store, - runId: 'chat:thread-1:2', - manifest: makeManifest(2), - workerId: 'worker-a', - produce: () => - fakeProducer({ - chunks: ['partial', '-lost'], - throwAfter: 1, - onConstruct: () => (firstConstructs += 1), - }), - replayEvent: (text) => ({ type: 'result', text }), - }) - const firstEvents: FakeEvent[] = [] - await expect( - (async () => { - for await (const e of first.stream) firstEvents.push(e) - })(), - ).rejects.toThrow('producer exploded mid-stream') - expect(firstConstructs).toBe(1) - expect(first.record()?.status).toBe('failed') - - // Second attempt — the failed step is NOT replayed; the producer runs - // again and the turn completes cleanly. - let secondConstructs = 0 - const second = runDurableTurn({ - store, - runId: 'chat:thread-1:2', - manifest: makeManifest(2), - workerId: 'worker-a', - produce: () => - fakeProducer({ chunks: ['recovered'], onConstruct: () => (secondConstructs += 1) }), - replayEvent: (text) => ({ type: 'result', text }), - }) - const secondEvents: FakeEvent[] = [] - for await (const e of second.stream) secondEvents.push(e) - - expect(secondConstructs).toBe(1) - expect(second.replayed()).toBe(false) - expect(second.finalText()).toBe('recovered') - expect(secondEvents).toEqual([ - { type: 'delta', text: 'recovered' }, - { type: 'result', text: 'recovered' }, - ]) - }) - - it('the accumulate hook builds final text when the producer reports none', async () => { - // Producer whose finalText() stays empty — accumulate must fill it. - function emptyFinalProducer() { - async function* stream(): AsyncGenerator { - yield { type: 'delta', text: 'a' } - yield { type: 'delta', text: 'b' } - } - return { stream: stream(), finalText: () => '' } - } - const handle = runDurableTurn({ - store, - runId: 'chat:thread-1:3', - manifest: makeManifest(3), - workerId: 'worker-a', - produce: emptyFinalProducer, - replayEvent: (text) => ({ type: 'result', text }), - accumulate: (event, current) => - event.type === 'delta' ? current + (event.text ?? '') : undefined, - }) - for await (const _ of handle.stream) { - /* drain */ - } - expect(handle.finalText()).toBe('ab') - - // Replay must return the accumulated text. - const replay = runDurableTurn({ - store, - runId: 'chat:thread-1:3', - manifest: makeManifest(3), - workerId: 'worker-b', - produce: emptyFinalProducer, - replayEvent: (text) => ({ type: 'result', text }), - }) - const events: FakeEvent[] = [] - for await (const e of replay.stream) events.push(e) - expect(events).toEqual([{ type: 'result', text: 'ab' }]) - }) - - it('rejects a concurrent attempt while the first turn holds the lease', async () => { - // Start the first turn but do NOT drain it — lease stays held. - const first = runDurableTurn({ - store, - runId: 'chat:thread-1:4', - manifest: makeManifest(4), - workerId: 'worker-a', - leaseMs: 60_000, - produce: () => fakeProducer({ chunks: ['slow'] }), - replayEvent: (text) => ({ type: 'result', text }), - }) - // Pump the first stream just enough to claim the lease (startOrResume - // runs on the first `.next()`). - const firstIter = first.stream[Symbol.asyncIterator]() - await firstIter.next() - - // Concurrent worker on the same runId must be rejected. - const second = runDurableTurn({ - store, - runId: 'chat:thread-1:4', - manifest: makeManifest(4), - workerId: 'worker-b', - leaseMs: 60_000, - produce: () => fakeProducer({ chunks: ['concurrent'] }), - replayEvent: (text) => ({ type: 'result', text }), - }) - await expect( - (async () => { - for await (const _ of second.stream) { - /* drain */ - } - })(), - ).rejects.toBeInstanceOf(DurableRunLeaseHeldError) - - // Drain the first to release the lease cleanly. - for await (const _ of { [Symbol.asyncIterator]: () => firstIter }) { - /* drain remainder */ - } - }) - }) -} diff --git a/src/durable/tests/execution-handle.test.ts b/src/durable/tests/execution-handle.test.ts new file mode 100644 index 00000000..dff7cbce --- /dev/null +++ b/src/durable/tests/execution-handle.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { deriveExecutionId } from '../execution-handle' + +describe('deriveExecutionId', () => { + it('is stable for the same identity tuple', () => { + expect(deriveExecutionId({ projectId: 'gtm-agent', sessionId: 'thread-1', turnIndex: 0 })).toBe( + deriveExecutionId({ projectId: 'gtm-agent', sessionId: 'thread-1', turnIndex: 0 }), + ) + }) + + it('differs across turnIndex', () => { + expect(deriveExecutionId({ projectId: 'p', sessionId: 's', turnIndex: 0 })).not.toBe( + deriveExecutionId({ projectId: 'p', sessionId: 's', turnIndex: 1 }), + ) + }) + + it('differs across projectId', () => { + expect(deriveExecutionId({ projectId: 'a', sessionId: 's', turnIndex: 0 })).not.toBe( + deriveExecutionId({ projectId: 'b', sessionId: 's', turnIndex: 0 }), + ) + }) + + it('differs across sessionId', () => { + expect(deriveExecutionId({ projectId: 'p', sessionId: 's1', turnIndex: 0 })).not.toBe( + deriveExecutionId({ projectId: 'p', sessionId: 's2', turnIndex: 0 }), + ) + }) +}) diff --git a/src/durable/tests/schema-sync.test.ts b/src/durable/tests/schema-sync.test.ts deleted file mode 100644 index 55b80680..00000000 --- a/src/durable/tests/schema-sync.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Guard: the inlined `DURABLE_SCHEMA_SQL` constant must stay byte-identical - * to `src/durable/schema.sql`. If the .sql file is the source of truth, the - * constant cannot drift — otherwise consumers and tests see different - * schemas and the D1 store silently goes inconsistent. - */ - -import { readFileSync } from 'node:fs' - -import { describe, expect, it } from 'vitest' - -import { DURABLE_SCHEMA_SQL, DURABLE_SCHEMA_VERSION } from '../schema' - -describe('durable schema', () => { - it('inlined SQL constant matches schema.sql byte-for-byte', () => { - const fileSql = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') - expect(DURABLE_SCHEMA_SQL).toBe(fileSql) - }) - - it('schema version is positive integer', () => { - expect(DURABLE_SCHEMA_VERSION).toBeGreaterThan(0) - expect(Number.isInteger(DURABLE_SCHEMA_VERSION)).toBe(true) - }) -}) diff --git a/src/durable/tests/session-supervisor-do.test.ts b/src/durable/tests/session-supervisor-do.test.ts deleted file mode 100644 index 5486b1ad..00000000 --- a/src/durable/tests/session-supervisor-do.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InMemoryDurableRunStore } from '../in-memory-store' -import { - ACTIVE_RUN_KEY, - createSessionSupervisorDO, - type DurableObjectStateLike, - type DurableObjectStorageLike, - type SupervisorHostConfig, -} from '../session-supervisor-do' -import { - runSupervisedTurn, - type SandboxReconnectAdapter, - type SupervisedEvent, -} from '../supervisor' -import type { DurableRunManifest, RunHandle } from '../types' - -const manifest = { - projectId: 'test', - scenarioId: 'persona-1', - task: { id: 'task-1', intent: 'chat', domain: 'test' }, - input: { q: 'hello' }, -} as unknown as DurableRunManifest - -class FakeStorage implements DurableObjectStorageLike { - map = new Map() - alarmAt: number | null = null - async get(key: string): Promise { - return this.map.get(key) as T | undefined - } - async put(key: string, value: T): Promise { - this.map.set(key, value) - } - async delete(key: string): Promise { - return this.map.delete(key) - } - async setAlarm(scheduledTime: number): Promise { - this.alarmAt = scheduledTime - } -} - -class FakeState implements DurableObjectStateLike { - storage = new FakeStorage() -} - -/** A fixed-length scripted adapter — `start` carries the handle on frame 0, - * `attach` resumes strictly past the cursor. */ -function scriptedAdapter(n: number): SandboxReconnectAdapter { - const ids = Array.from({ length: n }, (_, i) => i) - return { - async *start(): AsyncGenerator> { - for (const i of ids) { - const handle: RunHandle | undefined = - i === 0 ? { kind: 'sandbox', runId: 's1', status: 'running' } : undefined - yield { eventId: `e${i}`, payload: i, handle } - } - }, - async *attach( - _handle: RunHandle, - afterEventId: string | undefined, - ): AsyncGenerator> { - const found = afterEventId ? ids.findIndex((i) => `e${i}` === afterEventId) : -1 - for (let i = found + 1; i < ids.length; i++) yield { eventId: `e${i}`, payload: i } - }, - } -} - -function config( - store: InMemoryDurableRunStore, -): SupervisorHostConfig> { - return { - async resolveRun() { - return { store, runId: 'do-run', manifest, workerId: 'w-fetch', adapter: scriptedAdapter(4) } - }, - async resolveOrphan(runId) { - return { store, runId, manifest, workerId: 'w-alarm', adapter: scriptedAdapter(4) } - }, - encodeEvent: (event) => `data: ${event}\n`, - now: () => 1000, - } -} - -async function consume( - stream: AsyncGenerator, - limit = Number.POSITIVE_INFINITY, -): Promise { - const out: T[] = [] - for await (const v of stream) { - out.push(v) - if (out.length >= limit) break - } - return out -} - -describe('SessionSupervisorDO — fetch', () => { - it('streams the supervised events, arms the alarm, and clears the run on completion', async () => { - const store = new InMemoryDurableRunStore() - const DO = createSessionSupervisorDO(config(store)) - const state = new FakeState() - const instance = new DO(state, {}) - - const res = await instance.fetch(new Request('http://do/')) - const text = await res.text() - - expect(res.headers.get('content-type')).toBe('text/event-stream') - expect(text).toBe('data: 0\ndata: 1\ndata: 2\ndata: 3\n') - expect(state.storage.alarmAt).toBe(1000 + 60_000) - // The run finished — the orphan-tracking key is cleared. - expect(state.storage.map.has(ACTIVE_RUN_KEY)).toBe(false) - expect((await store.readStreamEvents('do-run')).map((e) => e.payload)).toEqual([0, 1, 2, 3]) - }) - - it('404s when no run resolves for the request', async () => { - const store = new InMemoryDurableRunStore() - const DO = createSessionSupervisorDO({ - ...config(store), - async resolveRun() { - return undefined - }, - }) - const res = await new DO(new FakeState(), {}).fetch(new Request('http://do/')) - expect(res.status).toBe(404) - }) -}) - -describe('SessionSupervisorDO — alarm re-attaches an orphan', () => { - it('drives a run abandoned by a dropped response stream to completion', async () => { - const store = new InMemoryDurableRunStore() - - // A fetch drained 2 of 4 events, then its worker died — simulate by - // partially consuming a supervised turn and lapsing the lease. - await consume( - runSupervisedTurn({ - store, - runId: 'do-run', - manifest, - workerId: 'w-dead', - adapter: scriptedAdapter(4), - }).stream, - 2, - ) - store._expireLease('do-run') - expect(await store.readStreamEvents('do-run')).toHaveLength(2) - - // The DO still has the run recorded; the orphan-check alarm fires. - const DO = createSessionSupervisorDO(config(store)) - const state = new FakeState() - await state.storage.put(ACTIVE_RUN_KEY, 'do-run') - await new DO(state, {}).alarm() - - // The run was re-driven headlessly to completion and cleared. - expect((await store.readStreamEvents('do-run')).map((e) => e.payload)).toEqual([0, 1, 2, 3]) - expect(store._inspect('do-run')?.status).toBe('completed') - expect(state.storage.map.has(ACTIVE_RUN_KEY)).toBe(false) - }) - - it('is a no-op when no run is recorded', async () => { - const store = new InMemoryDurableRunStore() - const DO = createSessionSupervisorDO(config(store)) - await expect(new DO(new FakeState(), {}).alarm()).resolves.toBeUndefined() - }) -}) diff --git a/src/durable/tests/sqlite-d1-adapter.ts b/src/durable/tests/sqlite-d1-adapter.ts deleted file mode 100644 index 5acdb3e8..00000000 --- a/src/durable/tests/sqlite-d1-adapter.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Wrap better-sqlite3 in the D1DatabaseLike surface so the durable-runs - * test matrix exercises the D1DurableRunStore against a real SQLite engine. - * Catches actual SQL semantics — unique constraints, CASE expressions, - * conditional UPDATEs — instead of a hand-rolled parser stub. - */ - -import type { Database as SqliteDatabase, Statement as SqliteStatement } from 'better-sqlite3' -import Database from 'better-sqlite3' - -import type { D1DatabaseLike, D1PreparedStatementLike } from '../d1-store' - -export interface SqliteD1Handle { - db: D1DatabaseLike - raw: SqliteDatabase - close(): void -} - -export function createSqliteD1(): SqliteD1Handle { - const raw = new Database(':memory:') - raw.pragma('journal_mode = WAL') - raw.pragma('foreign_keys = ON') - const adapter: D1DatabaseLike = { - prepare(query: string): D1PreparedStatementLike { - // D1's ISO-8601 millis: `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` is - // SQLite-native; keep as-is. - return new SqliteAdaptedStatement(raw.prepare(query)) - }, - async batch(statements: D1PreparedStatementLike[]): Promise { - const results: unknown[] = [] - for (const s of statements) results.push(await s.run()) - return results - }, - } - return { db: adapter, raw, close: () => raw.close() } -} - -class SqliteAdaptedStatement implements D1PreparedStatementLike { - private bound: unknown[] = [] - - constructor(private readonly stmt: SqliteStatement) {} - - bind(...values: unknown[]): D1PreparedStatementLike { - this.bound = values.map(coerceBindValue) - return this - } - - async first(): Promise { - const row = this.stmt.get(...this.bound) - return (row as T | undefined) ?? null - } - - async all(): Promise<{ results: T[] }> { - const rows = this.stmt.all(...this.bound) as T[] - return { results: rows } - } - - async run(): Promise<{ success: boolean; meta?: { changes?: number } }> { - const info = this.stmt.run(...this.bound) - return { success: true, meta: { changes: info.changes } } - } -} - -/** better-sqlite3 rejects raw `undefined`; map to null. */ -function coerceBindValue(v: unknown): unknown { - if (v === undefined) return null - if (typeof v === 'boolean') return v ? 1 : 0 - return v -} diff --git a/src/durable/tests/supervisor.test.ts b/src/durable/tests/supervisor.test.ts deleted file mode 100644 index 8efd0137..00000000 --- a/src/durable/tests/supervisor.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InMemoryDurableRunStore } from '../in-memory-store' -import { - type RunSupervisorOptions, - runSupervisedTurn, - type SandboxReconnectAdapter, - type SupervisedEvent, -} from '../supervisor' -import type { DurableRunManifest, RunHandle } from '../types' - -const manifest = { - projectId: 'test', - scenarioId: 'persona-1', - task: { id: 'task-1', intent: 'chat', domain: 'test' }, - input: { q: 'hello' }, -} as unknown as DurableRunManifest - -const HANDLE: RunHandle = { - kind: 'sandbox', - sandboxId: 'sbx-1', - runId: 'sbx-run-1', - status: 'running', -} - -interface ScriptEvent { - eventId: string - payload: number -} - -/** A controllable SandboxReconnectAdapter over a fixed script of events. - * `start` carries the handle on the first frame; `attach` resumes past the - * cursor (or re-yields the boundary when `reYieldBoundary`). */ -class ScriptedAdapter implements SandboxReconnectAdapter { - startCalls = 0 - attachCalls = 0 - constructor( - private readonly script: ScriptEvent[], - private readonly opts: { reYieldBoundary?: boolean } = {}, - ) {} - - async *start(): AsyncGenerator> { - this.startCalls += 1 - for (const [i, e] of this.script.entries()) { - yield { eventId: e.eventId, payload: e.payload, handle: i === 0 ? HANDLE : undefined } - } - } - - async *attach( - _handle: RunHandle, - afterEventId: string | undefined, - ): AsyncGenerator> { - this.attachCalls += 1 - const found = afterEventId ? this.script.findIndex((e) => e.eventId === afterEventId) : -1 - const from = found >= 0 ? found + (this.opts.reYieldBoundary ? 0 : 1) : 0 - for (let i = from; i < this.script.length; i++) { - const e = this.script[i]! - yield { eventId: e.eventId, payload: e.payload } - } - } -} - -const script: ScriptEvent[] = [0, 1, 2, 3, 4, 5].map((n) => ({ eventId: `e${n}`, payload: n })) - -function opts( - store: InMemoryDurableRunStore, - workerId: string, - adapter: SandboxReconnectAdapter, - extra: Partial> = {}, -): RunSupervisorOptions { - return { store, runId: 'turn-1', manifest, workerId, adapter, ...extra } -} - -async function consume( - stream: AsyncGenerator, - limit = Number.POSITIVE_INFINITY, -): Promise { - const out: T[] = [] - for await (const v of stream) { - out.push(v) - if (out.length >= limit) break - } - return out -} - -describe('runSupervisedTurn — fresh', () => { - it('drains the adapter stream into the durable log and forwards it', async () => { - const store = new InMemoryDurableRunStore() - const sup = runSupervisedTurn(opts(store, 'w1', new ScriptedAdapter(script))) - const out = await consume(sup.stream) - - expect(out).toEqual([0, 1, 2, 3, 4, 5]) - expect(sup.mode()).toBe('fresh') - expect(sup.record()?.status).toBe('completed') - const logged = await store.readStreamEvents('turn-1') - expect(logged.map((e) => e.payload)).toEqual([0, 1, 2, 3, 4, 5]) - expect(logged.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) - }) - - it('persists the run handle and flips it to completed on a clean drain', async () => { - const store = new InMemoryDurableRunStore() - const sup = runSupervisedTurn(opts(store, 'w1', new ScriptedAdapter(script))) - await consume(sup.stream) - expect(sup.record()?.handle).toMatchObject({ runId: 'sbx-run-1', status: 'completed' }) - }) -}) - -describe('runSupervisedTurn — replayed', () => { - it('a completed turn replays the logged stream without touching the adapter', async () => { - const store = new InMemoryDurableRunStore() - await consume(runSupervisedTurn(opts(store, 'w1', new ScriptedAdapter(script))).stream) - - const neverAdapter: SandboxReconnectAdapter = { - start() { - throw new Error('start must not run on replay') - }, - attach() { - throw new Error('attach must not run on replay') - }, - } - const replay = runSupervisedTurn(opts(store, 'w2', neverAdapter)) - const out = await consume(replay.stream) - expect(out).toEqual([0, 1, 2, 3, 4, 5]) - expect(replay.mode()).toBe('replayed') - }) -}) - -describe('runSupervisedTurn — chaos: cross-worker resume', () => { - it('a turn killed mid-stream resumes on a fresh worker with no gap and no duplicate', async () => { - const store = new InMemoryDurableRunStore() - - // Worker 1 drains 3 of 6 events, then its isolate "dies" — stop pulling. - const sup1 = runSupervisedTurn(opts(store, 'w1', new ScriptedAdapter(script))) - const partial = await consume(sup1.stream, 3) - expect(partial).toEqual([0, 1, 2]) - - // The dead worker no longer heartbeats — its lease lapses. - store._expireLease('turn-1') - expect(await store.readStreamEvents('turn-1')).toHaveLength(3) - - // Worker 2 picks the run up. The sandbox container outlived worker 1, so - // the adapter still has the whole script. - const adapter2 = new ScriptedAdapter(script) - const sup2 = runSupervisedTurn(opts(store, 'w2', adapter2)) - const out = await consume(sup2.stream) - - expect(sup2.mode()).toBe('resumed') - expect(adapter2.attachCalls).toBe(1) - expect(adapter2.startCalls).toBe(0) - // Worker 2's stream is the COMPLETE turn, each event exactly once. - expect(out).toEqual([0, 1, 2, 3, 4, 5]) - expect(sup2.record()?.status).toBe('completed') - // The durable log holds the full sequence with monotonic seqs — no gap. - const logged = await store.readStreamEvents('turn-1') - expect(logged.map((e) => e.payload)).toEqual([0, 1, 2, 3, 4, 5]) - expect(logged.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) - }) - - it('dedups the reconnect seam when the adapter re-yields the boundary event', async () => { - const store = new InMemoryDurableRunStore() - const sup1 = runSupervisedTurn(opts(store, 'w1', new ScriptedAdapter(script))) - await consume(sup1.stream, 3) - store._expireLease('turn-1') - - // adapter2.attach re-yields the boundary event e2 (inclusive resume). - const adapter2 = new ScriptedAdapter(script, { reYieldBoundary: true }) - const sup2 = runSupervisedTurn(opts(store, 'w2', adapter2)) - const out = await consume(sup2.stream) - - // The re-yielded e2 is idempotent on append → not double-forwarded. - expect(out).toEqual([0, 1, 2, 3, 4, 5]) - const logged = await store.readStreamEvents('turn-1') - expect(logged.map((e) => e.eventId)).toEqual(['e0', 'e1', 'e2', 'e3', 'e4', 'e5']) - }) - - it('survives two successive mid-stream deaths', async () => { - const store = new InMemoryDurableRunStore() - await consume(runSupervisedTurn(opts(store, 'w1', new ScriptedAdapter(script))).stream, 2) - store._expireLease('turn-1') - await consume(runSupervisedTurn(opts(store, 'w2', new ScriptedAdapter(script))).stream, 4) - store._expireLease('turn-1') - const sup3 = runSupervisedTurn(opts(store, 'w3', new ScriptedAdapter(script))) - const out = await consume(sup3.stream) - expect(out).toEqual([0, 1, 2, 3, 4, 5]) - expect((await store.readStreamEvents('turn-1')).map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) - }) -}) - -describe('runSupervisedTurn — lease + heartbeat', () => { - it('renews the lease while draining a long turn', async () => { - const store = new InMemoryDurableRunStore() - const renew = vi.spyOn(store, 'renewLease') - let clock = 0 - const sup = runSupervisedTurn( - opts(store, 'w1', new ScriptedAdapter(script), { - heartbeatMs: 10, - now: () => { - clock += 100 // each call advances well past the heartbeat window - return clock - }, - }), - ) - await consume(sup.stream) - expect(renew).toHaveBeenCalled() - }) - - it('aborts without writing terminal state when the lease is lost mid-drain', async () => { - const store = new InMemoryDurableRunStore() - vi.spyOn(store, 'renewLease').mockResolvedValue({ ok: false }) - let clock = 0 - const sup = runSupervisedTurn( - opts(store, 'w1', new ScriptedAdapter(script), { - heartbeatMs: 1, - now: () => { - clock += 100 - return clock - }, - }), - ) - await expect(consume(sup.stream)).rejects.toThrow(/lease lost/) - // The supervisor that lost the lease must not mark the run failed — - // the new owner owns the terminal state. - expect(store._inspect('turn-1')?.status).not.toBe('failed') - }) -}) - -describe('SandboxReconnectAdapter — conformance', () => { - it('start yields a running handle with a runId on an early frame', async () => { - const adapter = new ScriptedAdapter(script) - const first = await adapter.start().next() - expect(first.done).toBe(false) - expect(first.value?.handle).toMatchObject({ status: 'running', runId: 'sbx-run-1' }) - }) - - it('attach yields only events strictly after the cursor', async () => { - const adapter = new ScriptedAdapter(script) - const out: number[] = [] - for await (const e of adapter.attach(HANDLE, 'e2')) out.push(e.payload) - expect(out).toEqual([3, 4, 5]) - }) - - it('attach from an undefined cursor yields the whole run', async () => { - const adapter = new ScriptedAdapter(script) - const out: number[] = [] - for await (const e of adapter.attach(HANDLE, undefined)) out.push(e.payload) - expect(out).toEqual([0, 1, 2, 3, 4, 5]) - }) -}) diff --git a/src/durable/turn.ts b/src/durable/turn.ts deleted file mode 100644 index 1fdb6f83..00000000 --- a/src/durable/turn.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * `runDurableTurn` — a streaming, backend-agnostic, checkpoint+replay durable - * turn. The single reusable primitive every product's chat handler routes - * through, so per-product durability code drops to zero. - * - * A **turn** is one request→response unit: a producer yields a stream of - * events and, once drained, exposes the turn's final text. `runDurableTurn` - * wraps that with a `DurableRunStore`: - * - * - **Fresh run** — no completed step for this `(runId)`. The producer - * runs; its events forward live to the caller (streaming preserved) - * while final text accumulates; on drain the text is checkpointed. - * - * - **Replay** — a completed step already exists (the worker died after - * the turn finished but before the response reached the client, and the - * client retried the same turn). The cached text is emitted as a single - * synthetic event; the producer is never constructed — no LLM call, no - * double-billing. - * - * - **Mid-stream crash** — a turn that died *while streaming* leaves step 0 - * in `running`/`failed`. There is no partial-stream checkpoint (the - * substrate checkpoints JSON values at step granularity), so the turn - * re-runs from the top. This is the honest durability ceiling: a - * *completed* turn is free to replay; an *interrupted* turn re-runs. - * - * Generic over the event type `TEvent` so a product can stream its own NDJSON - * shape or the runtime's `RuntimeStreamEvent` — `runDurableTurn` never - * inspects events, it only forwards them and reads `finalText()` after drain. - * - * Lease: a turn is a single step, fast enough that the heartbeat in - * `runDurable` is unnecessary — `runDurableTurn` claims the lease once via - * `startOrResume` and releases it on `endRun`. Concurrent workers on the same - * `runId` are rejected with `DurableRunLeaseHeldError` (the client retried - * before the first attempt finished); callers surface that as "turn already - * in flight." - */ - -import { canonicalHash } from './identity' -import type { DurableRunManifest, DurableRunStore, RunRecord } from './types' - -/** The live side of a turn — what a fresh run produces. */ -export interface DurableTurnProducer { - /** The turn's event stream. Forwarded verbatim to the caller. */ - stream: AsyncGenerator - /** The turn's final assistant text. Read once, after `stream` drains. */ - finalText(): string -} - -export interface RunDurableTurnOptions { - store: DurableRunStore - /** Stable per-turn run id. Convention: `chat::`. The - * same id on a retry is what enables replay. */ - runId: string - manifest: DurableRunManifest - /** Stable per-isolate worker id. Defaults to a fresh `deriveWorkerId()` - * per call when omitted — fine for single-attempt turns. */ - workerId: string - /** Lease window in ms. Default 60_000 — a turn rarely runs longer. */ - leaseMs?: number - /** Human-readable step label. Default `turn`. */ - intent?: string - /** Builds the live producer. Called exactly once, on a fresh run; never - * called on the replay path. */ - produce: () => DurableTurnProducer - /** Synthesizes the single event emitted on the replay path from the - * cached final text (e.g. a product's `{ type: 'result', data: {...} }`). */ - replayEvent: (finalText: string) => TEvent - /** Optional live accumulator. When the producer's `finalText()` is only - * valid after drain, this lets `runDurableTurn` also observe each event - * to build the text — return the running text or `undefined` to ignore - * an event. When omitted, `producer.finalText()` is the sole source. */ - accumulate?: (event: TEvent, current: string) => string | undefined -} - -export interface DurableTurnHandle { - /** Drop-in stream. Fresh runs forward producer events live; replays emit - * exactly one `replayEvent(cachedText)`. */ - stream: AsyncGenerator - /** The turn's final text. Valid after `stream` drains. */ - finalText(): string - /** True iff this turn replayed a cached result (no producer ran). Valid - * after `stream` drains. */ - replayed(): boolean - /** The durable `RunRecord` for this turn. Valid after `stream` drains. */ - record(): RunRecord | undefined -} - -const STEP_INDEX = 0 - -export function runDurableTurn( - options: RunDurableTurnOptions, -): DurableTurnHandle { - const { store, runId, manifest, workerId } = options - const leaseMs = options.leaseMs ?? 60_000 - const intent = options.intent ?? 'turn' - const inputHash = canonicalHash(manifest.input) - - let accumulated = '' - let didReplay = false - let finalRecord: RunRecord | undefined - - async function* stream(): AsyncGenerator { - const { completedSteps } = await store.startOrResume({ - runId, - manifest, - workerId, - leaseMs, - }) - const prior = completedSteps.find((s) => s.stepIndex === STEP_INDEX) - - if (prior && prior.status === 'completed') { - // ── Replay path — producer never constructed ────────────────── - didReplay = true - const cached = prior.result as { finalText?: string } | undefined - accumulated = cached?.finalText ?? '' - yield options.replayEvent(accumulated) - finalRecord = await store.endRun({ runId, workerId, status: 'completed' }) - return - } - - // ── Fresh run — produce live, forward, checkpoint ──────────────── - await store.beginStep({ - runId, - stepIndex: STEP_INDEX, - intent, - kind: 'llm', - inputHash, - }) - try { - const producer = options.produce() - for await (const event of producer.stream) { - if (options.accumulate) { - const next = options.accumulate(event, accumulated) - if (typeof next === 'string') accumulated = next - } - yield event - } - // Producer's own finalText wins when it is populated; otherwise the - // live accumulator's value stands. - const producerText = producer.finalText() - if (producerText) accumulated = producerText - await store.completeStep({ - runId, - stepIndex: STEP_INDEX, - result: { finalText: accumulated }, - }) - finalRecord = await store.endRun({ - runId, - workerId, - status: 'completed', - outcome: { notes: intent, metadata: { chars: accumulated.length } }, - }) - } catch (err) { - await store.failStep({ - runId, - stepIndex: STEP_INDEX, - error: { message: err instanceof Error ? err.message : String(err) }, - }) - finalRecord = await store.endRun({ runId, workerId, status: 'failed' }) - throw err - } - } - - return { - stream: stream(), - finalText: () => accumulated, - replayed: () => didReplay, - record: () => finalRecord, - } -} diff --git a/src/durable/types.ts b/src/durable/types.ts deleted file mode 100644 index 617fa6da..00000000 --- a/src/durable/types.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * Durable-run substrate: the typed contract for checkpointed agent runs that - * survive worker crashes, deploy rolls, OOM, and transient transport errors. - * - * The model — directly inspired by Absurd (Postgres-backed) and Cloudflare - * Workflows — splits a run into ordered, idempotent **steps**. Each step's - * result is persisted before the next step runs. On resume, the runner reads - * the prior steps from a `DurableRunStore` and fast-replays them (returning - * cached values) until it reaches the first unfinished step, where execution - * actually resumes. - * - * Three boundary disciplines: - * - * 1. Step results MUST be JSON-serializable. No closures, no class - * instances, no live streams. The store treats results as opaque JSON. - * - * 2. Step intents MUST be stable across replays. The runner derives a - * stable step id from (runId, stepIndex, intent). Mismatched intent at - * the same index = `DurableRunDivergenceError`. - * - * 3. Non-determinism (now / uuid / random) MUST flow through the - * `DurableContext` helpers — `ctx.now()`, `ctx.uuid()` — so the values - * are checkpointed and identical on replay. Bare `Date.now()` / - * `crypto.randomUUID()` inside a task fn breaks replay equality. - */ - -import type { AgentTaskSpec } from '../types' - -/** Caller-facing kinds. The runner uses these for telemetry + querying. */ -export type StepKind = - /** Logical step that ran user code (the default for ctx.step). */ - | 'logic' - /** A wrapped LLM call. */ - | 'llm' - /** A wrapped tool call. */ - | 'tool' - /** A wrapped readiness probe. */ - | 'readiness' - /** A deterministic clock or uuid read. */ - | 'deterministic' - /** A suspend-for-event boundary. */ - | 'event' - -export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' - -export interface StepError { - message: string - code?: string - /** Optional stack — stored for diagnostics, NEVER replayed as an exception. */ - stack?: string -} - -export interface StepRecord { - runId: string - /** Monotonic 0-based index. Position is the load-bearing identifier — the - * same intent string at different positions is a different step. */ - stepIndex: number - /** Caller-supplied label; intended for human reading + log correlation. */ - intent: string - kind: StepKind - /** sha256 of the canonical input fingerprint at begin-time. Used to detect - * divergence (caller changed inputs across replays). Empty for steps where - * the input cannot be canonicalized (e.g. ctx.now()). */ - inputHash: string - status: StepStatus - /** Re-entry count. Increments each time the step begins. */ - attempts: number - /** JSON-serializable result. Present when status === 'completed'. */ - result?: T - error?: StepError - startedAt?: string - completedAt?: string -} - -export interface EventRecord { - runId: string - key: string - payload: unknown - emittedAt: string -} - -/** - * A pointer to a substrate run that outlives the worker isolate — the sandbox - * container is orchestrator-managed and survives a Worker death or a Durable - * Object migration. Persisted on the run row so a fresh supervisor re-attaches - * to the in-flight run instead of re-prompting. - */ -export interface RunHandle { - /** Which substrate owns the run. `sandbox` runs are reconnectable; - * `tcloud` runs have no cross-process replay endpoint. */ - kind: 'sandbox' | 'tcloud' - /** Orchestrator-managed sandbox id — stable across worker isolates. */ - sandboxId?: string - /** Sandbox conversation/session id. */ - sessionId?: string - /** The substrate run id (the sandbox SDK's `executionId`). The replay - * endpoint keys on it. */ - runId?: string - /** Lifecycle of the substrate run as last observed. */ - status: 'running' | 'completed' | 'failed' - /** Last substrate event id seen — the adapter's reconnect cursor. */ - cursor?: string -} - -/** - * One event in a run's ordered, replayable stream log. The supervisor drains - * a run's event stream into this log as it flows, so replay is guaranteed by - * the substrate rather than by the sandbox runtime's own buffering. - */ -export interface StreamEventRecord { - runId: string - /** Monotonic 0-based sequence — the store's ordering + cursor. */ - seq: number - /** Producer-supplied stable id — the dedup key and the substrate cursor. */ - eventId: string - payload: unknown - appendedAt: string -} - -export type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'suspended' - -export interface RunOutcome { - pass?: boolean - score?: number - notes?: string - /** Free-form bag of run-level metrics — surfaced in OTLP / TraceStore. */ - metadata?: Record -} - -export interface DurableRunManifest { - /** Stable per-product id (e.g. 'legal-agent', 'creative-agent'). */ - projectId: string - /** Optional scenario / persona / session id — surfaced in telemetry. */ - scenarioId?: string - task: AgentTaskSpec - /** Input payload. Hashed into the run identity so two runs with the same - * runId but different inputs raise DurableRunInputMismatchError. */ - input: Record - /** Free-form tags surfaced into RunRecord / OTLP. */ - tags?: Record -} - -export interface RunRecord { - runId: string - manifestHash: string - projectId: string - scenarioId?: string - status: RunStatus - createdAt: string - updatedAt: string - completedAt?: string - /** Stable per-worker id holding the lease. */ - leaseHolderId?: string - leaseExpiresAt?: string - outcome?: RunOutcome - stepCount: number - /** Pointer to the in-flight substrate run, when one has been registered. - * A fresh supervisor re-attaches by it. */ - handle?: RunHandle -} - -/** - * The durable-run substrate. Implementations: in-memory (dev), file-system - * (eval harness), D1 (Cloudflare prod). All stores share this exact contract - * — swap by changing one factory call. - * - * Concurrency model: at most one worker holds a run's lease at a time. Lease - * renewal happens on a heartbeat; on lease expiry, another worker can - * `startOrResume` and pick up. Steps committed by the prior worker survive. - */ -export interface DurableRunStore { - /** - * Begin or resume a run. Returns the canonical RunRecord, all previously - * completed steps (in order), and the lease deadline. - * - * If the run did not exist, creates it with status='running'. If it existed - * with a different manifest hash, throws DurableRunInputMismatchError. - * If it existed with a live lease held by a different worker, throws - * DurableRunLeaseHeldError (caller can retry or back off). - */ - startOrResume(input: { - runId: string - manifest: DurableRunManifest - workerId: string - leaseMs?: number - }): Promise<{ - run: RunRecord - completedSteps: ReadonlyArray - leaseExpiresAt: string - }> - - /** Renew the lease. Returns false if another worker now holds it. */ - renewLease(input: { - runId: string - workerId: string - leaseMs?: number - }): Promise<{ ok: boolean; leaseExpiresAt?: string }> - - /** Load a step by position. Returns undefined if not yet begun. */ - loadStep(runId: string, stepIndex: number): Promise - - /** Record step start (intent + input hash + kind). Bumps attempt count. */ - beginStep(input: { - runId: string - stepIndex: number - intent: string - kind: StepKind - inputHash: string - }): Promise - - /** Mark step completed with a JSON-serializable result. */ - completeStep(input: { runId: string; stepIndex: number; result: unknown }): Promise - - /** Mark step failed with a captured error. */ - failStep(input: { runId: string; stepIndex: number; error: StepError }): Promise - - /** End the run; releases lease. */ - endRun(input: { - runId: string - workerId: string - status: 'completed' | 'failed' - outcome?: RunOutcome - }): Promise - - /** - * Emit an event. First emit wins; subsequent emits return the existing - * record under `existing` and accepted=false. Caller can treat that as - * idempotency-by-design — never double-fire a downstream side effect. - */ - emitEvent(input: { - runId: string - key: string - payload: unknown - }): Promise<{ accepted: boolean; record: EventRecord }> - - /** Load the cached event payload if it has been emitted. */ - loadEvent(runId: string, key: string): Promise - - /** - * Append an event to the run's ordered stream log. The store assigns the - * monotonic `seq`. Idempotent on `eventId`: re-appending a known id is a - * no-op that returns the existing record under `accepted: false` — so an - * adapter that re-yields a boundary event on reconnect cannot double-log. - */ - appendStreamEvent(input: { - runId: string - eventId: string - payload: unknown - }): Promise<{ accepted: boolean; record: StreamEventRecord }> - - /** - * Read the stream log in `seq` order. `afterSeq` (exclusive) resumes a - * reader from a cursor; omit for the whole log. - */ - readStreamEvents(runId: string, afterSeq?: number): Promise> - - /** Persist the run handle — the pointer a fresh supervisor re-attaches by. - * One per run; overwrites. */ - setRunHandle(input: { runId: string; handle: RunHandle }): Promise - - /** Cleanup hook for in-memory / fs stores; no-op for D1. Idempotent. */ - close(): Promise -} - -// ── Public error contract ──────────────────────────────────────────────── - -/** Base class for durable-run errors. */ -export class DurableRunError extends Error { - constructor( - message: string, - public readonly code: - | 'lease_held' - | 'manifest_mismatch' - | 'step_divergence' - | 'step_input_mismatch' - | 'await_event_timeout' - | 'event_emit_race', - ) { - super(message) - this.name = this.constructor.name - } -} - -/** Thrown when another worker holds the lease for this runId. */ -export class DurableRunLeaseHeldError extends DurableRunError { - constructor(message: string) { - super(message, 'lease_held') - } -} - -/** Thrown when the manifest hash differs from a prior run with the same id. */ -export class DurableRunInputMismatchError extends DurableRunError { - constructor(message: string) { - super(message, 'manifest_mismatch') - } -} - -/** Thrown when the same stepIndex re-runs with a different intent string. */ -export class DurableRunDivergenceError extends DurableRunError { - constructor(message: string) { - super(message, 'step_divergence') - } -} - -/** Thrown when `awaitEvent` times out. */ -export class DurableAwaitEventTimeoutError extends DurableRunError { - constructor(message: string) { - super(message, 'await_event_timeout') - } -} diff --git a/src/durable/workflows.ts b/src/durable/workflows.ts deleted file mode 100644 index 1af75efb..00000000 --- a/src/durable/workflows.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Cloudflare Workflows integration for the durable-run substrate. - * - * Two valid deployment patterns on Cloudflare: - * - * A. **Plain Worker + D1DurableRunStore.** Each request invokes - * `runDurable(...)` directly against a D1 binding. Survives worker - * isolate restarts; lease takeover happens via D1 row-level - * conditional UPDATE. The default path; no Workflows binding needed. - * - * B. **Cloudflare Workflows entrypoint.** Wrap an entire `runDurable(...)` - * call inside a single Workflow `step.do(...)`. Workflows gives you - * retry-on-throw with platform-managed exponential backoff and - * survives full Workers deploy rolls. Use it when the task can take - * minutes to hours, or when you want the Workflows dashboard for - * observability. Inside the step, `runDurable` still uses D1 for - * step-level checkpoints — so a half-completed run resumes from - * its last checkpoint on retry rather than restarting from scratch. - * - * This module provides the surface for pattern B: a thin helper that - * converts a Workflows `WorkflowStep` into a `DurableContext`. We do not - * take a runtime dep on `cloudflare:workers` — the integration is purely - * structural typing. - * - * Example (pattern B): - * - * import { WorkflowEntrypoint } from 'cloudflare:workers' - * import { runOnWorkflowStep } from '@tangle-network/agent-runtime' - * - * export class LegalChatWorkflow extends WorkflowEntrypoint { - * async run(event, step) { - * return runOnWorkflowStep(step, { - * workflowName: 'legal-chat', - * taskFn: async (ctx) => { - * const ready = await ctx.step('readiness', () => probeKnowledge(...)) - * const answer = await ctx.step('llm:turn-1', () => callLlm(...)) - * const shipped = await ctx.awaitEvent('shipped', { timeoutMs: 5 * 60_000 }) - * return { answer, shipped } - * }, - * }) - * } - * } - * - * Step ordering, replay semantics, and divergence detection inside the - * `taskFn` are inherited from Cloudflare's Workflows engine — we - * intentionally do NOT layer a second durable store inside this path. - * Pick pattern A or pattern B per agent; do not mix. - */ - -import type { DurableContext } from './runner' - -/** - * Structural subset of Cloudflare's `WorkflowStep`. Mirrors the public surface - * documented at https://developers.cloudflare.com/workflows/build/. Defined - * here so this module imposes zero `cloudflare:workers` runtime dependency. - */ -export interface WorkflowStepLike { - do(name: string, opts: WorkflowStepConfig, fn: () => Promise): Promise - do(name: string, fn: () => Promise): Promise - sleep(name: string, duration: string | number): Promise - waitForEvent( - name: string, - opts: { type: string; timeout?: string }, - ): Promise<{ - payload: T - timestamp: number - type: string - }> -} - -export interface WorkflowStepConfig { - retries?: { - limit: number - delay: string | number - backoff?: 'constant' | 'linear' | 'exponential' - } - timeout?: string | number -} - -export interface RunOnWorkflowStepInput { - /** Logical workflow name; used as a prefix on step ids for filtering. */ - workflowName: string - /** User task — same shape as runDurable's taskFn. */ - taskFn: (ctx: DurableContext) => Promise - /** Optional per-step retry / timeout policy applied to ctx.step calls. */ - stepConfig?: WorkflowStepConfig - /** Optional clock — defaults to Date.now. */ - now?: () => number -} - -/** - * Adapt a Cloudflare `WorkflowStep` into a `DurableContext` and run a task. - * - * Every `ctx.step(intent, fn)` becomes `step.do(, fn)` with stable - * names — Workflows checkpoints + replays based on step name + position, - * matching our model. - * - * `ctx.awaitEvent(key)` becomes `step.waitForEvent(key, { type: key })`. - * Caller is responsible for emitting from the platform side (e.g. via the - * Workflows REST API or a sibling worker that publishes events). - * - * `ctx.now()` and `ctx.uuid()` go through `step.do` so the values are - * captured in the platform's checkpoint state and remain stable across - * replay — same invariant as our own stores. - */ -export async function runOnWorkflowStep( - workflowStep: WorkflowStepLike, - input: RunOnWorkflowStepInput, -): Promise { - const stepCfg = input.stepConfig - let counter = 0 - const stepName = (intent: string) => `${input.workflowName}/${counter++}:${intent}` - - const ctx: DurableContext = { - runId: `cf-workflow:${input.workflowName}`, - projectId: input.workflowName, - async step(intent: string, fn: () => Promise): Promise { - const name = stepName(intent) - if (stepCfg) { - return workflowStep.do(name, stepCfg, fn) - } - return workflowStep.do(name, fn) - }, - async awaitEvent(key: string, opts?: { timeoutMs?: number }): Promise { - const timeout = opts?.timeoutMs ? `${Math.ceil(opts.timeoutMs / 1000)}s` : undefined - const ev = await workflowStep.waitForEvent(stepName(`event:${key}`), { - type: key, - timeout, - }) - return ev.payload - }, - async emitEvent(): Promise<{ accepted: boolean }> { - // Workflows events are emitted from OUTSIDE the workflow (via the - // platform API). A workflow that emits its own events is an - // anti-pattern — surface that fail-loud rather than silently no-op. - throw new Error( - 'runOnWorkflowStep: ctx.emitEvent is not available inside a Workflows entrypoint. ' + - 'Emit from the sibling worker that drives the workflow.', - ) - }, - async now(): Promise { - const iso = await this.step('deterministic:now', async () => new Date().toISOString()) - return new Date(iso) - }, - async uuid(): Promise { - return this.step('deterministic:uuid', async () => { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID() - } - // Cloudflare runtime always has Web Crypto; fallback is defensive. - const bytes = new Uint8Array(16) - crypto.getRandomValues(bytes) - bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 - bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` - }) - }, - } - - return input.taskFn(ctx) -} diff --git a/src/index.ts b/src/index.ts index c5118aba..4f3b9ae5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,11 +44,12 @@ export { runChatTurn, sandboxAsChatTurnTarget, } from './chat-turn' -// ── Durable-run substrate ───────────────────────────────────────────── -// Step-checkpointed agent runs that survive worker crashes, deploy rolls, -// rate-limit cascades, and transient transport errors. See ./durable for -// the full contract; in-memory + filesystem stores ship out of the box, -// D1 store + Cloudflare Workflows adapter land as opt-in subpath exports. +// ── Chat-turn HTTP orchestration ────────────────────────────────────── +// `handleChatTurn` frames a producer with the `session.run.*` envelope +// + NDJSON line protocol + persist/post-process/trace-flush hook order. +// `deriveExecutionId` produces the stable id products persist so a +// client retry can replay the same substrate execution. Long-running +// execution durability itself lives in @tangle-network/sandbox. export * from './durable' // ── Errors ─────────────────────────────────────────────────────────── export { diff --git a/tangle-network-agent-runtime-0.16.0.tgz b/tangle-network-agent-runtime-0.16.0.tgz new file mode 100644 index 00000000..e0f7e48f Binary files /dev/null and b/tangle-network-agent-runtime-0.16.0.tgz differ diff --git a/tests/workers/entry.ts b/tests/workers/entry.ts deleted file mode 100644 index 502cac59..00000000 --- a/tests/workers/entry.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Test-only worker entry. Builds a `SessionSupervisorDO` over an in-memory - * store + a controllable scripted adapter, so a vitest-pool-workers test can - * drive the real Cloudflare DO host under real `workerd`. Not a deployable - * Worker — referenced only by `wrangler.workers.toml`. - * - * The adapter is parameterised through the request URL (`?events=N&runId=…`) - * so the test injects different scenarios without recompiling. The in-memory - * store + scripted-adapter factory live at module scope; they survive across - * requests inside one workerd run, matching how a real product's DO would - * share a substrate binding across requests. - */ - -import { - createSessionSupervisorDO, - type DurableRunManifest, - InMemoryDurableRunStore, - type RunHandle, - type SandboxReconnectAdapter, - type SupervisedEvent, -} from '../../src/durable' - -interface TestEnv { - SESSION_SUPERVISOR: DurableObjectNamespace -} - -const store = new InMemoryDurableRunStore() - -const manifest = { - projectId: 'test', - scenarioId: 'persona-1', - task: { id: 'task-1', intent: 'chat', domain: 'test' }, - input: { q: 'hello' }, -} as unknown as DurableRunManifest - -function scriptedAdapter(n: number): SandboxReconnectAdapter { - const ids = Array.from({ length: n }, (_, i) => i) - return { - async *start(): AsyncGenerator> { - for (const i of ids) { - const handle: RunHandle | undefined = - i === 0 ? { kind: 'sandbox', runId: 'sbx-test', status: 'running' } : undefined - yield { eventId: `e${i}`, payload: i, handle } - } - }, - async *attach( - _handle: RunHandle, - afterEventId: string | undefined, - ): AsyncGenerator> { - const found = afterEventId ? ids.findIndex((i) => `e${i}` === afterEventId) : -1 - for (let i = found + 1; i < ids.length; i++) yield { eventId: `e${i}`, payload: i } - }, - } -} - -export const TestSessionSupervisor = createSessionSupervisorDO({ - async resolveRun(request) { - const url = new URL(request.url) - const runId = url.searchParams.get('runId') ?? 'r1' - const events = Number(url.searchParams.get('events') ?? '4') - return { - store, - runId, - manifest, - workerId: 'w-fetch', - adapter: scriptedAdapter(events), - } - }, - async resolveOrphan(runId) { - return { - store, - runId, - manifest, - workerId: 'w-alarm', - adapter: scriptedAdapter(4), - } - }, - encodeEvent: (event) => `data: ${event}\n`, -}) - -export default { - async fetch(request: Request, env: TestEnv): Promise { - const url = new URL(request.url) - const sessionId = url.searchParams.get('session') ?? 'default' - const id = env.SESSION_SUPERVISOR.idFromName(sessionId) - const stub = env.SESSION_SUPERVISOR.get(id) - return stub.fetch(request) - }, -} satisfies ExportedHandler diff --git a/tests/workers/session-supervisor-do.workers.test.ts b/tests/workers/session-supervisor-do.workers.test.ts deleted file mode 100644 index af9f1d76..00000000 --- a/tests/workers/session-supervisor-do.workers.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { SELF } from 'cloudflare:test' -import { describe, expect, it } from 'vitest' - -/** - * Test the SessionSupervisorDO end-to-end under real workerd: real - * `DurableObjectState`, real `ReadableStream`, real `Response`. The - * fake-state suite in `src/durable/tests/session-supervisor-do.test.ts` - * exercises the supervisor logic exhaustively (fresh / resumed / replayed / - * orphan re-attach via `alarm()`); this file's job is to prove the same DO - * host works against the actual Cloudflare runtime, catching any platform - * quirk the structural fakes miss. - */ -describe('SessionSupervisorDO under real workerd', () => { - it('streams a fresh supervised run end-to-end through a real DO', async () => { - const res = await SELF.fetch('http://test/?session=s-fresh&runId=r-fresh&events=4') - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('text/event-stream') - const text = await res.text() - expect(text).toBe('data: 0\ndata: 1\ndata: 2\ndata: 3\n') - }) - - it('a second fetch for the same runId replays the same stream from the durable log', async () => { - await SELF.fetch('http://test/?session=s-replay&runId=r-replay&events=3').then((r) => r.text()) - // Second fetch — the run already completed, so the supervisor enters the - // replay path and re-yields the logged stream verbatim. - const replayed = await SELF.fetch('http://test/?session=s-replay&runId=r-replay&events=3').then( - (r) => r.text(), - ) - expect(replayed).toBe('data: 0\ndata: 1\ndata: 2\n') - }) -}) diff --git a/vitest.config.ts b/vitest.config.ts index 0c08e073..280bf587 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,7 @@ import { defineConfig } from 'vitest/config' -/** Default Node-pool vitest run. The workerd-only suite under - * `tests/workers/**` runs separately via `pnpm test:workers` against - * `vitest.workers.config.ts` — it imports `cloudflare:test`, which only - * resolves inside `@cloudflare/vitest-pool-workers`. */ export default defineConfig({ test: { - exclude: ['node_modules/**', 'dist/**', 'tests/workers/**'], + exclude: ['node_modules/**', 'dist/**'], }, }) diff --git a/vitest.workers.config.ts b/vitest.workers.config.ts deleted file mode 100644 index ba9252ef..00000000 --- a/vitest.workers.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config' - -/** Separate vitest config used by `pnpm test:workers` so the main suite stays - * on the default Node pool; only the workerd-only `*.workers.test.ts` files - * run inside the real Cloudflare runtime via `@cloudflare/vitest-pool-workers`. */ -export default defineWorkersConfig({ - test: { - include: ['tests/workers/**/*.workers.test.ts'], - poolOptions: { - workers: { - wrangler: { configPath: './wrangler.workers.toml' }, - }, - }, - }, -}) diff --git a/wrangler.workers.toml b/wrangler.workers.toml deleted file mode 100644 index 68ff9c26..00000000 --- a/wrangler.workers.toml +++ /dev/null @@ -1,17 +0,0 @@ -# Wrangler config used ONLY by the `test:workers` vitest pool — it pins a -# real `workerd` runtime around the `SessionSupervisorDO` so the DO host is -# exercised under actual Cloudflare semantics (real DurableObjectState, real -# ReadableStream, real Response). Not a deployable Worker. - -name = "agent-runtime-workers-tests" -main = "tests/workers/entry.ts" -compatibility_date = "2025-05-01" -compatibility_flags = ["nodejs_compat"] - -[[durable_objects.bindings]] -name = "SESSION_SUPERVISOR" -class_name = "TestSessionSupervisor" - -[[migrations]] -tag = "v1" -new_classes = ["TestSessionSupervisor"]