From 7dc05edc6ebd3d23d655ae390ce6cfe077cec8ff Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Fri, 2 Oct 2026 14:09:05 +0300 Subject: [PATCH 1/4] R2: export KVStore from a /kv subpath and add fileStore(dir) - New subpath @lousho/build-ai-agent/kv (src/deploy/kv.ts): KVStore, KVCheckpointStore, CHECKPOINT_KV_BINDING and the KVStoreOptions, KVBinding, KVPutOptions types. Its import graph is Node-free: assertSessionId moved to src/session/sessionId.ts (re-exported from sessionStore.ts), and the type-only imports in kvStore.ts and checkpoint.ts are now `import type`. kv.test.ts bundles the barrel with esbuild for the browser platform and fails on any node: import. - fileStore(dir, { historyLimit }) (root export): sessions, checkpoints, checkpoint history and approvals as JSON files, written atomically, no lock files. Approvals are claimed with an exclusive create so two resolvers get the record once (a rename claim is not atomic on Windows). - Docs: sessions, deployment, durable-execution, api-overview; CHANGELOG. Closes #189 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/api-overview.md | 2 + docs/deployment.md | 36 +++- docs/durable-execution.md | 3 +- docs/sessions.md | 37 ++-- llms-full.txt | 76 ++++--- package.json | 5 + src/deploy/kv.test.ts | 44 ++++ src/deploy/kv.ts | 9 + src/deploy/kvStore.ts | 3 +- src/execution/checkpoint.ts | 4 +- .../checkpointHistory.contract.test.ts | 23 +- src/index.ts | 1 + src/session/sessionId.ts | 24 +++ src/session/sessionStore.ts | 20 +- src/storage/fileStore.test.ts | 184 ++++++++++++++++ src/storage/fileStore.ts | 203 ++++++++++++++++++ tsup.config.ts | 1 + 18 files changed, 598 insertions(+), 78 deletions(-) create mode 100644 src/deploy/kv.test.ts create mode 100644 src/deploy/kv.ts create mode 100644 src/session/sessionId.ts create mode 100644 src/storage/fileStore.test.ts create mode 100644 src/storage/fileStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 779900f9..c5fcadd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The project is now **lousho** (it was `loushy`), before the first npm release, so nothing was ever published under the old name. Everything that carried the name changed with it, and this changelog uses the new names throughout, including in older entries: the package `@lousho/build-ai-agent` (was `@loushy/build-ai-agent`), the `lousho` CLI (was `loushy`), `create-lousho-agent` (was `create-loushy-agent`), the exports `useLoushoAgent`, `loushoAgent`, `LoushoAgentSource`, `LoushoUIMessageChunk` and the other `Lousho*` types, every `LOUSHO_*` error code and environment variable (`LOUSHO_MODEL`, `LOUSHO_API_TOKEN`, `LOUSHO_STORE`, ...), the `.lousho/` directory, the `lousho.*` span attributes and the `data-lousho-approval` stream part. Migration for a checkout that used the old name: replace `loushy` with `lousho` (keeping the case) in imports, scripts, environment variables and config, and rename an existing `.loushy/` directory to `.lousho/`. ### Added +- Two ready-made durable stores (R2). `@lousho/build-ai-agent/kv` is a new subpath exporting `KVStore`, `KVCheckpointStore`, `CHECKPOINT_KV_BINDING` and the types `KVStoreOptions`, `KVBinding`, `KVPutOptions`, so a hand-written Cloudflare Worker can use `createAgent({ provider, store: new KVStore(env.AGENT_KV) })`; nothing in its import graph touches `node:*`, so it bundles without shims (`assertSessionId` moved to the Node-free `src/session/sessionId.ts` and is still exported where it was). `fileStore(dir, { historyLimit? })` (root export) is an `AgentStore` of plain JSON files: `sessions/.json`, `checkpoints/.json`, `checkpoint-history/.json` and `approvals/.json`, each written to a temp file and renamed into place, with no lock files and no `StorageService`; resolving an approval claims it with an exclusive create, so of two processes resolving one approval only one gets it. It replaces combining `FileSessionStore`, `LocalStorageCheckpointStore` and `StorageServiceApprovalStore` by hand. See docs/sessions.md#choosing-a-store and docs/deployment.md. - Remote sub-agent approvals go through the lead run (LOU-Y7.3): a `remoteAgent()` task whose remote run pauses for a tool approval now pauses the lead run the way a local sub-agent does (it used to fail with `LOUSHO_SESSION_AWAITING_APPROVAL`): the lead's pending approval (`agent.approvals.list()`, channel buttons, the dev chat, ACP permission requests) has the remote tool's name and input and `subagentPath: []`, and a remote `ask_question` arrives as a question. Deciding it on the lead (`resolve`, `streamResolve`, `answer`) posts the decision to the remote `POST /chat/:sessionId/approvals/:id` and the continuation's final answer is the `task` result; a further pause pauses the lead again. The lead's approval snapshot stores the remote session id, the remote approval id, the `taskId` and the agent name (never the token), so a fresh lead process on the same store can decide it. Failures while deciding (401, other non-2xx including the remote's 404 for an approval no longer pending, network) are the `task` call's structured tool error with `LOUSHO_REMOTE_UNAUTHORIZED` / `LOUSHO_REMOTE_REQUEST_FAILED`. A remote agent with an `output` schema now returns its object as JSON with the footer (the V4.2 shape), read from `run.done`'s `object`. `RemoteSubagent.run()` takes `pausable` and `decision` (type `RemoteRunOptions`); the internal session client gains `resolveRemoteApproval()` and `SessionTurnSummary.object`. Only a lead run without an approval store keeps the old `LOUSHO_SESSION_AWAITING_APPROVAL` error. See docs/sub-agents.md#remote-approvals. - Structured output for sessions and sub-agents, typed for either zod major (LOU-V4.2): `createAgent({ output })` and `ExecuteOptions.output` accept a zod 3 schema, a zod 4 schema (`zod/v4` on zod 3.25, or zod 4) or a Standard Schema that can produce JSON Schema, and `result.object` is inferred from each without a cast (`InferSchemaOutput`, as `defineTool`). `AgentSession` is generic (`AgentSession`): `agent.session().send()` and `.stream()` results carry the typed `object`. A sub-agent with its own `output` returns its validated object as JSON (then the `taskId` footer) as the `task` and `agent_await` result; its `output-invalid` finish is a structured tool error. Sub-agents do not inherit the lead's `output`; `remoteAgent()` returns text only. The exported spec schemas (`agentSpecSchema`, ...) are typed as `SpecSchema` / `SpecObjectSchema`, so the published `schema-*.d.ts` no longer depends on zod 3 generics (`skipLibCheck: false` projects on zod 4). The Ollama missing-peer note now says `ollama-ai-provider-v2` needs zod 4 (`npm install zod@^4.0.0`). See docs/structured-output.md. - Publish readiness (LOU-D49): `npm run pack-smoke` (scripts/pack-smoke.ts, a CI job) packs the SDK and `create-lousho-agent`, checks the tarball (no `.env`, tests or secret-looking strings; entry count and size caps), runs `npm publish --dry-run` for both (nothing is published), installs the tarballs plus peers from the registry into a fresh project and verifies ESM and CJS loads of every `exports` entry, a mock-model agent turn, the `lousho` bin (`--help`, `doctor`) and `tsc` with `moduleResolution` bundler and node16. diff --git a/docs/api-overview.md b/docs/api-overview.md index 0f1169e1..ede60342 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -49,6 +49,8 @@ How the pieces fit: | `InMemoryApprovalStore` | Process-local `ApprovalStore`; the default store of `createAgent()` agents. | | `StorageServiceApprovalStore`, `LocalStorageCheckpointStore` | File-backed approval and checkpoint stores over a `StorageService` (see [Approvals](./approvals.md), [Durable execution](./durable-execution.md)). | | `SqliteStore` (from `/sqlite`) | Sessions, checkpoints and approvals in one SQLite file (see [Sessions](./sessions.md#choosing-a-store)). | +| `fileStore(dir)` | Sessions, checkpoints and approvals as plain JSON files under `dir` (see [Sessions](./sessions.md#choosing-a-store)). | +| `KVStore`, `KVCheckpointStore` (from `/kv`) | Stores on a Cloudflare Workers KV binding, for a hand-written Worker (see [Deployment](./deployment.md)). | | `AgentStore`, `memoryStore()` | The `createAgent({ store })` option: `{ sessions?, checkpoints?, approvals? }`, and an in-memory one (see [Sessions](./sessions.md#choosing-a-store)). | | `SessionAwaitingApprovalError` | Thrown by `execute()` when its `sessionId` is paused on an approval (see [Durable execution](./durable-execution.md)). | | `SDKError`, `ERROR_CODES` | Base class of the SDK's errors: a stable `code`, a `hint` and a `docs` link (see [Errors](./errors.md)). | diff --git a/docs/deployment.md b/docs/deployment.md index 524eb751..e5d8692c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -199,10 +199,33 @@ curl -N https://.workers.dev/chat \ -d '{ "sessionId": "alice", "input": "Hello" }' ``` -`KVStore(kvBinding, { prefix?, ttl? })` (`src/deploy/kvStore.ts`) is the -`AgentStore` the generated Worker builds from the binding. It is not exported -from any entry point of the package, so importing it in a hand-written Worker -is not supported yet. Its keys, with an optional `prefix` before each: +`KVStore(kvBinding, { prefix?, ttl?, historyLimit? })` is the `AgentStore` the +generated Worker builds from the binding. A hand-written Worker imports it from +the `/kv` subpath, which has no `node:*` import anywhere in its graph, with the +binding typed as `KVBinding` (the `get`/`put`/`delete` part of Cloudflare's +`KVNamespace`, so `@cloudflare/workers-types` is not needed): + +```ts +import { createAgent } from '@lousho/build-ai-agent'; +import { KVStore, type KVBinding } from '@lousho/build-ai-agent/kv'; + +interface Env { + AGENT_KV: KVBinding; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const agent = createAgent({ provider, store: new KVStore(env.AGENT_KV) }); + const { sessionId, input } = (await request.json()) as { sessionId: string; input: string }; + const { text } = await agent.session({ id: sessionId }).send(input); + return Response.json({ text }); + }, +}; +``` + +`/kv` also exports `KVCheckpointStore` (checkpoints only) and +`CHECKPOINT_KV_BINDING` (`'AGENT_CHECKPOINTS'`, the binding name the generated +Worker reads). `KVStore`'s keys, with an optional `prefix` before each: | Key | Value | | --- | ----- | @@ -303,9 +326,8 @@ one session at the same moment can overwrite each other's turn, since a KV read-modify-write is not atomic. The KV-backed stores (`KVStore`, `KVCheckpointStore` and `CHECKPOINT_KV_BINDING`, -in `src/deploy/kvStore.ts`, `src/deploy/kvCheckpointStore.ts` and -`src/deploy/checkpointBinding.ts`) have no `node:*` references anywhere in their -dependency graph. The Worker runs the spec as a `createAgent()` agent, whose +exported from `@lousho/build-ai-agent/kv`) have no `node:*` references anywhere +in their dependency graph. The Worker runs the spec as a `createAgent()` agent, whose Node-only imports (project instructions, the file session store, guardrail patches, MCP over stdio) the build points at a shim that fails when used (`src/deploy/shims/node.worker.ts`). The built `dist/worker.js` bundle is then diff --git a/docs/durable-execution.md b/docs/durable-execution.md index 9a67ab03..c8a3aa68 100644 --- a/docs/durable-execution.md +++ b/docs/durable-execution.md @@ -199,7 +199,8 @@ Which stores keep one: | `memoryStore()` | yes | `memoryStore({ historyLimit })` | | `SqliteStore` | yes (`checkpoint_history` table) | `new SqliteStore(path, { historyLimit })` | | `LocalStorageCheckpointStore` | yes | `new LocalStorageCheckpointStore(storage, { historyLimit })` | -| `KVCheckpointStore` / `KVStore` (Cloudflare Workers KV) | yes (see below) | `new KVStore(kv, { historyLimit })` | +| `fileStore(dir)` (JSON files) | yes (`checkpoint-history/.json`) | `fileStore(dir, { historyLimit })` | +| `KVCheckpointStore` / `KVStore` (Cloudflare Workers KV, from `@lousho/build-ai-agent/kv`) | yes (see below) | `new KVStore(kv, { historyLimit })` | | Agent Forge's `FileCheckpointStore` | yes | `new FileCheckpointStore(dir, { historyLimit })` | | a custom `CheckpointStore` | only if it implements `history()` | - | diff --git a/docs/sessions.md b/docs/sessions.md index 56d630ad..539ed008 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -227,33 +227,30 @@ implementation by where the process runs: | Store | Sessions | Checkpoints | Approvals | Use it when | | --- | --- | --- | --- | --- | | In memory (`memoryStore()`) | `MemorySessionStore` | in memory | `InMemoryApprovalStore` | Tests, scripts, one process that never restarts | -| Files | `FileSessionStore(dir)` | `LocalStorageCheckpointStore` | `StorageServiceApprovalStore` | One machine, you want plain inspectable files | +| Files (`fileStore(dir)`) | `/sessions/.json` | `/checkpoints/`, `/checkpoint-history/` | `/approvals/.json` | One machine, you want plain inspectable files | | SQLite | `store.sessions` | `store.checkpoints` | `store.approvals` | A Node server: one durable, transactional file, shared safely by several processes | -| Cloudflare KV | - | `KVCheckpointStore` | - | Workers deployments (see [Deployment](deployment.md)) | +| Cloudflare KV (`KVStore` from `@lousho/build-ai-agent/kv`) | `store.sessions` | `store.checkpoints` | `store.approvals` | Workers deployments (see [Deployment](deployment.md)) | Each one is an `AgentStore` part: pass them together as -`createAgent({ store: { sessions, checkpoints, approvals } })`. `memoryStore()` -and `SqliteStore` are ready-made `AgentStore`s; for plain files, combine the -file stores: +`createAgent({ store: { sessions, checkpoints, approvals } })`. `memoryStore()`, +`fileStore(dir)`, `SqliteStore` and `KVStore` are ready-made `AgentStore`s. For +plain files, `fileStore(dir)` writes one JSON file per session, checkpoint and +pending approval under `dir`, each written to a temp file and renamed into +place, with no lock files: ```ts -import { - createAgent, - FileSessionStore, - LocalStorageCheckpointStore, - StorageServiceApprovalStore, - type AgentStore, -} from '@lousho/build-ai-agent'; - -// `storage` is a StorageService rooted where the files should go. -const store: AgentStore = { - sessions: new FileSessionStore('./.lousho/sessions'), - checkpoints: new LocalStorageCheckpointStore(storage), - approvals: new StorageServiceApprovalStore(storage), -}; -const agent = createAgent({ provider, store }); +import { createAgent, fileStore } from '@lousho/build-ai-agent'; + +const agent = createAgent({ provider, store: fileStore('./.lousho') }); +// ./.lousho/sessions/user-42.json, ./.lousho/checkpoints/..., ./.lousho/approvals/... +await agent.session({ id: 'user-42' }).send('Hello'); ``` +Resolving an approval from `fileStore` is safe across processes (only one +caller gets the record); two processes writing one session at the same moment +are not coordinated, so the last write wins. For several processes sharing a +store, use `SqliteStore`. + Any object with the three methods of a part works there too: a Redis `SessionStore`, or a KV-backed `CheckpointStore` on Cloudflare Workers (the generated Worker uses `KVCheckpointStore`, see [Deployment](deployment.md)). diff --git a/llms-full.txt b/llms-full.txt index 43ba6689..b1f476fd 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -801,6 +801,8 @@ How the pieces fit: | `InMemoryApprovalStore` | Process-local `ApprovalStore`; the default store of `createAgent()` agents. | | `StorageServiceApprovalStore`, `LocalStorageCheckpointStore` | File-backed approval and checkpoint stores over a `StorageService` (see [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md), [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md)). | | `SqliteStore` (from `/sqlite`) | Sessions, checkpoints and approvals in one SQLite file (see [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md#choosing-a-store)). | +| `fileStore(dir)` | Sessions, checkpoints and approvals as plain JSON files under `dir` (see [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md#choosing-a-store)). | +| `KVStore`, `KVCheckpointStore` (from `/kv`) | Stores on a Cloudflare Workers KV binding, for a hand-written Worker (see [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md)). | | `AgentStore`, `memoryStore()` | The `createAgent({ store })` option: `{ sessions?, checkpoints?, approvals? }`, and an in-memory one (see [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md#choosing-a-store)). | | `SessionAwaitingApprovalError` | Thrown by `execute()` when its `sessionId` is paused on an approval (see [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md)). | | `SDKError`, `ERROR_CODES` | Base class of the SDK's errors: a stable `code`, a `hint` and a `docs` link (see [Errors](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/errors.md)). | @@ -4436,10 +4438,33 @@ curl -N https://.workers.dev/chat \ -d '{ "sessionId": "alice", "input": "Hello" }' ``` -`KVStore(kvBinding, { prefix?, ttl? })` (`src/deploy/kvStore.ts`) is the -`AgentStore` the generated Worker builds from the binding. It is not exported -from any entry point of the package, so importing it in a hand-written Worker -is not supported yet. Its keys, with an optional `prefix` before each: +`KVStore(kvBinding, { prefix?, ttl?, historyLimit? })` is the `AgentStore` the +generated Worker builds from the binding. A hand-written Worker imports it from +the `/kv` subpath, which has no `node:*` import anywhere in its graph, with the +binding typed as `KVBinding` (the `get`/`put`/`delete` part of Cloudflare's +`KVNamespace`, so `@cloudflare/workers-types` is not needed): + +```ts +import { createAgent } from '@lousho/build-ai-agent'; +import { KVStore, type KVBinding } from '@lousho/build-ai-agent/kv'; + +interface Env { + AGENT_KV: KVBinding; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const agent = createAgent({ provider, store: new KVStore(env.AGENT_KV) }); + const { sessionId, input } = (await request.json()) as { sessionId: string; input: string }; + const { text } = await agent.session({ id: sessionId }).send(input); + return Response.json({ text }); + }, +}; +``` + +`/kv` also exports `KVCheckpointStore` (checkpoints only) and +`CHECKPOINT_KV_BINDING` (`'AGENT_CHECKPOINTS'`, the binding name the generated +Worker reads). `KVStore`'s keys, with an optional `prefix` before each: | Key | Value | | --- | ----- | @@ -4540,9 +4565,8 @@ one session at the same moment can overwrite each other's turn, since a KV read-modify-write is not atomic. The KV-backed stores (`KVStore`, `KVCheckpointStore` and `CHECKPOINT_KV_BINDING`, -in `src/deploy/kvStore.ts`, `src/deploy/kvCheckpointStore.ts` and -`src/deploy/checkpointBinding.ts`) have no `node:*` references anywhere in their -dependency graph. The Worker runs the spec as a `createAgent()` agent, whose +exported from `@lousho/build-ai-agent/kv`) have no `node:*` references anywhere +in their dependency graph. The Worker runs the spec as a `createAgent()` agent, whose Node-only imports (project instructions, the file session store, guardrail patches, MCP over stdio) the build points at a shim that fails when used (`src/deploy/shims/node.worker.ts`). The built `dist/worker.js` bundle is then @@ -4766,7 +4790,8 @@ Which stores keep one: | `memoryStore()` | yes | `memoryStore({ historyLimit })` | | `SqliteStore` | yes (`checkpoint_history` table) | `new SqliteStore(path, { historyLimit })` | | `LocalStorageCheckpointStore` | yes | `new LocalStorageCheckpointStore(storage, { historyLimit })` | -| `KVCheckpointStore` / `KVStore` (Cloudflare Workers KV) | yes (see below) | `new KVStore(kv, { historyLimit })` | +| `fileStore(dir)` (JSON files) | yes (`checkpoint-history/.json`) | `fileStore(dir, { historyLimit })` | +| `KVCheckpointStore` / `KVStore` (Cloudflare Workers KV, from `@lousho/build-ai-agent/kv`) | yes (see below) | `new KVStore(kv, { historyLimit })` | | Agent Forge's `FileCheckpointStore` | yes | `new FileCheckpointStore(dir, { historyLimit })` | | a custom `CheckpointStore` | only if it implements `history()` | - | @@ -7781,33 +7806,30 @@ implementation by where the process runs: | Store | Sessions | Checkpoints | Approvals | Use it when | | --- | --- | --- | --- | --- | | In memory (`memoryStore()`) | `MemorySessionStore` | in memory | `InMemoryApprovalStore` | Tests, scripts, one process that never restarts | -| Files | `FileSessionStore(dir)` | `LocalStorageCheckpointStore` | `StorageServiceApprovalStore` | One machine, you want plain inspectable files | +| Files (`fileStore(dir)`) | `/sessions/.json` | `/checkpoints/`, `/checkpoint-history/` | `/approvals/.json` | One machine, you want plain inspectable files | | SQLite | `store.sessions` | `store.checkpoints` | `store.approvals` | A Node server: one durable, transactional file, shared safely by several processes | -| Cloudflare KV | - | `KVCheckpointStore` | - | Workers deployments (see [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md)) | +| Cloudflare KV (`KVStore` from `@lousho/build-ai-agent/kv`) | `store.sessions` | `store.checkpoints` | `store.approvals` | Workers deployments (see [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md)) | Each one is an `AgentStore` part: pass them together as -`createAgent({ store: { sessions, checkpoints, approvals } })`. `memoryStore()` -and `SqliteStore` are ready-made `AgentStore`s; for plain files, combine the -file stores: +`createAgent({ store: { sessions, checkpoints, approvals } })`. `memoryStore()`, +`fileStore(dir)`, `SqliteStore` and `KVStore` are ready-made `AgentStore`s. For +plain files, `fileStore(dir)` writes one JSON file per session, checkpoint and +pending approval under `dir`, each written to a temp file and renamed into +place, with no lock files: ```ts -import { - createAgent, - FileSessionStore, - LocalStorageCheckpointStore, - StorageServiceApprovalStore, - type AgentStore, -} from '@lousho/build-ai-agent'; +import { createAgent, fileStore } from '@lousho/build-ai-agent'; -// `storage` is a StorageService rooted where the files should go. -const store: AgentStore = { - sessions: new FileSessionStore('./.lousho/sessions'), - checkpoints: new LocalStorageCheckpointStore(storage), - approvals: new StorageServiceApprovalStore(storage), -}; -const agent = createAgent({ provider, store }); +const agent = createAgent({ provider, store: fileStore('./.lousho') }); +// ./.lousho/sessions/user-42.json, ./.lousho/checkpoints/..., ./.lousho/approvals/... +await agent.session({ id: 'user-42' }).send('Hello'); ``` +Resolving an approval from `fileStore` is safe across processes (only one +caller gets the record); two processes writing one session at the same moment +are not coordinated, so the last write wins. For several processes sharing a +store, use `SqliteStore`. + Any object with the three methods of a part works there too: a Redis `SessionStore`, or a KV-backed `CheckpointStore` on Cloudflare Workers (the generated Worker uses `KVCheckpointStore`, see [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md)). diff --git a/package.json b/package.json index cc564e5d..c478c491 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,11 @@ "import": "./dist/storage/sqlite/index.mjs", "require": "./dist/storage/sqlite/index.js" }, + "./kv": { + "types": "./dist/deploy/kv.d.ts", + "import": "./dist/deploy/kv.mjs", + "require": "./dist/deploy/kv.js" + }, "./triggers": { "types": "./dist/triggers/index.d.ts", "import": "./dist/triggers/index.mjs", diff --git a/src/deploy/kv.test.ts b/src/deploy/kv.test.ts new file mode 100644 index 00000000..8a35379c --- /dev/null +++ b/src/deploy/kv.test.ts @@ -0,0 +1,44 @@ +/** + * R2: `@lousho/build-ai-agent/kv` must bundle for a hand-written Cloudflare + * Worker, which has no shim for Node builtins. This guards the barrel's + * import graph: no module it reaches may import a `node:` path. + */ +import { describe, expect, it } from 'vitest'; +import { build } from 'esbuild'; +import { join } from 'node:path'; +import * as kv from './kv'; + +describe('@lousho/build-ai-agent/kv (R2)', () => { + it('bundles for the browser platform with no node: import in its graph', async () => { + const result = await build({ + entryPoints: [join(__dirname, 'kv.ts')], + bundle: true, + write: false, + platform: 'browser', + format: 'esm', + external: ['ai', 'zod', '@opentelemetry/api'], + metafile: true, + logLevel: 'silent', + }); + + expect(result.errors).toEqual([]); + const nodeImports = Object.entries(result.metafile.inputs).flatMap(([file, input]) => + input.imports.filter((entry) => entry.path.startsWith('node:')).map((entry) => `${file} -> ${entry.path}`) + ); + expect(nodeImports).toEqual([]); + expect(Object.keys(result.metafile.inputs).some((file) => file.endsWith('kvStore.ts'))).toBe(true); + }, 30_000); + + it('exports KVStore, KVCheckpointStore and CHECKPOINT_KV_BINDING', () => { + expect(Object.keys(kv).sort()).toEqual(['CHECKPOINT_KV_BINDING', 'KVCheckpointStore', 'KVStore']); + expect(kv.CHECKPOINT_KV_BINDING).toBe('AGENT_CHECKPOINTS'); + const memory = new Map(); + const binding: kv.KVBinding = { + get: async (key) => memory.get(key) ?? null, + put: async (key, value, _options?: kv.KVPutOptions) => void memory.set(key, value), + delete: async (key) => void memory.delete(key), + }; + const options: kv.KVStoreOptions = { prefix: 'app/' }; + expect(new kv.KVStore(binding, options).checkpoints).toBeInstanceOf(kv.KVCheckpointStore); + }); +}); diff --git a/src/deploy/kv.ts b/src/deploy/kv.ts new file mode 100644 index 00000000..e22e1506 --- /dev/null +++ b/src/deploy/kv.ts @@ -0,0 +1,9 @@ +/** + * `@lousho/build-ai-agent/kv` (R2): the Cloudflare Workers KV stores, for a + * hand-written Worker. Node-free on purpose (no `node:*` import anywhere in + * its graph, guarded by kv.test.ts) so it bundles for the Workers runtime + * without shims. + */ +export { KVStore, type KVStoreOptions } from './kvStore'; +export { KVCheckpointStore, type KVBinding, type KVPutOptions } from './kvCheckpointStore'; +export { CHECKPOINT_KV_BINDING } from './checkpointBinding'; diff --git a/src/deploy/kvStore.ts b/src/deploy/kvStore.ts index fadd58a9..ce5cb60f 100644 --- a/src/deploy/kvStore.ts +++ b/src/deploy/kvStore.ts @@ -16,7 +16,8 @@ */ import type { ApprovalStore, ExecutionSnapshot, PendingApproval, ResolvedApproval } from '../execution/ApprovalGate'; import type { Message } from '../providers/llm'; -import { assertSessionId, type SessionStore } from '../session/sessionStore'; +import { assertSessionId } from '../session/sessionId'; +import type { SessionStore } from '../session/sessionStore'; import type { AgentStore } from '../storage/agentStore'; import { DEFAULT_KV_KEY_PREFIX, KVCheckpointStore, type KVBinding } from './kvCheckpointStore'; diff --git a/src/execution/checkpoint.ts b/src/execution/checkpoint.ts index 480ad5fd..7009fb35 100644 --- a/src/execution/checkpoint.ts +++ b/src/execution/checkpoint.ts @@ -3,8 +3,8 @@ * Backend-agnostic durable-execution checkpointing for AgentExecutor runs. */ -import { Message } from '../providers'; -import { StorageService } from '../storage'; +import type { Message } from '../providers'; +import type { StorageService } from '../storage'; import type { StepUsage } from '../models/usage'; import type { CheckpointUsage } from './runUsage'; import type { AgentFingerprint } from './agentFingerprint'; diff --git a/src/execution/checkpointHistory.contract.test.ts b/src/execution/checkpointHistory.contract.test.ts index c8d186db..9039c496 100644 --- a/src/execution/checkpointHistory.contract.test.ts +++ b/src/execution/checkpointHistory.contract.test.ts @@ -1,10 +1,13 @@ /** * LOU-D43: one contract suite for `CheckpointStore.history()`, run against * every store that implements it (the in-memory store, `SqliteStore`, - * `LocalStorageCheckpointStore` and `KVCheckpointStore`), plus the `getCheckpointHistory()` helper on + * `LocalStorageCheckpointStore`, `KVCheckpointStore` and `fileStore()`), plus the `getCheckpointHistory()` helper on * stores that do not. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { getCheckpointHistory, LocalStorageCheckpointStore, @@ -12,6 +15,7 @@ import { } from './checkpoint'; import { StorageService } from '../storage/StorageService'; import { memoryStore } from '../storage/agentStore'; +import { fileStore } from '../storage/fileStore'; import { SqliteStore } from '../storage/sqlite'; import { KVCheckpointStore } from '../deploy/kvCheckpointStore'; import { createFakeFs } from './__fixtures__/fakeFs'; @@ -23,7 +27,12 @@ afterEach(() => { while (sqliteStores.length) sqliteStores.pop()?.close(); }); -const implementations: Array<[string, CheckpointHistoryStoreFactory]> = [ +const tempDirs: string[] = []; +afterAll(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); +}); + +const implementations:Array<[string, CheckpointHistoryStoreFactory]> = [ ['memoryStore()', (options) => memoryStore(options).checkpoints], [ 'SqliteStore', @@ -41,6 +50,14 @@ const implementations: Array<[string, CheckpointHistoryStoreFactory]> = [ return new KVCheckpointStore(kv, undefined, undefined, options); }, ], + [ + 'fileStore()', + (options) => { + const dir = mkdtempSync(join(tmpdir(), 'lousho-history-')); + tempDirs.push(dir); + return fileStore(dir, options).checkpoints; + }, + ], [ 'LocalStorageCheckpointStore', (options) => { diff --git a/src/index.ts b/src/index.ts index bd027723..82de0098 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ export * from './createAgent'; export type { AgentApprovals, ApproveToolCall } from './createAgentApprovals'; // One store for sessions, checkpoints and approvals: createAgent({ store }) (LOU-D30) export { memoryStore, type AgentStore, type MemoryStoreOptions } from './storage/agentStore'; +export { fileStore, type FileStoreOptions } from './storage/fileStore'; // Sessions: multi-turn conversations for createAgent() (LOU-W4) export * from './session'; diff --git a/src/session/sessionId.ts b/src/session/sessionId.ts new file mode 100644 index 00000000..9c4c4f8d --- /dev/null +++ b/src/session/sessionId.ts @@ -0,0 +1,24 @@ +/** + * Session id validation, in a module with no Node builtins so Worker code + * (`KVStore`, `@lousho/build-ai-agent/kv`) can import it. `sessionStore.ts` + * re-exports `assertSessionId`. + */ + +import { ConfigurationError } from '../execution/errors'; + +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +/** + * Throws unless `id` is 1-128 characters of letters, digits, `_` or `-`. + * Session ids become file names, so anything else (`../`, `/`, `.`) is refused. + */ +export function assertSessionId(id: string): void { + if (typeof id !== 'string' || !SESSION_ID_PATTERN.test(id)) { + throw new ConfigurationError( + `Invalid session id ${JSON.stringify(id)}: use 1-128 characters from A-Z, a-z, 0-9, '_' and '-' ` + + "(e.g. 'user-42'). Omit the id to get a generated one.", + 'id', + 'LOUSHO_SESSION_ID_INVALID' + ); + } +} diff --git a/src/session/sessionStore.ts b/src/session/sessionStore.ts index 538b2f20..b4b855ac 100644 --- a/src/session/sessionStore.ts +++ b/src/session/sessionStore.ts @@ -7,7 +7,8 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { randomUUID } from 'node:crypto'; import type { Message } from '../providers/llm'; -import { ConfigurationError, SDKError } from '../execution/errors'; +import { SDKError } from '../execution/errors'; +import { assertSessionId } from './sessionId'; /** How bytes (image and file parts, LOU-V11) are saved in a JSON transcript: `{ "$bytes": "" }`. */ const BYTES_KEY = '$bytes'; @@ -45,22 +46,7 @@ export interface SessionStore { delete(id: string): Promise; } -const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; - -/** - * Throws unless `id` is 1-128 characters of letters, digits, `_` or `-`. - * Session ids become file names, so anything else (`../`, `/`, `.`) is refused. - */ -export function assertSessionId(id: string): void { - if (typeof id !== 'string' || !SESSION_ID_PATTERN.test(id)) { - throw new ConfigurationError( - `Invalid session id ${JSON.stringify(id)}: use 1-128 characters from A-Z, a-z, 0-9, '_' and '-' ` + - "(e.g. 'user-42'). Omit the id to get a generated one.", - 'id', - 'LOUSHO_SESSION_ID_INVALID' - ); - } -} +export { assertSessionId }; /** * In-memory store: the default. Transcripts live as long as the process diff --git a/src/storage/fileStore.test.ts b/src/storage/fileStore.test.ts new file mode 100644 index 00000000..5fe9825a --- /dev/null +++ b/src/storage/fileStore.test.ts @@ -0,0 +1,184 @@ +/** + * R2: `fileStore(dir)`, an AgentStore of JSON files. Runs the shared store + * contracts, then the file-specific behavior: layout, atomic claims of + * approvals, id validation, a corrupt history file, and two agents on one dir. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { fileStore } from './fileStore'; +import { createAgent } from '../createAgent'; +import { defineTool } from '../tools/defineTool'; +import { mockModel } from '../testing'; +import type { Message } from '../providers/llm'; +import { + describeApprovalStoreContract, + describeCheckpointStoreContract, + describeSessionStoreContract, + makeCheckpoint, + makePending, + makeSnapshot, +} from './sqlite/__fixtures__/storeContracts'; + +const dirs: string[] = []; +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'lousho-filestore-')); + dirs.push(dir); + return dir; +} +afterEach(() => { + while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +describeSessionStoreContract('fileStore().sessions', () => fileStore(tempDir()).sessions); +describeCheckpointStoreContract('fileStore().checkpoints', () => fileStore(tempDir()).checkpoints); +describeApprovalStoreContract('fileStore().approvals', () => fileStore(tempDir()).approvals); + +const save = (store: ReturnType, sessionId: string, stepIndex: number) => + store.checkpoints.save(sessionId, makeCheckpoint({ sessionId, stepIndex })); + +describe('fileStore(dir) (R2)', () => { + it('writes sessions, checkpoints, history and approvals at the documented layout, with no temp or lock files left', async () => { + const dir = tempDir(); + const store = fileStore(dir); + await store.sessions.save('chat', [{ role: 'user', content: 'hi' }]); + await save(store, 'chat.turn-0', 1); + const pending = makePending('appr_1'); + await store.approvals.save(pending, makeSnapshot(pending)); + + expect(readdirSync(dir).sort()).toEqual(['approvals', 'checkpoint-history', 'checkpoints', 'sessions']); + expect(readdirSync(join(dir, 'sessions'))).toEqual(['chat.json']); + expect(readdirSync(join(dir, 'checkpoints'))).toEqual(['chat.turn-0.json']); + expect(readdirSync(join(dir, 'checkpoint-history'))).toEqual(['chat.turn-0.json']); + expect(readdirSync(join(dir, 'approvals'))).toEqual(['appr_1.json']); + }); + + it('round-trips a transcript with a Uint8Array file part, across store instances', async () => { + const dir = tempDir(); + const bytes = new Uint8Array([0, 1, 2, 250, 255]); + const messages: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'read this' }, { type: 'file', data: bytes, mimeType: 'application/octet-stream' }] }, + ]; + await fileStore(dir).sessions.save('doc', messages); + + const loaded = await fileStore(dir).sessions.load('doc'); + const part = (loaded![0].content as Array<{ type: string; data?: unknown }>)[1]; + expect(part.data).toBeInstanceOf(Uint8Array); + expect(Array.from(part.data as Uint8Array)).toEqual(Array.from(bytes)); + }); + + it('checkpoints: save, load, delete and history() newest first', async () => { + const store = fileStore(tempDir()); + await save(store, 's', 1); + await save(store, 's', 2); + expect((await store.checkpoints.load('s'))?.stepIndex).toBe(2); + expect((await store.checkpoints.history('s')).map((entry) => entry.step)).toEqual([2, 1]); + await store.checkpoints.delete('s'); + expect(await store.checkpoints.load('s')).toBeNull(); + expect(await store.checkpoints.history('s')).toEqual([]); + }); + + it('honors historyLimit: 3 keeps the newest three, 0 keeps none, keepHistory keeps the ring', async () => { + const small = fileStore(tempDir(), { historyLimit: 3 }); + for (let step = 0; step < 5; step++) await save(small, 's', step); + expect((await small.checkpoints.history('s')).map((entry) => entry.step)).toEqual([4, 3, 2]); + await small.checkpoints.delete('s', { keepHistory: true }); + expect(await small.checkpoints.load('s')).toBeNull(); + expect((await small.checkpoints.history('s')).map((entry) => entry.step)).toEqual([4, 3, 2]); + + const dir = tempDir(); + const off = fileStore(dir, { historyLimit: 0 }); + await save(off, 's', 1); + expect(await off.checkpoints.history('s')).toEqual([]); + expect(readdirSync(dir)).not.toContain('checkpoint-history'); + }); + + it('reads a truncated history file as empty, and the next save starts a new ring', async () => { + const dir = tempDir(); + const store = fileStore(dir); + await save(store, 's', 1); + writeFileSync(join(dir, 'checkpoint-history', 's.json'), '[{"step":1,"savedAt":"20'); + expect(await store.checkpoints.history('s')).toEqual([]); + await save(store, 's', 2); + expect((await store.checkpoints.history('s')).map((entry) => entry.step)).toEqual([2]); + }); + + it('approvals: resolve returns the record once, then null', async () => { + const store = fileStore(tempDir()); + const pending = makePending('appr_once'); + await store.approvals.save(pending, makeSnapshot(pending)); + expect((await store.approvals.resolve('appr_once'))?.pending.id).toBe('appr_once'); + expect(await store.approvals.resolve('appr_once')).toBeNull(); + }); + + it('two concurrent resolves of one approval (two store instances on one dir) give exactly one record', async () => { + const dir = tempDir(); + for (let round = 0; round < 25; round++) { + const id = `appr_race_${round}`; + const pending = makePending(id); + await fileStore(dir).approvals.save(pending, makeSnapshot(pending)); + const results = await Promise.all([fileStore(dir).approvals.resolve(id), fileStore(dir).approvals.resolve(id)]); + expect(results.filter((result) => result !== null)).toHaveLength(1); + } + expect(readdirSync(join(dir, 'approvals'))).toEqual([]); + }); + + it('a claim left by a crashed resolver makes resolve return null until the approval is saved again', async () => { + const dir = tempDir(); + const store = fileStore(dir); + const pending = makePending('appr_stale'); + await store.approvals.save(pending, makeSnapshot(pending)); + writeFileSync(join(dir, 'approvals', 'appr_stale.json.claim'), '1234'); + expect(await store.approvals.resolve('appr_stale')).toBeNull(); + await store.approvals.save(pending, makeSnapshot(pending)); + expect((await store.approvals.resolve('appr_stale'))?.pending.id).toBe('appr_stale'); + }); + + it('rejects session, checkpoint and approval ids that would escape the directory', async () => { + const store = fileStore(tempDir()); + await expect(store.sessions.save('../evil', [])).rejects.toThrow(/Invalid session id/); + await expect(store.sessions.load('a/../../b')).rejects.toThrow(/Invalid session id/); + await expect(save(store, '../evil', 1)).rejects.toThrow(/Invalid session id/); + await expect(store.checkpoints.load('..')).rejects.toThrow(/Invalid session id/); + await expect(store.approvals.resolve('../evil')).rejects.toThrow(/Invalid approval id/); + const pending = makePending('../evil'); + await expect(store.approvals.save(pending, makeSnapshot(pending))).rejects.toThrow(/Invalid approval id/); + }); +}); + +describe('createAgent({ store: fileStore(dir) }) (R2)', () => { + it('a session survives a second createAgent() on the same dir', async () => { + const dir = tempDir(); + await createAgent({ provider: mockModel(['Hi Ali.']), store: fileStore(dir) }).session({ id: 'chat' }).send('My name is Ali.'); + + const model = mockModel(['Ali.']); + const { text } = await createAgent({ provider: model, store: fileStore(dir) }).session({ id: 'chat' }).send('What is my name?'); + + expect(text).toBe('Ali.'); + expect(model.calls[0].messages.some((m) => m.content === 'My name is Ali.')).toBe(true); + }); + + it('an approval-gated run paused on one agent resumes on a second agent over the same dir', async () => { + const dir = tempDir(); + let sent = 0; + const tools = [ + defineTool({ name: 'send_email', description: 'send', input: z.object({}), needsApproval: true, execute: async () => `sent ${++sent}` }), + ]; + const paused = await createAgent({ provider: mockModel([{ toolCalls: [{ name: 'send_email', id: 'call_1' }] }]), tools, store: fileStore(dir) }).send( + 'Email Sam', + { sessionId: 'job-1' } + ); + expect(paused.finishReason).toBe('awaiting-approval'); + expect(readdirSync(join(dir, 'approvals'))).toEqual([`${paused.approvalId}.json`]); + + const agent = createAgent({ provider: mockModel(['Email sent.']), tools, store: fileStore(dir) }); + const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); + + expect(result.text).toBe('Email sent.'); + expect(sent).toBe(1); + expect(await fileStore(dir).checkpoints.load('job-1')).toMatchObject({ status: 'finished' }); + expect(readdirSync(join(dir, 'approvals'))).toEqual([]); + }); +}); diff --git a/src/storage/fileStore.ts b/src/storage/fileStore.ts new file mode 100644 index 00000000..74ed2435 --- /dev/null +++ b/src/storage/fileStore.ts @@ -0,0 +1,203 @@ +/** + * `fileStore(dir)` (R2): an `AgentStore` of plain JSON files, for a Node + * process that should keep sessions, checkpoints and paused approvals across + * restarts without a database: + * + * `/sessions/.json` a transcript (`FileSessionStore`) + * `/checkpoints/.json` the latest checkpoint of a run + * `/checkpoint-history/.json` its bounded history, oldest first + * `/approvals/.json` a pending approval and its snapshot + * + * Every write goes to a temp file and is renamed into place, so a crash never + * leaves half a file. No lock files: a crashed writer cannot block anyone. + * Two processes saving one session's checkpoint at the same time can lose one + * history entry (the ring is a read-modify-write); resolving an approval is + * safe across processes (see `FileApprovalStore`). + */ + +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { ApprovalStore, ExecutionSnapshot, PendingApproval, ResolvedApproval } from '../execution/ApprovalGate'; +import { + appendToRing, + newestFirst, + resolveHistoryLimit, + toHistoryEntry, + type Checkpoint, + type CheckpointDeleteOptions, + type CheckpointHistoryEntry, + type CheckpointHistoryOptions, + type CheckpointStore, +} from '../execution/checkpoint'; +import { ConfigurationError } from '../execution/errors'; +import { decodeBytes, encodeBytes, FileSessionStore } from '../session/sessionStore'; +import type { AgentStore } from './agentStore'; + +/** Options of {@link fileStore}. */ +export interface FileStoreOptions { + /** Checkpoints kept per session in `checkpoints.history()` (default 50, `0` keeps none). */ + historyLimit?: number; +} + +/** + * Checkpoint ids are session ids plus the `.turn-` / `.fork-` suffixes + * the SDK appends, so a `.` is allowed, but not first (no `.`, `..` or hidden + * files) and never a path separator. + */ +const CHECKPOINT_ID_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,199}$/; + +/** Approval ids arrive from HTTP input and become file names. */ +const APPROVAL_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +function assertId(id: string, pattern: RegExp, what: string): void { + if (typeof id !== 'string' || !pattern.test(id)) { + throw new ConfigurationError(`Invalid ${what} ${JSON.stringify(id)}: it becomes a file name, so use letters, digits, '_' and '-'.`, 'id'); + } +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +/** `undefined` when `file` does not exist. */ +async function readText(file: string): Promise { + try { + return await readFile(file, 'utf8'); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } +} + +/** Write to a unique temp file next to `file` and rename it over `file`. */ +async function writeAtomic(file: string, value: unknown): Promise { + const temp = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temp, JSON.stringify(value, encodeBytes), 'utf8'); + await rename(temp, file); + } catch (error) { + await rm(temp, { force: true }); + throw error; + } +} + +/** Checkpoints and their history as JSON files; created on first write. */ +class FileCheckpointStore implements CheckpointStore { + private readonly historyLimit: number; + + constructor( + private readonly checkpointDir: string, + private readonly historyDir: string, + options: FileStoreOptions + ) { + this.historyLimit = resolveHistoryLimit(options.historyLimit); + } + + private fileFor(dir: string, sessionId: string): string { + assertId(sessionId, CHECKPOINT_ID_PATTERN, 'session id'); + return join(dir, `${sessionId}.json`); + } + + /** The oldest-first ring; a missing or unreadable (half-written) file counts as empty. */ + private async readRing(sessionId: string): Promise { + const raw = await readText(this.fileFor(this.historyDir, sessionId)); + if (raw === undefined) return []; + try { + const parsed: unknown = JSON.parse(raw, decodeBytes); + return Array.isArray(parsed) ? (parsed as CheckpointHistoryEntry[]) : []; + } catch { + return []; + } + } + + async save(sessionId: string, checkpoint: Checkpoint): Promise { + const file = this.fileFor(this.checkpointDir, sessionId); + await mkdir(this.checkpointDir, { recursive: true }); + await writeAtomic(file, checkpoint); + if (this.historyLimit === 0) return; + const ring = appendToRing(await this.readRing(sessionId), toHistoryEntry(checkpoint), this.historyLimit); + await mkdir(this.historyDir, { recursive: true }); + await writeAtomic(this.fileFor(this.historyDir, sessionId), ring); + } + + async load(sessionId: string): Promise { + const raw = await readText(this.fileFor(this.checkpointDir, sessionId)); + return raw === undefined ? null : (JSON.parse(raw, decodeBytes) as Checkpoint); + } + + async delete(sessionId: string, options: CheckpointDeleteOptions = {}): Promise { + await rm(this.fileFor(this.checkpointDir, sessionId), { force: true }); + if (!options.keepHistory) await rm(this.fileFor(this.historyDir, sessionId), { force: true }); + } + + async history(sessionId: string, options?: CheckpointHistoryOptions): Promise { + return newestFirst(await this.readRing(sessionId), options); + } +} + +/** + * One JSON file per pending approval. `resolve` first creates `.json.claim` + * with an exclusive create (`wx`, atomic on POSIX and NTFS), so of two callers + * resolving one approval, in one process or two, exactly one gets the record. + * (A rename is not a safe claim on Windows: two renames of one file can both + * succeed.) A resolver that crashes after claiming leaves the claim file, and + * the approval then resolves to `null`; saving it again clears the claim. + */ +class FileApprovalStore implements ApprovalStore { + constructor(private readonly dir: string) {} + + private fileFor(id: string): string { + assertId(id, APPROVAL_ID_PATTERN, 'approval id'); + return join(this.dir, `${id}.json`); + } + + async save(pending: PendingApproval, snapshot: ExecutionSnapshot): Promise { + const file = this.fileFor(pending.id); + await mkdir(this.dir, { recursive: true }); + const record: ResolvedApproval = { pending, snapshot }; + await writeAtomic(file, record); + await rm(`${file}.claim`, { force: true }); + } + + async resolve(id: string): Promise { + const file = this.fileFor(id); + const claim = `${file}.claim`; + try { + await writeFile(claim, String(process.pid), { flag: 'wx' }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EEXIST' || code === 'ENOENT') return null; // another caller has it, or nothing was ever saved + throw error; + } + try { + const raw = await readText(file); + if (raw === undefined) return null; + await rm(file, { force: true }); + return JSON.parse(raw, decodeBytes) as ResolvedApproval; + } finally { + await rm(claim, { force: true }); + } + } +} + +/** + * An {@link AgentStore} of plain, inspectable JSON files under `dir` + * (`sessions/`, `checkpoints/`, `checkpoint-history/`, `approvals/`), for + * `createAgent({ store })` in a Node process. Directories are created on + * first write. + * + * @example + * ```ts + * const agent = createAgent({ provider, store: fileStore('./.lousho') }); + * await agent.session({ id: 'user-42' }).send('Hello'); + * ``` + */ +export function fileStore(dir: string, options: FileStoreOptions = {}): Required { + const root = resolve(dir); + return { + sessions: new FileSessionStore(join(root, 'sessions')), + checkpoints: new FileCheckpointStore(join(root, 'checkpoints'), join(root, 'checkpoint-history'), options), + approvals: new FileApprovalStore(join(root, 'approvals')), + }; +} diff --git a/tsup.config.ts b/tsup.config.ts index ab3975ef..9072e31c 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ 'execution/otel': 'src/execution/otel.ts', 'execution/hooks': 'src/execution/hooks.ts', 'storage/sqlite/index': 'src/storage/sqlite/index.ts', + 'deploy/kv': 'src/deploy/kv.ts', 'triggers/index': 'src/triggers/index.ts', 'react/index': 'src/react/index.ts', 'vue/index': 'src/vue/index.ts', From 13ea3b07744504b54b8a9227107fdc6de3cf6fe0 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Fri, 2 Oct 2026 15:08:24 +0300 Subject: [PATCH 2/4] R2: stop esbuild after the /kv bundle test; guardrails test cleanup waits for its killed child The guardrails timeout test kills its slow child with a taskkill it does not wait for; on Windows the scratch directory stays locked (EPERM) until the child exits, and with the new kv.test.ts in the suite the afterEach rm hit that window in 5 of 6 local full runs. The cleanup now retries on EPERM. Co-Authored-By: Claude Opus 5.5 --- src/deploy/kv.test.ts | 7 +++++-- src/execution/guardrails.test.ts | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/deploy/kv.test.ts b/src/deploy/kv.test.ts index 8a35379c..693e19a9 100644 --- a/src/deploy/kv.test.ts +++ b/src/deploy/kv.test.ts @@ -3,11 +3,14 @@ * Worker, which has no shim for Node builtins. This guards the barrel's * import graph: no module it reaches may import a `node:` path. */ -import { describe, expect, it } from 'vitest'; -import { build } from 'esbuild'; +import { afterAll, describe, expect, it } from 'vitest'; +import { build, stop } from 'esbuild'; import { join } from 'node:path'; import * as kv from './kv'; +// esbuild keeps a service process alive after build(); end it so it does not outlive this file. +afterAll(() => stop()); + describe('@lousho/build-ai-agent/kv (R2)', () => { it('bundles for the browser platform with no node: import in its graph', async () => { const result = await build({ diff --git a/src/execution/guardrails.test.ts b/src/execution/guardrails.test.ts index e4eed189..445948c9 100644 --- a/src/execution/guardrails.test.ts +++ b/src/execution/guardrails.test.ts @@ -118,9 +118,19 @@ describe('command guardrails (LOU-E11)', () => { fs.cpSync(fixtureRepo, scratchDir, { recursive: true }); }); - afterEach(() => { - fs.rmSync(scratchDir, { recursive: true, force: true }); - }); + afterEach(async () => { + // The timeout test's child is killed by a taskkill nobody waits for; on + // Windows its working directory stays locked (EPERM) until it exits. + for (let attempt = 0; ; attempt++) { + try { + fs.rmSync(scratchDir, { recursive: true, force: true }); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EPERM' || attempt >= 50) throw error; + await new Promise((done) => setTimeout(done, 200)); + } + } + }, 15_000); it('createTestRunGuardrail passes against the unmutated fixture repo', async () => { const guardrail = createTestRunGuardrail(scratchDir); From 3f28fca576330c80f0174cd80aad8ab5d50fcf2c Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Fri, 2 Oct 2026 15:26:44 +0300 Subject: [PATCH 3/4] R2: fileStore creates directories only when a write finds them missing; longer timeout for the file-backed history contract Co-Authored-By: Claude Opus 5.5 --- src/execution/checkpointHistory.contract.test.ts | 5 ++++- src/storage/fileStore.ts | 16 ++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/execution/checkpointHistory.contract.test.ts b/src/execution/checkpointHistory.contract.test.ts index 9039c496..dff58e45 100644 --- a/src/execution/checkpointHistory.contract.test.ts +++ b/src/execution/checkpointHistory.contract.test.ts @@ -4,7 +4,7 @@ * `LocalStorageCheckpointStore`, `KVCheckpointStore` and `fileStore()`), plus the `getCheckpointHistory()` helper on * stores that do not. */ -import { describe, it, expect, afterEach, afterAll } from 'vitest'; +import { describe, it, expect, afterEach, afterAll, vi } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -27,6 +27,9 @@ afterEach(() => { while (sqliteStores.length) sqliteStores.pop()?.close(); }); +// fileStore() writes 55 checkpoints in one test; real files are slow on a loaded Windows machine. +vi.setConfig({ testTimeout: 20_000 }); + const tempDirs: string[] = []; afterAll(() => { for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); diff --git a/src/storage/fileStore.ts b/src/storage/fileStore.ts index 74ed2435..95ffaeac 100644 --- a/src/storage/fileStore.ts +++ b/src/storage/fileStore.ts @@ -16,7 +16,7 @@ */ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { randomUUID } from 'node:crypto'; import type { ApprovalStore, ExecutionSnapshot, PendingApproval, ResolvedApproval } from '../execution/ApprovalGate'; import { @@ -70,11 +70,18 @@ async function readText(file: string): Promise { } } -/** Write to a unique temp file next to `file` and rename it over `file`. */ +/** Write to a unique temp file next to `file` and rename it over `file`; creates the directory when it is missing. */ async function writeAtomic(file: string, value: unknown): Promise { const temp = `${file}.${process.pid}.${randomUUID()}.tmp`; + const content = JSON.stringify(value, encodeBytes); try { - await writeFile(temp, JSON.stringify(value, encodeBytes), 'utf8'); + try { + await writeFile(temp, content, 'utf8'); + } catch (error) { + if (!isMissing(error)) throw error; + await mkdir(dirname(file), { recursive: true }); + await writeFile(temp, content, 'utf8'); + } await rename(temp, file); } catch (error) { await rm(temp, { force: true }); @@ -113,11 +120,9 @@ class FileCheckpointStore implements CheckpointStore { async save(sessionId: string, checkpoint: Checkpoint): Promise { const file = this.fileFor(this.checkpointDir, sessionId); - await mkdir(this.checkpointDir, { recursive: true }); await writeAtomic(file, checkpoint); if (this.historyLimit === 0) return; const ring = appendToRing(await this.readRing(sessionId), toHistoryEntry(checkpoint), this.historyLimit); - await mkdir(this.historyDir, { recursive: true }); await writeAtomic(this.fileFor(this.historyDir, sessionId), ring); } @@ -154,7 +159,6 @@ class FileApprovalStore implements ApprovalStore { async save(pending: PendingApproval, snapshot: ExecutionSnapshot): Promise { const file = this.fileFor(pending.id); - await mkdir(this.dir, { recursive: true }); const record: ResolvedApproval = { pending, snapshot }; await writeAtomic(file, record); await rm(`${file}.claim`, { force: true }); From ba2e2721de47f5f59a4c2fd4e1da9a888fdc9c06 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Fri, 2 Oct 2026 18:52:07 +0300 Subject: [PATCH 4/4] R2: format the history contract implementations list Co-Authored-By: Claude Opus 5.5 --- src/execution/checkpointHistory.contract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/execution/checkpointHistory.contract.test.ts b/src/execution/checkpointHistory.contract.test.ts index dff58e45..c8420d71 100644 --- a/src/execution/checkpointHistory.contract.test.ts +++ b/src/execution/checkpointHistory.contract.test.ts @@ -35,7 +35,7 @@ afterAll(() => { for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); }); -const implementations:Array<[string, CheckpointHistoryStoreFactory]> = [ +const implementations: Array<[string, CheckpointHistoryStoreFactory]> = [ ['memoryStore()', (options) => memoryStore(options).checkpoints], [ 'SqliteStore',