Skip to content

[R2] Export KVStore from a /kv subpath and add fileStore(dir) #189

Description

@LinuxDevil

Goal

Two durable-store gaps in the matrix row "Durable stores" close. A hand-written Cloudflare Worker can import KVStore from @lousho/build-ai-agent/kv (today no entry point exports it, yet docs/deployment.md points users at it). A Node user gets fileStore(dir), a ready-made AgentStore of plain inspectable files, instead of assembling three classes and a StorageService by hand.

Current state

  • src/deploy/kvStore.ts:105 defines export class KVStore implements Required<AgentStore> with new KVStore(kv: KVBinding, { prefix?, ttl?, historyLimit? }). KVStoreOptions is at line 24. KVCheckpointStore, KVBinding, KVPutOptions and DEFAULT_KV_KEY_PREFIX live in src/deploy/kvCheckpointStore.ts (lines 72-99); CHECKPOINT_KV_BINDING in src/deploy/checkpointBinding.ts:16. None is reachable from src/index.ts or any package.json exports entry. Only src/deploy/runtime.worker.ts:70 imports KVStore.
  • docs/deployment.md:202-205 says: "KVStore(kvBinding, { prefix?, ttl? }) ... is not exported from any entry point of the package, so importing it in a hand-written Worker is not supported yet." docs/deployment.md:305-306 and docs/durable-execution.md:202 mention KVCheckpointStore / KVStore; docs/sessions.md:228-232 has the store table with the Cloudflare row "- | KVCheckpointStore | -".
  • src/deploy/kvStore.ts:19 imports assertSessionId from ../session/sessionStore, and that module imports node:fs/promises, node:path and node:crypto (src/session/sessionStore.ts:7-9). I bundled src/deploy/kvStore.ts with esbuild (platform: 'browser', external: ['ai']) and it fails with Could not resolve "node:fs/promises", "node:path", "node:crypto". The generated Worker only works because workerNodeShimPlugin() (src/deploy/bundle.ts:103) redirects those imports for session/sessionStore. A user's own Worker has no such shim, so exporting KVStore as it is would not bundle. src/execution/checkpoint.ts:6-7 also has non-type imports of ../providers and ../storage (Message, StorageService) that esbuild reported as retained imports; see Notes.
  • package.json exports has ./sqlite (dist storage/sqlite/index) and no ./kv. tsup.config.ts:41 lists 'storage/sqlite/index' as an entry; there is no deploy/... entry.
  • File stores today: FileSessionStore(dir) (src/session/sessionStore.ts:106, root export via src/session/index.ts:12), LocalStorageCheckpointStore(storage) (src/execution/checkpoint.ts:238), StorageServiceApprovalStore(storage) (src/execution/ApprovalGate.ts:171). The last two need a StorageService(databaseIdHash, schema, fs, path, rootPath) (src/storage/StorageService.ts:53) and use .lock files: a crashed writer blocks others for 5 seconds (50 attempts x 100 ms) and then they throw. docs/sessions.md:236-257 tells users to combine them by hand with an undefined storage. Agent Forge has its own FileCheckpointStore (apps/agent-forge/server/checkpointStore.ts).
  • src/storage/agentStore.ts holds AgentStore and memoryStore(); src/index.ts:56 exports memoryStore, AgentStore, MemoryStoreOptions from it.

Scope

In:

  1. @lousho/build-ai-agent/kv subpath.
    • New barrel src/deploy/kv.ts exporting KVStore, KVCheckpointStore, CHECKPOINT_KV_BINDING, and the types KVStoreOptions, KVBinding, KVPutOptions.
    • tsup.config.ts: add entry 'deploy/kv': 'src/deploy/kv.ts'. package.json exports: add "./kv" with types ./dist/deploy/kv.d.ts, import ./dist/deploy/kv.mjs, require ./dist/deploy/kv.js. This is additive, so the ticket is not owner-decision.
    • Make the barrel Worker-safe: move assertSessionId and its pattern into a new Node-free module src/session/sessionId.ts and re-export it from src/session/sessionStore.ts so every existing import keeps working. src/deploy/kvStore.ts imports from the new module and uses import type for SessionStore. Change the two value imports in src/execution/checkpoint.ts:6-7 to import type if they are used only as types.
    • New test src/deploy/kv.test.ts: (a) esbuild bundles src/deploy/kv.ts with platform: 'browser', external: ['ai', 'zod', '@opentelemetry/api'], metafile: true and no node:* external, the build succeeds, and no input in the metafile imports a node: path; (b) the barrel exports the names above. This is the regression guard for the Node-free graph. esbuild is a devDependency.
  2. fileStore(dir, options?).
    • src/storage/fileStore.ts: export function fileStore(dir: string, options?: { historyLimit?: number }): Required<AgentStore>. Layout under dir: sessions/<id>.json (reuse FileSessionStore(join(dir, 'sessions'))), checkpoints/<id>.json, checkpoint-history/<id>.json, approvals/<id>.json.
    • Checkpoints: a FileCheckpointStore class in the same file (not exported from the root; Agent Forge keeps its own). It uses the helpers appendToRing, newestFirst, resolveHistoryLimit, toHistoryEntry from src/execution/checkpoint.ts, writes atomically (temp file, then rename, like FileSessionStore.save), validates the session id with assertSessionId, creates directories on first write, and treats a corrupt history file as empty (as apps/agent-forge/server/checkpointStore.ts does).
    • Approvals: a FileApprovalStore implements ApprovalStore in the same file. save writes approvals/<id>.json atomically. resolve(id) claims the record by renaming it to a unique temp name, reads it, deletes it, and returns null when the rename fails with ENOENT, so two processes resolving one approval cannot both get it. Validate id against /^[A-Za-z0-9_-]{1,128}$/ first: approval ids reach resolve from HTTP input and become file names.
    • No .lock files and no StorageService.
    • Export fileStore from the root: src/index.ts, next to the memoryStore line 56. FileSessionStore is a root export already, so Node imports in the root are not new.
  3. Docs.
    • docs/deployment.md: replace the paragraph at lines 202-205 with the import import { KVStore } from '@lousho/build-ai-agent/kv' and a Worker example (createAgent({ provider, store: new KVStore(env.AGENT_KV) }), with env typed using KVBinding); keep the key table.
    • docs/sessions.md: replace the hand-built file store snippet (lines 236-257) with fileStore('./.lousho'), change the "Files" row to fileStore(dir), and the Cloudflare KV row to KVStore from /kv.
    • docs/durable-execution.md:202: add fileStore(dir) to the history table (history yes, option fileStore(dir, { historyLimit })) and the import path of KVStore.
    • docs/api-overview.md:51-52: add fileStore() and KVStore (from /kv) beside SqliteStore.
    • Every snippet passes npm run docs:verify-snippets. Do not rename or add ##/###/#### headings in existing pages.
  4. CHANGELOG under ## [Unreleased]: Added @lousho/build-ai-agent/kv (KVStore, KVCheckpointStore) and fileStore().

Out:

  • A D1 or Durable Object store, and a Redis store.
  • Making Agent Forge use fileStore (its FileCheckpointStore uses .lousho/agents/<agentId>/checkpoints).
  • Changing how the generated Worker builds its store (workerStore() in src/deploy/runtime.worker.ts:118).
  • Docs-site changes: the pages already exist; no new page.

Acceptance criteria

  • @lousho/build-ai-agent/kv resolves in ESM and CJS and its .d.ts exports KVStore, KVCheckpointStore, CHECKPOINT_KV_BINDING, KVStoreOptions, KVBinding, KVPutOptions. npm run pack-smoke passes (it loads every exports subpath).
  • src/deploy/kv.test.ts proves the barrel bundles for the browser platform with no node: import (the same bundle of kvStore.ts fails on main today).
  • src/session/sessionId.ts exists and src/session/sessionStore.ts still exports assertSessionId; npx vitest run src/session src/deploy passes.
  • src/storage/fileStore.test.ts covers, in a temp directory: session round-trip including a Uint8Array file part; checkpoint save/load/delete and history() newest first with historyLimit honored (0 keeps none; delete(id, { keepHistory: true }) keeps the ring); approval save then resolve returns the record once and a second resolve returns null; two concurrent resolve calls on one id give exactly one record; a session id and an approval id containing ../ are rejected; a truncated history file reads as empty; the files appear at the layout above.
  • fileStore() runs through the existing src/execution/__fixtures__/checkpointHistoryContract.ts (see src/execution/checkpointHistory.contract.test.ts for how a store is plugged in).
  • A createAgent({ provider: mockModel(...), store: fileStore(dir) }) test: a session survives a second createAgent() on the same dir; an approval-gated run paused on the first agent resumes on the second (model it on src/createAgentStore.test.ts).
  • The four doc pages above are updated; npm run docs:verify-snippets -- --skip-build and npm run docs:llms:check pass after npm run docs:llms.
  • CHANGELOG entry added; README unchanged (it is the short front page).
  • The PR description says no page was added and no heading changed, so the docs site needs no structural update.

Live test

None: this ticket spends nothing.

Dependencies

None. Conflicts: package.json and tsup.config.ts are also touched by R4 (the files list) and by A1 to A3 (new subpaths); keep the exports edit to one added block. docs/sessions.md and docs/deployment.md are also edited by tickets G2a and G6; keep edits to the lines named above.

Notes for the implementer

  • Decision: the subpath is ./kv (the name in the plan), and fileStore is a root export, not a subpath, because FileSessionStore already pulls node:fs into the root. SqliteStore is not the precedent: it needs node:sqlite, which the root must never load.
  • Decision: no new dependency, and no StorageService in fileStore: its lock files go stale after a crash and block for 5 seconds.
  • src/session/sessionStore.ts must keep exporting assertSessionId (used by src/cli/chatRepl.ts:15, src/server/fetchRoutes.ts:19, src/session/AgentSession.ts:17). Re-export it; do not change those imports.
  • NODE_SHIMMED_IMPORTERS in src/deploy/bundle.ts:103 still lists session/sessionStore; leave it, the generated Worker still bundles AgentSession, which imports it.
  • tsconfig.json:18 has isolatedModules, so esbuild keeps an import unless it is import type. If the browser bundle of kv.ts still reaches ../providers or ../storage, change those lines to import type. If anything else with a node: import shows up, fix it at the source; do not add a shim.
  • fallow flags dead exports: export only what the root barrel and tests use.
  • Windows: rename over an existing file works in Node on the same volume (FileSessionStore already relies on it). The resolve claim renames to a new unique name, which is safe.

Round 2 ticket R2. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; put Closes #<this issue> in it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    model:opusRun loop, security or API design; needs Opusround-2Round 2 plan ticketwave-0Round 2, wave 0

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions