diff --git a/docs/superpowers/plans/2026-05-22-phase3-agent-protocol.md b/docs/superpowers/plans/2026-05-22-phase3-agent-protocol.md new file mode 100644 index 00000000..2c02e7a1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-phase3-agent-protocol.md @@ -0,0 +1,1815 @@ +# Phase 3 Sub-project 7 — Agent Protocol + SQLite Storage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Dawn's `POST /runs/stream` with AP-compatible HTTP routes backed by a Dawn-native SQLite checkpointer + threads store, so conversation state survives process restart. + +**Architecture:** New `@dawn-ai/sqlite-storage` package wraps `node:sqlite` to provide `sqliteCheckpointer` (a `BaseCheckpointSaver`) and `createThreadsStore`. Both are pluggable via `dawn.config.ts`. The dev server's HTTP layer is rewritten to expose AP routes (`/threads`, `/threads/{id}/runs/stream`, `/threads/{id}/state`, etc.) and the existing in-process `MemorySaver` in `@dawn-ai/langchain` is replaced by a caller-injected checkpointer. + +**Tech Stack:** TypeScript, pnpm workspaces, vitest, `node:sqlite` (built-in, Node 22+), `@langchain/langgraph-checkpoint` (for `BaseCheckpointSaver` types), biome. + +**Spec:** `docs/superpowers/specs/2026-05-22-phase3-agent-protocol-design.md` + +--- + +## File map + +**New (`packages/sqlite-storage/`)** +- `package.json`, `tsconfig.json`, `vitest.config.ts` +- `src/index.ts` — public re-exports +- `src/internal/db.ts` — `openDb(path)` opens `DatabaseSync`, enables WAL + FK pragmas +- `src/internal/migrate.ts` — `runMigrations(db, current, migrations)` with `schema_version` table +- `src/checkpointer/schema.ts` — DDL for `checkpoints` + `writes` +- `src/checkpointer/serde.ts` — encode/decode JSON+BLOB checkpoint payloads +- `src/checkpointer/saver.ts` — `DawnSqliteSaver` (subclass of `BaseCheckpointSaver`) +- `src/checkpointer/index.ts` — `sqliteCheckpointer({path})` factory +- `src/threads/schema.ts` — DDL for `threads` +- `src/threads/store.ts` — CRUD impl +- `src/threads/index.ts` — `createThreadsStore({path})` factory + types +- `test/checkpointer.test.ts`, `test/threads.test.ts`, `test/migrate.test.ts` + +**Modified** +- `packages/core/src/types.ts` — add `checkpointer`, `threadsStore` to `DawnConfig`; export `ThreadsStore` type +- `packages/langchain/src/agent-adapter.ts` — accept checkpointer from caller; drop `MemorySaver` import +- `packages/cli/src/lib/runtime/execute-route.ts` — instantiate sqlite defaults, thread `threadsStore` through +- `packages/cli/src/lib/dev/runtime-server.ts` — full rewrite of HTTP routes (AP shape) +- `examples/chat/web/app/api/permission-resume/route.ts` — point proxy at `/threads/{id}/resume` +- `examples/chat/web/app/page.tsx` — pass `threadId` directly to AP endpoints; create thread on first send +- Test harness packing: `test/generated/run-generated-app.test.ts`, `test/generated/harness.ts`, `test/generated/cli-testing-export.test.ts`, `test/runtime/run-runtime-contract.test.ts`, `test/smoke/run-smoke.test.ts`, `packages/create-dawn-app/src/index.ts` + +**New tests** +- `test/runtime/run-agent-protocol.test.ts` — integration: persistence across restart + +--- + +## Task 1: Scaffold `@dawn-ai/sqlite-storage` package + +**Files:** +- Create: `packages/sqlite-storage/package.json` +- Create: `packages/sqlite-storage/tsconfig.json` +- Create: `packages/sqlite-storage/vitest.config.ts` +- Create: `packages/sqlite-storage/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@dawn-ai/sqlite-storage", + "version": "0.1.0", + "private": false, + "type": "module", + "license": "MIT", + "homepage": "https://github.com/cacheplane/dawnai/tree/main/packages/sqlite-storage#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/cacheplane/dawnai.git", + "directory": "packages/sqlite-storage" + }, + "bugs": { "url": "https://github.com/cacheplane/dawnai/issues" }, + "engines": { "node": ">=22.12.0" }, + "files": ["dist"], + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } + }, + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsc -b tsconfig.json", + "lint": "biome check --config-path ../config-biome/biome.json package.json src tsconfig.json vitest.config.ts", + "test": "vitest --run --config vitest.config.ts --passWithNoTests", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@langchain/langgraph-checkpoint": "^0.1.0" + }, + "devDependencies": { + "@dawn-ai/config-typescript": "workspace:*", + "@langchain/langgraph-checkpoint": "^0.1.0", + "@types/node": "25.6.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../config-typescript/node.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*.ts"] +} +``` + +- [ ] **Step 3: Create vitest.config.ts** + +```ts +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + passWithNoTests: true, + }, +}) +``` + +- [ ] **Step 4: Create stub src/index.ts** + +```ts +export {} +``` + +- [ ] **Step 5: Install + verify build** + +Run: `cd /Users/blove/repos/dawn && pnpm install && pnpm --filter @dawn-ai/sqlite-storage build` +Expected: `dist/index.js` and `dist/index.d.ts` created, no errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/sqlite-storage pnpm-lock.yaml +git commit -m "feat(sqlite-storage): scaffold package" +``` + +--- + +## Task 2: `openDb` helper with WAL + FK pragmas + +**Files:** +- Create: `packages/sqlite-storage/src/internal/db.ts` +- Create: `packages/sqlite-storage/test/db.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +// packages/sqlite-storage/test/db.test.ts +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { openDb } from "../src/internal/db.js" + +describe("openDb", () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "dawn-sqlite-")) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + + it("opens a database with WAL journal_mode and foreign_keys ON", () => { + const db = openDb(join(dir, "test.sqlite")) + const journal = db.prepare("PRAGMA journal_mode").get() as { journal_mode: string } + const fk = db.prepare("PRAGMA foreign_keys").get() as { foreign_keys: number } + expect(journal.journal_mode).toBe("wal") + expect(fk.foreign_keys).toBe(1) + db.close() + }) + + it("creates parent directory if missing", () => { + const path = join(dir, "nested", "deep", "test.sqlite") + const db = openDb(path) + expect(db).toBeDefined() + db.close() + }) +}) +``` + +- [ ] **Step 2: Run test (expect fail)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: FAIL — cannot find `../src/internal/db.js`. + +- [ ] **Step 3: Implement** + +```ts +// packages/sqlite-storage/src/internal/db.ts +import { mkdirSync } from "node:fs" +import { dirname } from "node:path" +import { DatabaseSync } from "node:sqlite" + +export type Db = DatabaseSync + +export function openDb(path: string): Db { + mkdirSync(dirname(path), { recursive: true }) + const db = new DatabaseSync(path) + db.exec("PRAGMA journal_mode = WAL") + db.exec("PRAGMA foreign_keys = ON") + db.exec("PRAGMA synchronous = NORMAL") + return db +} +``` + +- [ ] **Step 4: Run test (expect pass)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/sqlite-storage/src/internal/db.ts packages/sqlite-storage/test/db.test.ts +git commit -m "feat(sqlite-storage): openDb helper with WAL + FK pragmas" +``` + +--- + +## Task 3: Schema migration runner + +**Files:** +- Create: `packages/sqlite-storage/src/internal/migrate.ts` +- Create: `packages/sqlite-storage/test/migrate.test.ts` + +- [ ] **Step 1: Write failing test** + +```ts +// packages/sqlite-storage/test/migrate.test.ts +import { describe, expect, it } from "vitest" +import { DatabaseSync } from "node:sqlite" +import { runMigrations } from "../src/internal/migrate.js" + +function memDb(): DatabaseSync { + return new DatabaseSync(":memory:") +} + +describe("runMigrations", () => { + it("creates schema_version table and applies all migrations on fresh db", () => { + const db = memDb() + runMigrations(db, [ + { version: 1, up: "CREATE TABLE t1(id INTEGER)" }, + { version: 2, up: "CREATE TABLE t2(id INTEGER)" }, + ]) + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as { name: string }[] + expect(tables.map((t) => t.name)).toEqual(["schema_version", "t1", "t2"]) + const v = db.prepare("SELECT max(version) AS v FROM schema_version").get() as { v: number } + expect(v.v).toBe(2) + }) + + it("skips migrations already applied", () => { + const db = memDb() + runMigrations(db, [{ version: 1, up: "CREATE TABLE t1(id INTEGER)" }]) + // Re-run with v2 added; v1 must not re-execute. + runMigrations(db, [ + { version: 1, up: "CREATE TABLE t1(id INTEGER)" }, // would error if re-run + { version: 2, up: "CREATE TABLE t2(id INTEGER)" }, + ]) + const v = db.prepare("SELECT max(version) AS v FROM schema_version").get() as { v: number } + expect(v.v).toBe(2) + }) +}) +``` + +- [ ] **Step 2: Run test (expect fail)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/sqlite-storage/src/internal/migrate.ts +import type { DatabaseSync } from "node:sqlite" + +export interface Migration { + readonly version: number + readonly up: string +} + +export function runMigrations(db: DatabaseSync, migrations: readonly Migration[]): void { + db.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)") + const row = db.prepare("SELECT max(version) AS v FROM schema_version").get() as { v: number | null } + const current = row?.v ?? 0 + const sorted = [...migrations].sort((a, b) => a.version - b.version) + for (const m of sorted) { + if (m.version <= current) continue + db.exec("BEGIN") + try { + db.exec(m.up) + db.prepare("INSERT INTO schema_version(version) VALUES (?)").run(m.version) + db.exec("COMMIT") + } catch (err) { + db.exec("ROLLBACK") + throw err + } + } +} +``` + +- [ ] **Step 4: Run test (expect pass)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/sqlite-storage/src/internal/migrate.ts packages/sqlite-storage/test/migrate.test.ts +git commit -m "feat(sqlite-storage): migration runner with schema_version" +``` + +--- + +## Task 4: Checkpointer schema + serde + +**Files:** +- Create: `packages/sqlite-storage/src/checkpointer/schema.ts` +- Create: `packages/sqlite-storage/src/checkpointer/serde.ts` +- Create: `packages/sqlite-storage/test/serde.test.ts` + +- [ ] **Step 1: Write schema module** + +```ts +// packages/sqlite-storage/src/checkpointer/schema.ts +import type { Migration } from "../internal/migrate.js" + +export const CHECKPOINTER_MIGRATIONS: readonly Migration[] = [ + { + version: 1, + up: ` + CREATE TABLE checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + parent_checkpoint_id TEXT, + type TEXT, + checkpoint BLOB NOT NULL, + metadata BLOB NOT NULL, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) + ); + CREATE INDEX idx_checkpoints_thread ON checkpoints(thread_id, checkpoint_ns); + CREATE TABLE writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + value BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) + ); + `, + }, +] +``` + +- [ ] **Step 2: Write failing serde test** + +```ts +// packages/sqlite-storage/test/serde.test.ts +import { describe, expect, it } from "vitest" +import { decodeBlob, encodeBlob } from "../src/checkpointer/serde.js" + +describe("checkpoint serde", () => { + it("round-trips a simple object", () => { + const obj = { messages: [{ role: "user", content: "hi" }], step: 3 } + const buf = encodeBlob(obj) + expect(buf).toBeInstanceOf(Uint8Array) + expect(decodeBlob(buf)).toEqual(obj) + }) + + it("round-trips null and undefined values", () => { + expect(decodeBlob(encodeBlob({ a: null }))).toEqual({ a: null }) + }) + + it("preserves nested structure", () => { + const obj = { a: { b: { c: [1, 2, 3] } } } + expect(decodeBlob(encodeBlob(obj))).toEqual(obj) + }) +}) +``` + +- [ ] **Step 3: Run test (expect fail)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: FAIL. + +- [ ] **Step 4: Implement serde** + +```ts +// packages/sqlite-storage/src/checkpointer/serde.ts +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +export function encodeBlob(value: unknown): Uint8Array { + return encoder.encode(JSON.stringify(value)) +} + +export function decodeBlob(buf: Uint8Array): unknown { + return JSON.parse(decoder.decode(buf)) +} +``` + +- [ ] **Step 5: Run test (expect pass)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/sqlite-storage/src/checkpointer/schema.ts packages/sqlite-storage/src/checkpointer/serde.ts packages/sqlite-storage/test/serde.test.ts +git commit -m "feat(sqlite-storage): checkpointer schema + JSON serde" +``` + +--- + +## Task 5: `DawnSqliteSaver` (BaseCheckpointSaver subclass) + +**Context:** `BaseCheckpointSaver` from `@langchain/langgraph-checkpoint` requires four methods: `getTuple(config)`, `list(config, options)`, `put(config, checkpoint, metadata, newVersions)`, `putWrites(config, writes, taskId)`. Read those signatures in `node_modules/@langchain/langgraph-checkpoint/dist/base.d.ts` if unsure. + +**Files:** +- Create: `packages/sqlite-storage/src/checkpointer/saver.ts` +- Create: `packages/sqlite-storage/src/checkpointer/index.ts` +- Create: `packages/sqlite-storage/test/checkpointer.test.ts` + +- [ ] **Step 1: Write failing contract test (put → getTuple round-trip)** + +```ts +// packages/sqlite-storage/test/checkpointer.test.ts +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { sqliteCheckpointer } from "../src/checkpointer/index.js" + +describe("DawnSqliteSaver", () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "dawn-ckpt-")) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + + function newSaver() { return sqliteCheckpointer({ path: join(dir, "ckpt.sqlite") }) } + + it("put + getTuple round-trip preserves checkpoint payload", async () => { + const saver = newSaver() + const config = { configurable: { thread_id: "t1", checkpoint_ns: "" } } + const checkpoint = { + v: 1, + id: "ckpt-1", + ts: "2026-05-22T00:00:00Z", + channel_values: { messages: ["hi"] }, + channel_versions: { messages: 1 }, + versions_seen: {}, + pending_sends: [], + } + const metadata = { source: "input", step: 0, writes: null, parents: {} } + await saver.put(config, checkpoint as never, metadata as never, {}) + const tuple = await saver.getTuple({ + configurable: { thread_id: "t1", checkpoint_ns: "", checkpoint_id: "ckpt-1" }, + }) + expect(tuple).toBeDefined() + expect(tuple?.checkpoint.id).toBe("ckpt-1") + expect(tuple?.checkpoint.channel_values).toEqual({ messages: ["hi"] }) + }) + + it("getTuple without checkpoint_id returns the latest by checkpoint_id desc", async () => { + const saver = newSaver() + const cfg = { configurable: { thread_id: "t1", checkpoint_ns: "" } } + const mk = (id: string) => ({ + v: 1, id, ts: "x", channel_values: {}, channel_versions: {}, versions_seen: {}, pending_sends: [], + }) + await saver.put(cfg, mk("a") as never, { source: "input", step: 0, writes: null, parents: {} } as never, {}) + await saver.put(cfg, mk("b") as never, { source: "input", step: 1, writes: null, parents: {} } as never, {}) + const t = await saver.getTuple(cfg) + expect(t?.checkpoint.id).toBe("b") + }) + + it("list yields checkpoints in reverse id order", async () => { + const saver = newSaver() + const cfg = { configurable: { thread_id: "t1", checkpoint_ns: "" } } + const mk = (id: string) => ({ + v: 1, id, ts: "x", channel_values: {}, channel_versions: {}, versions_seen: {}, pending_sends: [], + }) + await saver.put(cfg, mk("a") as never, { source: "input", step: 0, writes: null, parents: {} } as never, {}) + await saver.put(cfg, mk("b") as never, { source: "input", step: 1, writes: null, parents: {} } as never, {}) + const ids: string[] = [] + for await (const t of saver.list(cfg)) ids.push(t.checkpoint.id) + expect(ids).toEqual(["b", "a"]) + }) + + it("putWrites is idempotent on (thread_id, ns, ckpt_id, task_id, idx)", async () => { + const saver = newSaver() + const cfg = { configurable: { thread_id: "t1", checkpoint_ns: "", checkpoint_id: "ckpt-1" } } + await saver.putWrites(cfg, [["messages", "a"]], "task-1") + await saver.putWrites(cfg, [["messages", "a"]], "task-1") // must not throw + expect(true).toBe(true) + }) + + it("persists across saver instances (file-backed)", async () => { + const path = join(dir, "ckpt.sqlite") + const s1 = sqliteCheckpointer({ path }) + const cfg = { configurable: { thread_id: "t1", checkpoint_ns: "" } } + const c = { v: 1, id: "c1", ts: "x", channel_values: { x: 1 }, channel_versions: {}, versions_seen: {}, pending_sends: [] } + await s1.put(cfg, c as never, { source: "input", step: 0, writes: null, parents: {} } as never, {}) + const s2 = sqliteCheckpointer({ path }) + const t = await s2.getTuple({ configurable: { thread_id: "t1", checkpoint_ns: "", checkpoint_id: "c1" } }) + expect(t?.checkpoint.channel_values).toEqual({ x: 1 }) + }) +}) +``` + +- [ ] **Step 2: Run test (expect fail)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: FAIL. + +- [ ] **Step 3: Implement DawnSqliteSaver** + +```ts +// packages/sqlite-storage/src/checkpointer/saver.ts +import { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" +import type { Checkpoint, CheckpointMetadata, CheckpointTuple } from "@langchain/langgraph-checkpoint" +import type { RunnableConfig } from "@langchain/core/runnables" +import type { Db } from "../internal/db.js" +import { decodeBlob, encodeBlob } from "./serde.js" + +interface CheckpointRow { + thread_id: string + checkpoint_ns: string + checkpoint_id: string + parent_checkpoint_id: string | null + type: string | null + checkpoint: Uint8Array + metadata: Uint8Array +} + +interface WriteRow { + task_id: string + channel: string + type: string | null + value: Uint8Array | null +} + +export class DawnSqliteSaver extends BaseCheckpointSaver { + constructor(private readonly db: Db) { + super() + } + + async getTuple(config: RunnableConfig): Promise { + const threadId = config.configurable?.thread_id as string | undefined + if (!threadId) return undefined + const ns = (config.configurable?.checkpoint_ns as string | undefined) ?? "" + const ckptId = config.configurable?.checkpoint_id as string | undefined + + let row: CheckpointRow | undefined + if (ckptId) { + row = this.db + .prepare( + "SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?", + ) + .get(threadId, ns, ckptId) as CheckpointRow | undefined + } else { + row = this.db + .prepare( + "SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? ORDER BY checkpoint_id DESC LIMIT 1", + ) + .get(threadId, ns) as CheckpointRow | undefined + } + if (!row) return undefined + + const checkpoint = decodeBlob(row.checkpoint) as Checkpoint + const metadata = decodeBlob(row.metadata) as CheckpointMetadata + + const writeRows = this.db + .prepare( + "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx", + ) + .all(threadId, ns, row.checkpoint_id) as WriteRow[] + const pendingWrites: [string, string, unknown][] = writeRows.map((w) => [ + w.task_id, + w.channel, + w.value ? decodeBlob(w.value) : null, + ]) + + return { + config: { + configurable: { + thread_id: row.thread_id, + checkpoint_ns: row.checkpoint_ns, + checkpoint_id: row.checkpoint_id, + }, + }, + checkpoint, + metadata, + parentConfig: row.parent_checkpoint_id + ? { + configurable: { + thread_id: row.thread_id, + checkpoint_ns: row.checkpoint_ns, + checkpoint_id: row.parent_checkpoint_id, + }, + } + : undefined, + pendingWrites, + } + } + + async *list( + config: RunnableConfig, + options?: { limit?: number; before?: RunnableConfig; filter?: Record }, + ): AsyncGenerator { + const threadId = config.configurable?.thread_id as string | undefined + if (!threadId) return + const ns = (config.configurable?.checkpoint_ns as string | undefined) ?? "" + const before = options?.before?.configurable?.checkpoint_id as string | undefined + const limit = options?.limit ?? -1 + + const params: unknown[] = [threadId, ns] + let sql = + "SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ?" + if (before) { + sql += " AND checkpoint_id < ?" + params.push(before) + } + sql += " ORDER BY checkpoint_id DESC" + if (limit > 0) { + sql += " LIMIT ?" + params.push(limit) + } + const rows = this.db.prepare(sql).all(...params) as CheckpointRow[] + for (const row of rows) { + yield { + config: { + configurable: { + thread_id: row.thread_id, + checkpoint_ns: row.checkpoint_ns, + checkpoint_id: row.checkpoint_id, + }, + }, + checkpoint: decodeBlob(row.checkpoint) as Checkpoint, + metadata: decodeBlob(row.metadata) as CheckpointMetadata, + parentConfig: row.parent_checkpoint_id + ? { + configurable: { + thread_id: row.thread_id, + checkpoint_ns: row.checkpoint_ns, + checkpoint_id: row.parent_checkpoint_id, + }, + } + : undefined, + } + } + } + + async put( + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + _newVersions: Record, + ): Promise { + const threadId = config.configurable?.thread_id as string + const ns = (config.configurable?.checkpoint_ns as string | undefined) ?? "" + const parentId = (config.configurable?.checkpoint_id as string | undefined) ?? null + this.db + .prepare( + `INSERT OR REPLACE INTO checkpoints + (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(threadId, ns, checkpoint.id, parentId, null, encodeBlob(checkpoint), encodeBlob(metadata)) + return { + configurable: { thread_id: threadId, checkpoint_ns: ns, checkpoint_id: checkpoint.id }, + } + } + + async putWrites( + config: RunnableConfig, + writes: [string, unknown][], + taskId: string, + ): Promise { + const threadId = config.configurable?.thread_id as string + const ns = (config.configurable?.checkpoint_ns as string | undefined) ?? "" + const ckptId = config.configurable?.checkpoint_id as string + const stmt = this.db.prepare( + `INSERT OR REPLACE INTO writes + (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + this.db.exec("BEGIN") + try { + writes.forEach(([channel, value], idx) => { + stmt.run(threadId, ns, ckptId, taskId, idx, channel, null, value == null ? null : encodeBlob(value)) + }) + this.db.exec("COMMIT") + } catch (err) { + this.db.exec("ROLLBACK") + throw err + } + } +} +``` + +- [ ] **Step 4: Implement factory** + +```ts +// packages/sqlite-storage/src/checkpointer/index.ts +import { openDb } from "../internal/db.js" +import { runMigrations } from "../internal/migrate.js" +import { CHECKPOINTER_MIGRATIONS } from "./schema.js" +import { DawnSqliteSaver } from "./saver.js" + +export interface SqliteCheckpointerOptions { + readonly path: string +} + +export function sqliteCheckpointer(options: SqliteCheckpointerOptions): DawnSqliteSaver { + const db = openDb(options.path) + runMigrations(db, CHECKPOINTER_MIGRATIONS) + return new DawnSqliteSaver(db) +} + +export { DawnSqliteSaver } from "./saver.js" +``` + +- [ ] **Step 5: Run tests (expect pass)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: PASS (all 5 checkpointer tests + earlier tests). + +- [ ] **Step 6: Commit** + +```bash +git add packages/sqlite-storage/src/checkpointer packages/sqlite-storage/test/checkpointer.test.ts +git commit -m "feat(sqlite-storage): DawnSqliteSaver implementing BaseCheckpointSaver" +``` + +--- + +## Task 6: Threads store + +**Files:** +- Create: `packages/sqlite-storage/src/threads/schema.ts` +- Create: `packages/sqlite-storage/src/threads/store.ts` +- Create: `packages/sqlite-storage/src/threads/index.ts` +- Create: `packages/sqlite-storage/test/threads.test.ts` + +- [ ] **Step 1: Define schema** + +```ts +// packages/sqlite-storage/src/threads/schema.ts +import type { Migration } from "../internal/migrate.js" + +export const THREADS_MIGRATIONS: readonly Migration[] = [ + { + version: 1, + up: ` + CREATE TABLE threads ( + thread_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'idle' + ); + CREATE INDEX idx_threads_updated ON threads(updated_at DESC); + `, + }, +] +``` + +- [ ] **Step 2: Write failing test** + +```ts +// packages/sqlite-storage/test/threads.test.ts +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { createThreadsStore } from "../src/threads/index.js" + +describe("createThreadsStore", () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "dawn-threads-")) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + + function newStore() { return createThreadsStore({ path: join(dir, "threads.sqlite") }) } + + it("create + get round-trips metadata and assigns timestamps", async () => { + const store = newStore() + const t = await store.createThread({ metadata: { user: "brian" } }) + expect(t.thread_id).toMatch(/^t-/) + expect(t.status).toBe("idle") + expect(t.metadata).toEqual({ user: "brian" }) + const fetched = await store.getThread(t.thread_id) + expect(fetched?.thread_id).toBe(t.thread_id) + expect(fetched?.metadata).toEqual({ user: "brian" }) + }) + + it("accepts explicit thread_id", async () => { + const store = newStore() + const t = await store.createThread({ thread_id: "t-explicit" }) + expect(t.thread_id).toBe("t-explicit") + }) + + it("getThread returns undefined for unknown id", async () => { + const store = newStore() + expect(await store.getThread("t-missing")).toBeUndefined() + }) + + it("deleteThread removes the thread", async () => { + const store = newStore() + const t = await store.createThread({}) + await store.deleteThread(t.thread_id) + expect(await store.getThread(t.thread_id)).toBeUndefined() + }) + + it("listThreads returns most-recently-updated first", async () => { + const store = newStore() + const a = await store.createThread({ thread_id: "t-a" }) + await new Promise((r) => setTimeout(r, 2)) + const b = await store.createThread({ thread_id: "t-b" }) + const list = await store.listThreads() + expect(list[0]?.thread_id).toBe(b.thread_id) + expect(list[1]?.thread_id).toBe(a.thread_id) + }) +}) +``` + +- [ ] **Step 3: Run test (expect fail)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: FAIL. + +- [ ] **Step 4: Implement store** + +```ts +// packages/sqlite-storage/src/threads/store.ts +import { randomBytes } from "node:crypto" +import type { Db } from "../internal/db.js" + +export type ThreadStatus = "idle" | "busy" | "interrupted" + +export interface Thread { + readonly thread_id: string + readonly created_at: string + readonly updated_at: string + readonly metadata: Record + readonly status: ThreadStatus +} + +export interface CreateThreadInput { + readonly thread_id?: string + readonly metadata?: Record +} + +export interface ThreadsStore { + createThread(input: CreateThreadInput): Promise + getThread(threadId: string): Promise + deleteThread(threadId: string): Promise + listThreads(): Promise + updateStatus(threadId: string, status: ThreadStatus): Promise +} + +interface ThreadRow { + thread_id: string + created_at: string + updated_at: string + metadata: string + status: ThreadStatus +} + +function rowToThread(row: ThreadRow): Thread { + return { + thread_id: row.thread_id, + created_at: row.created_at, + updated_at: row.updated_at, + metadata: JSON.parse(row.metadata) as Record, + status: row.status, + } +} + +function newThreadId(): string { + return `t-${randomBytes(4).toString("hex")}` +} + +export function makeThreadsStore(db: Db): ThreadsStore { + return { + async createThread(input) { + const now = new Date().toISOString() + const threadId = input.thread_id ?? newThreadId() + const metadata = JSON.stringify(input.metadata ?? {}) + db.prepare( + "INSERT INTO threads(thread_id, created_at, updated_at, metadata, status) VALUES (?, ?, ?, ?, 'idle')", + ).run(threadId, now, now, metadata) + return { + thread_id: threadId, + created_at: now, + updated_at: now, + metadata: input.metadata ?? {}, + status: "idle", + } + }, + async getThread(threadId) { + const row = db + .prepare("SELECT thread_id, created_at, updated_at, metadata, status FROM threads WHERE thread_id = ?") + .get(threadId) as ThreadRow | undefined + return row ? rowToThread(row) : undefined + }, + async deleteThread(threadId) { + db.prepare("DELETE FROM threads WHERE thread_id = ?").run(threadId) + }, + async listThreads() { + const rows = db + .prepare( + "SELECT thread_id, created_at, updated_at, metadata, status FROM threads ORDER BY updated_at DESC", + ) + .all() as ThreadRow[] + return rows.map(rowToThread) + }, + async updateStatus(threadId, status) { + const now = new Date().toISOString() + db.prepare("UPDATE threads SET status = ?, updated_at = ? WHERE thread_id = ?").run(status, now, threadId) + }, + } +} +``` + +- [ ] **Step 5: Implement factory** + +```ts +// packages/sqlite-storage/src/threads/index.ts +import { openDb } from "../internal/db.js" +import { runMigrations } from "../internal/migrate.js" +import { THREADS_MIGRATIONS } from "./schema.js" +import { makeThreadsStore } from "./store.js" + +export interface ThreadsStoreOptions { + readonly path: string +} + +export function createThreadsStore(options: ThreadsStoreOptions) { + const db = openDb(options.path) + runMigrations(db, THREADS_MIGRATIONS) + return makeThreadsStore(db) +} + +export type { Thread, ThreadStatus, ThreadsStore, CreateThreadInput } from "./store.js" +``` + +- [ ] **Step 6: Run tests (expect pass)** + +Run: `pnpm --filter @dawn-ai/sqlite-storage test` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/sqlite-storage/src/threads packages/sqlite-storage/test/threads.test.ts +git commit -m "feat(sqlite-storage): threads store CRUD" +``` + +--- + +## Task 7: Public exports + +**Files:** +- Modify: `packages/sqlite-storage/src/index.ts` + +- [ ] **Step 1: Write re-exports** + +```ts +// packages/sqlite-storage/src/index.ts +export { sqliteCheckpointer, DawnSqliteSaver } from "./checkpointer/index.js" +export type { SqliteCheckpointerOptions } from "./checkpointer/index.js" +export { createThreadsStore } from "./threads/index.js" +export type { + Thread, + ThreadStatus, + ThreadsStore, + CreateThreadInput, + ThreadsStoreOptions, +} from "./threads/index.js" +``` + +(Note: `ThreadsStoreOptions` is re-exported; ensure the threads `index.ts` exports it.) + +- [ ] **Step 2: Add ThreadsStoreOptions export** + +Edit `packages/sqlite-storage/src/threads/index.ts` to add: + +```ts +export type { ThreadsStoreOptions } +``` + +at the bottom (and convert the existing inline `interface` into an explicit export). + +- [ ] **Step 3: Build + typecheck** + +Run: `pnpm --filter @dawn-ai/sqlite-storage build && pnpm --filter @dawn-ai/sqlite-storage typecheck` +Expected: clean. + +- [ ] **Step 4: Lint** + +Run: `pnpm --filter @dawn-ai/sqlite-storage lint` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add packages/sqlite-storage/src/index.ts packages/sqlite-storage/src/threads/index.ts +git commit -m "feat(sqlite-storage): public exports" +``` + +--- + +## Task 8: Extend `DawnConfig` with `checkpointer` + `threadsStore` + +**Files:** +- Modify: `packages/core/src/types.ts` + +- [ ] **Step 1: Read current DawnConfig** + +```bash +grep -n "DawnConfig" /Users/blove/repos/dawn/packages/core/src/types.ts +``` + +- [ ] **Step 2: Add imports + fields** + +Add these imports at the top of `packages/core/src/types.ts`: + +```ts +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" +import type { ThreadsStore } from "@dawn-ai/sqlite-storage" +``` + +Inside the `DawnConfig` interface, add: + +```ts +readonly checkpointer?: BaseCheckpointSaver +readonly threadsStore?: ThreadsStore +``` + +- [ ] **Step 3: Add @dawn-ai/sqlite-storage to core's package.json** + +Edit `packages/core/package.json`, add to `peerDependencies`: + +```json +"@dawn-ai/sqlite-storage": "workspace:*", +"@langchain/langgraph-checkpoint": "^0.1.0" +``` + +And to `devDependencies`: + +```json +"@dawn-ai/sqlite-storage": "workspace:*", +"@langchain/langgraph-checkpoint": "^0.1.0" +``` + +- [ ] **Step 4: Install + typecheck** + +Run: `cd /Users/blove/repos/dawn && pnpm install && pnpm --filter @dawn-ai/core typecheck` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/types.ts packages/core/package.json pnpm-lock.yaml +git commit -m "feat(core): add checkpointer + threadsStore to DawnConfig" +``` + +--- + +## Task 9: `agent-adapter` accepts external checkpointer + +**Context:** Currently `packages/langchain/src/agent-adapter.ts` constructs a process-level `MemorySaver` singleton. Replace that with a caller-supplied `BaseCheckpointSaver`. + +**Files:** +- Modify: `packages/langchain/src/agent-adapter.ts` + +- [ ] **Step 1: Locate the MemorySaver site** + +```bash +grep -n "MemorySaver\|checkpointer" /Users/blove/repos/dawn/packages/langchain/src/agent-adapter.ts +``` + +- [ ] **Step 2: Add `checkpointer` to `AgentOptions`** + +In `packages/langchain/src/agent-adapter.ts`, find the `AgentOptions` interface and add: + +```ts +readonly checkpointer?: BaseCheckpointSaver +``` + +with `import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"` at top. + +- [ ] **Step 3: Replace MemorySaver fallback** + +Replace the `const checkpointer = new MemorySaver()` line with: + +```ts +const checkpointer = options.checkpointer +if (!checkpointer) { + throw new Error( + "[dawn] agent-adapter requires a checkpointer. Pass one in AgentOptions (the CLI runtime instantiates sqliteCheckpointer by default).", + ) +} +``` + +Remove the `import { MemorySaver } from "@langchain/langgraph"` line. + +- [ ] **Step 4: Typecheck** + +Run: `pnpm --filter @dawn-ai/langchain typecheck` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add packages/langchain/src/agent-adapter.ts +git commit -m "refactor(langchain): require external checkpointer in agent-adapter" +``` + +--- + +## Task 10: `execute-route` instantiates sqlite defaults + +**Files:** +- Modify: `packages/cli/src/lib/runtime/execute-route.ts` + +- [ ] **Step 1: Inspect current wiring** + +```bash +grep -n "createAgent\|checkpointer\|permissionsStore\|MemorySaver" /Users/blove/repos/dawn/packages/cli/src/lib/runtime/execute-route.ts +``` + +- [ ] **Step 2: Add imports** + +At the top of `execute-route.ts`: + +```ts +import { sqliteCheckpointer, createThreadsStore, type ThreadsStore } from "@dawn-ai/sqlite-storage" +import { join } from "node:path" +``` + +- [ ] **Step 3: Instantiate defaults after loading config** + +Where `config` is loaded (just after permissions wiring), add: + +```ts +const checkpointer = + config.checkpointer ?? sqliteCheckpointer({ path: join(appRoot, ".dawn/checkpoints.sqlite") }) +const threadsStore: ThreadsStore = + config.threadsStore ?? createThreadsStore({ path: join(appRoot, ".dawn/threads.sqlite") }) +``` + +(Use the same `appRoot` variable already used by the permissions wiring.) + +- [ ] **Step 4: Pass `checkpointer` to agent-adapter call** + +Find the `createAgent(...)` or `agentAdapter(...)` invocation and add `checkpointer` to the options object. + +- [ ] **Step 5: Export `threadsStore` for the HTTP layer** + +Change `executeResolvedRoute` (or the surrounding factory) to return `threadsStore` alongside whatever it currently returns. If it returns a function, change the runtime-server caller to receive both. + +Concrete shape: add to the route descriptor returned by `resolveRoute(...)`: + +```ts +return { ...existing, threadsStore, checkpointer } +``` + +- [ ] **Step 6: Add package deps** + +Edit `packages/cli/package.json`: + +```json +"dependencies": { + "@dawn-ai/sqlite-storage": "workspace:*" +} +``` + +- [ ] **Step 7: Install + typecheck** + +Run: `pnpm install && pnpm --filter @dawn-ai/cli typecheck` +Expected: clean. + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/lib/runtime/execute-route.ts packages/cli/package.json pnpm-lock.yaml +git commit -m "feat(cli): instantiate sqlite checkpointer + threadsStore defaults" +``` + +--- + +## Task 11: AP routes — threads CRUD + +**Context:** `packages/cli/src/lib/dev/runtime-server.ts` currently has one `POST /runs/stream` and one resume endpoint. Replace with AP-shaped routes. Read the file in full first. + +**Files:** +- Modify: `packages/cli/src/lib/dev/runtime-server.ts` +- Create: `test/runtime/run-agent-protocol.test.ts` (integration, deferred to Task 15) + +- [ ] **Step 1: Read the existing server** + +```bash +wc -l /Users/blove/repos/dawn/packages/cli/src/lib/dev/runtime-server.ts +``` + +Read entire file before editing. + +- [ ] **Step 2: Extract a `routeHandler` helper** + +At the top of the request listener, add a small URL pattern matcher. Add this helper above the listener: + +```ts +type RouteMatcher = { + method: string + pattern: RegExp + handle: (req: IncomingMessage, res: ServerResponse, params: Record) => Promise +} + +function matchRoute(routes: RouteMatcher[], req: IncomingMessage): { handle: RouteMatcher["handle"]; params: Record } | undefined { + const url = new URL(req.url ?? "/", "http://localhost") + for (const r of routes) { + if (r.method !== req.method) continue + const m = url.pathname.match(r.pattern) + if (!m) continue + const params = m.groups ?? {} + return { handle: r.handle, params } + } + return undefined +} +``` + +- [ ] **Step 3: Add `POST /threads`** + +```ts +{ + method: "POST", + pattern: /^\/threads$/, + handle: async (req, res) => { + const body = await readJson(req) + const thread = await threadsStore.createThread({ metadata: body?.metadata }) + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify(thread)) + }, +} +``` + +- [ ] **Step 4: Add `GET /threads/:thread_id`** + +```ts +{ + method: "GET", + pattern: /^\/threads\/(?[^/]+)$/, + handle: async (_req, res, params) => { + const t = await threadsStore.getThread(params.thread_id) + if (!t) { + res.writeHead(404, { "content-type": "application/json" }) + res.end(JSON.stringify({ error: "thread not found", code: "thread_not_found" })) + return + } + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify(t)) + }, +} +``` + +- [ ] **Step 5: Add `DELETE /threads/:thread_id`** + +```ts +{ + method: "DELETE", + pattern: /^\/threads\/(?[^/]+)$/, + handle: async (_req, res, params) => { + await threadsStore.deleteThread(params.thread_id) + res.writeHead(204).end() + }, +} +``` + +- [ ] **Step 6: Provide `readJson` helper if missing** + +```ts +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const raw = Buffer.concat(chunks).toString("utf8") + return raw ? JSON.parse(raw) : {} +} +``` + +- [ ] **Step 7: Smoke test the new endpoints with curl** + +Start the server (in another terminal): +```bash +cd examples/chat/server && pnpm dawn dev +``` + +Run: +```bash +curl -X POST -H "content-type: application/json" -d '{"metadata":{"user":"brian"}}' http://localhost:3001/threads +``` +Expected: JSON `{thread_id: "t-...", ...}`. + +```bash +curl http://localhost:3001/threads/t-xxxx +curl -X DELETE http://localhost:3001/threads/t-xxxx +``` + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/lib/dev/runtime-server.ts +git commit -m "feat(cli): AP threads CRUD endpoints" +``` + +--- + +## Task 12: AP routes — runs/stream, runs/wait, state, resume + +**Files:** +- Modify: `packages/cli/src/lib/dev/runtime-server.ts` + +- [ ] **Step 1: Add `POST /threads/:thread_id/runs/stream`** + +Move the existing `/runs/stream` body into this handler, but require `params.thread_id`. The body shape becomes `{input, route, config?}` (was `{message, route, threadId}`). Pass `params.thread_id` as the `threadId` into the agent invocation. + +```ts +{ + method: "POST", + pattern: /^\/threads\/(?[^/]+)\/runs\/stream$/, + handle: async (req, res, params) => { + const body = await readJson(req) + // Ensure thread exists; create if missing (AP idempotence) + if (!(await threadsStore.getThread(params.thread_id))) { + await threadsStore.createThread({ thread_id: params.thread_id }) + } + await threadsStore.updateStatus(params.thread_id, "busy") + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }) + try { + const route = await resolveRoute(body.route) + await streamResolvedRoute({ + route, + input: body.input, + threadId: params.thread_id, + onChunk: (chunk) => res.write(`data: ${JSON.stringify(chunk)}\n\n`), + onInterrupt: (envelope) => { + res.write("event: interrupt\n") + res.write(`data: ${JSON.stringify(envelope)}\n\n`) + }, + }) + res.write("event: done\ndata: {}\n\n") + } catch (err) { + res.write(`event: error\ndata: ${JSON.stringify({ error: String(err) })}\n\n`) + } finally { + await threadsStore.updateStatus(params.thread_id, "idle") + res.end() + } + }, +} +``` + +(Names: replace `streamResolvedRoute`, `resolveRoute` with whatever the current execute-route exports — read it to confirm.) + +- [ ] **Step 2: Add `POST /threads/:thread_id/runs/wait`** + +```ts +{ + method: "POST", + pattern: /^\/threads\/(?[^/]+)\/runs\/wait$/, + handle: async (req, res, params) => { + const body = await readJson(req) + if (!(await threadsStore.getThread(params.thread_id))) { + await threadsStore.createThread({ thread_id: params.thread_id }) + } + await threadsStore.updateStatus(params.thread_id, "busy") + try { + const route = await resolveRoute(body.route) + const final = await invokeResolvedRoute({ + route, + input: body.input, + threadId: params.thread_id, + }) + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify(final)) + } finally { + await threadsStore.updateStatus(params.thread_id, "idle") + } + }, +} +``` + +If `invokeResolvedRoute` doesn't exist, create it in `packages/cli/src/lib/runtime/execute-route.ts` as a thin wrapper that calls `agent.invoke(input, {configurable: {thread_id}})`. + +- [ ] **Step 3: Add `GET /threads/:thread_id/state`** + +```ts +{ + method: "GET", + pattern: /^\/threads\/(?[^/]+)\/state$/, + handle: async (_req, res, params) => { + const tuple = await checkpointer.getTuple({ + configurable: { thread_id: params.thread_id, checkpoint_ns: "" }, + }) + if (!tuple) { + res.writeHead(404, { "content-type": "application/json" }) + res.end(JSON.stringify({ error: "no state for thread", code: "no_state" })) + return + } + res.writeHead(200, { "content-type": "application/json" }) + res.end( + JSON.stringify({ + values: tuple.checkpoint.channel_values, + next: tuple.checkpoint.pending_sends ?? [], + config: tuple.config, + metadata: tuple.metadata, + created_at: tuple.checkpoint.ts, + parent_config: tuple.parentConfig, + }), + ) + }, +} +``` + +The `checkpointer` reference must be threaded through the server constructor; update the server-factory signature to accept `{checkpointer, threadsStore}` alongside whatever route resolution it already has. + +- [ ] **Step 4: Move resume endpoint under threads** + +Locate the existing `POST /api/permission-resume` (or wherever sub-project 4.5's resume lives). Replace its route with: + +```ts +{ + method: "POST", + pattern: /^\/threads\/(?[^/]+)\/resume$/, + handle: async (req, res, params) => { + const body = await readJson(req) + const pending = pendingByThread.get(params.thread_id) + if (!pending || pending.interruptId !== body.interruptId) { + res.writeHead(409, { "content-type": "application/json" }) + res.end(JSON.stringify({ error: "stale interrupt_id", code: "stale_interrupt" })) + return + } + pending.resolve(body.decision) + pendingByThread.delete(params.thread_id) + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true })) + }, +} +``` + +- [ ] **Step 5: Remove dead `/runs/stream` route** + +Delete the un-thread-keyed `/runs/stream` handler entirely. + +- [ ] **Step 6: Typecheck** + +Run: `pnpm --filter @dawn-ai/cli typecheck` +Expected: clean. + +- [ ] **Step 7: Manual curl smoke** + +```bash +curl -X POST -H "content-type: application/json" -d '{}' http://localhost:3001/threads +# returns {"thread_id":"t-aaaa",...} + +curl -N -X POST -H "content-type: application/json" \ + -d '{"input":{"messages":[{"role":"user","content":"hi"}]},"route":"chat"}' \ + http://localhost:3001/threads/t-aaaa/runs/stream +# streams SSE + +curl http://localhost:3001/threads/t-aaaa/state +# returns {values, next, config, ...} +``` + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/lib/dev/runtime-server.ts packages/cli/src/lib/runtime/execute-route.ts +git commit -m "feat(cli): AP runs/stream, runs/wait, state, resume endpoints" +``` + +--- + +## Task 13: Update chat example to call AP endpoints + +**Files:** +- Modify: `examples/chat/web/app/api/chat/route.ts` +- Modify: `examples/chat/web/app/api/permission-resume/route.ts` +- Modify: `examples/chat/web/app/page.tsx` + +- [ ] **Step 1: Read current proxy routes** + +```bash +cat /Users/blove/repos/dawn/examples/chat/web/app/api/chat/route.ts +cat /Users/blove/repos/dawn/examples/chat/web/app/api/permission-resume/route.ts +``` + +- [ ] **Step 2: Update `/api/chat` proxy to first create thread (if new), then call `runs/stream`** + +```ts +// examples/chat/web/app/api/chat/route.ts +const DAWN = process.env.DAWN_SERVER_URL ?? "http://localhost:3001" + +export async function POST(req: Request) { + const body = (await req.json()) as { threadId: string; message: string; route: string } + // Idempotent: server creates if missing. + const upstream = await fetch(`${DAWN}/threads/${encodeURIComponent(body.threadId)}/runs/stream`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: { messages: [{ role: "user", content: body.message }] }, + route: body.route, + }), + }) + return new Response(upstream.body, { + headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }, + }) +} +``` + +- [ ] **Step 3: Update `/api/permission-resume` proxy** + +```ts +// examples/chat/web/app/api/permission-resume/route.ts +const DAWN = process.env.DAWN_SERVER_URL ?? "http://localhost:3001" + +export async function POST(req: Request) { + const body = (await req.json()) as { threadId: string; interruptId: string; decision: "once" | "always" | "deny" } + const upstream = await fetch(`${DAWN}/threads/${encodeURIComponent(body.threadId)}/resume`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ interruptId: body.interruptId, decision: body.decision }), + }) + return new Response(upstream.body, { status: upstream.status }) +} +``` + +- [ ] **Step 4: No changes needed in `page.tsx`** + +The web page already passes `threadId` through `/api/chat`; the proxy now puts it in the URL path. Verify there's nothing else that needs updating: + +```bash +grep -n "runs/stream\|threads\|permission-resume" /Users/blove/repos/dawn/examples/chat/web/app/page.tsx +``` + +- [ ] **Step 5: Manual smoke via browser** + +```bash +cd examples/chat && pnpm dev +``` + +Open browser to `http://localhost:3000`. Send a message on `/chat` route. Verify SSE events stream. Then send a second message in the same thread and verify the agent has the prior context (state survived). + +Then kill the Dawn server, restart it, send another message in the same browser session → verify the prior conversation context is still present (proves checkpoint persisted). + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/web/app/api/chat/route.ts examples/chat/web/app/api/permission-resume/route.ts +git commit -m "feat(chat-example): proxy AP-shaped endpoints" +``` + +--- + +## Task 14: Verification harness packing + +**Files:** +- Modify: `test/generated/run-generated-app.test.ts` +- Modify: `test/generated/harness.ts` +- Modify: `test/generated/cli-testing-export.test.ts` +- Modify: `test/runtime/run-runtime-contract.test.ts` +- Modify: `test/smoke/run-smoke.test.ts` +- Modify: `packages/create-dawn-app/src/index.ts` + +- [ ] **Step 1: Find every `@dawn-ai/permissions` reference** + +```bash +grep -rn "@dawn-ai/permissions" /Users/blove/repos/dawn/test /Users/blove/repos/dawn/packages/create-dawn-app/src +``` + +These are the sites that need `@dawn-ai/sqlite-storage` added in parallel. + +- [ ] **Step 2: Per file, mirror the permissions pattern** + +For each file, add `"@dawn-ai/sqlite-storage"` everywhere `"@dawn-ai/permissions"` appears: +- `packageNames` arrays +- `PackedTarballs` interface fields +- Override maps +- `toPackedTarballs` function bodies +- Fixture snapshots +- `pnpm.overrides` blocks + +Example diff per array: + +```ts +const packageNames = [ + "@dawn-ai/core", + "@dawn-ai/cli", + "@dawn-ai/langchain", + "@dawn-ai/workspace", + "@dawn-ai/permissions", + "@dawn-ai/sqlite-storage", // NEW +] as const +``` + +Example diff per interface: + +```ts +interface PackedTarballs { + core: string + cli: string + langchain: string + workspace: string + permissions: string + sqliteStorage: string // NEW +} +``` + +- [ ] **Step 3: Run framework + runtime + smoke verification** + +Run each test suite individually first: + +```bash +cd /Users/blove/repos/dawn +pnpm --filter dawn-tests test:framework +pnpm --filter dawn-tests test:runtime +pnpm --filter dawn-tests test:smoke +``` + +Expected: each passes (they pack the new package alongside others). + +- [ ] **Step 4: Commit** + +```bash +git add test packages/create-dawn-app/src/index.ts +git commit -m "test: pack @dawn-ai/sqlite-storage in verification harnesses" +``` + +--- + +## Task 15: Integration test — persistence across restart + +**Files:** +- Create: `test/runtime/run-agent-protocol.test.ts` + +- [ ] **Step 1: Write the test** + +```ts +// test/runtime/run-agent-protocol.test.ts +import { spawn, type ChildProcess } from "node:child_process" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { buildPackedApp } from "./harness.js" // existing helper from runtime tests +import { fetch } from "undici" + +describe("agent protocol persistence", () => { + let appDir: string + let server: ChildProcess | undefined + let port: number + + beforeEach(async () => { + appDir = mkdtempSync(join(tmpdir(), "dawn-ap-")) + await buildPackedApp(appDir) // packs core+cli+langchain+workspace+permissions+sqlite-storage + port = 4000 + Math.floor(Math.random() * 1000) + }) + + afterEach(() => { + server?.kill("SIGKILL") + rmSync(appDir, { recursive: true, force: true }) + }) + + async function startServer(): Promise { + server = spawn("pnpm", ["dawn", "dev", "--port", String(port)], { cwd: appDir, stdio: "pipe" }) + // Wait for "listening on" log + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("server start timeout")), 30_000) + server?.stdout?.on("data", (chunk) => { + if (chunk.toString().includes("listening")) { clearTimeout(t); resolve() } + }) + }) + } + + it("state survives server restart", async () => { + await startServer() + const base = `http://localhost:${port}` + const created = await (await fetch(`${base}/threads`, { method: "POST", body: "{}", headers: { "content-type": "application/json" } })).json() as { thread_id: string } + const threadId = created.thread_id + + // Drive a run + const runResp = await fetch(`${base}/threads/${threadId}/runs/wait`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ input: { messages: [{ role: "user", content: "hi" }] }, route: "chat" }), + }) + expect(runResp.status).toBe(200) + + // Capture state + const state1 = await (await fetch(`${base}/threads/${threadId}/state`)).json() as { values: { messages: unknown[] } } + expect(state1.values.messages.length).toBeGreaterThan(0) + + // Kill + restart server, then re-read state + server?.kill("SIGTERM") + await new Promise((r) => setTimeout(r, 500)) + await startServer() + + const state2 = await (await fetch(`http://localhost:${port}/threads/${threadId}/state`)).json() as { values: { messages: unknown[] } } + expect(state2.values.messages.length).toBe(state1.values.messages.length) + }, 60_000) +}) +``` + +- [ ] **Step 2: Run the test** + +```bash +cd /Users/blove/repos/dawn +pnpm --filter dawn-tests vitest --run test/runtime/run-agent-protocol.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add test/runtime/run-agent-protocol.test.ts +git commit -m "test(runtime): AP persistence across server restart" +``` + +--- + +## Task 16: Update phase memory + PR + +**Files:** +- Modify: `/Users/blove/.claude/projects/-Users-blove-repos-dawn/memory/project_phase_status.md` + +- [ ] **Step 1: Add sub-project 7 ✅ entry** + +Edit the file: find sub-project list, add: + +```md +7. ✅ **Agent Protocol HTTP endpoints + Dawn-native SQLite checkpointer** — shipped in [PR #NNN](https://github.com/cacheplane/dawnai/pull/NNN). New `@dawn-ai/sqlite-storage` package ships `sqliteCheckpointer` (BaseCheckpointSaver via `node:sqlite`, no native deps) and `createThreadsStore`. `dawn.config.ts.checkpointer` + `.threadsStore` are pluggable. HTTP layer rewritten to AP shape: `POST /threads`, `GET/DELETE /threads/{id}`, `POST /threads/{id}/runs/stream`, `POST /threads/{id}/runs/wait`, `GET /threads/{id}/state`, `POST /threads/{id}/resume`. Conversation state survives process restart; verified by `test/runtime/run-agent-protocol.test.ts`. `MemorySaver` removed from `@dawn-ai/langchain`; caller must inject checkpointer. +``` + +- [ ] **Step 2: Push branch + open PR** + +```bash +git push -u origin HEAD +gh pr create --title "feat: phase3 sub-project 7 — agent protocol + sqlite storage" --body "$(cat <<'EOF' +## Summary +- New `@dawn-ai/sqlite-storage` package: Dawn-native `BaseCheckpointSaver` + threads store on `node:sqlite` (no native deps). +- HTTP layer rewritten to AP shape (`/threads`, `/threads/{id}/runs/stream`, `/state`, `/resume`). +- `MemorySaver` removed from `@dawn-ai/langchain`; checkpointer is now pluggable via `dawn.config.ts`. +- Conversation state survives server restart (verified by new integration test). + +## Test plan +- [ ] Unit tests for checkpointer + threads store + migrations +- [ ] Integration test (`run-agent-protocol.test.ts`) persistence-across-restart +- [ ] Resume regression under new `/threads/{id}/resume` URL +- [ ] Chrome MCP smoke against chat example: send two messages in same thread, restart server, verify context + +Spec: `docs/superpowers/specs/2026-05-22-phase3-agent-protocol-design.md` +Plan: `docs/superpowers/plans/2026-05-22-phase3-agent-protocol.md` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 3: Update memory file with PR number once opened** + +Replace `PR #NNN` with the actual number. + +- [ ] **Step 4: Final commit** + +```bash +git add docs/superpowers/plans/2026-05-22-phase3-agent-protocol.md +git commit -m "docs: phase3 sub-project 7 implementation plan" +git push +``` + +--- + +## Self-Review Notes + +**Spec coverage check:** +- AP endpoint surface (spec §"Endpoint surface") → Tasks 11-12 +- Request/response shapes (spec §"Request/response shapes") → Tasks 11-12 handlers +- SQLite checkpointer (spec §"File structure" → sqlite-storage package) → Tasks 1-5, 7 +- Threads store (spec §"File structure" → threads/) → Task 6 +- `DawnConfig` extension (spec §"Updates to existing packages") → Task 8 +- `agent-adapter` rewiring (spec §"Updates to existing packages") → Task 9 +- `execute-route` defaults (spec §"Updates to existing packages") → Task 10 +- Chat-example proxy update (spec §"Updates to existing packages") → Task 13 +- Verification harness packing (spec §"Verification harness packing") → Task 14 +- Integration test for restart persistence (spec §"Testing strategy") → Task 15 +- Threads-store unit tests (spec §"Testing strategy") → Task 6 +- Checkpointer contract tests (spec §"Testing strategy") → Task 5 +- Migration tests (spec §"Testing strategy") → Task 3 +- Resume regression (spec §"Testing strategy") → covered in Task 13 manual smoke + Task 15 by extension; if needed add packed automation later + +**Out-of-scope items intentionally not implemented:** Assistants resource, cron, multi-tenant auth, Postgres backend, websockets, migration tooling for in-memory threads. + +**Type consistency check:** +- `sqliteCheckpointer({path})` factory name used identically in Tasks 5, 8, 10 +- `createThreadsStore({path})` used identically in Tasks 6, 8, 10 +- `ThreadsStore` interface name consistent throughout +- `DawnSqliteSaver` class name consistent +- `BaseCheckpointSaver` import from `@langchain/langgraph-checkpoint` consistent +- HTTP route URL paths match between server (Tasks 11-12) and client proxies (Task 13) +- `pendingByThread` map name from sub-project 4.5 preserved in Task 12 diff --git a/docs/superpowers/specs/2026-05-22-phase3-agent-protocol-design.md b/docs/superpowers/specs/2026-05-22-phase3-agent-protocol-design.md new file mode 100644 index 00000000..f32cc0e5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-phase3-agent-protocol-design.md @@ -0,0 +1,229 @@ +# Phase 3 — Sub-project 7: Agent Protocol HTTP endpoints + Dawn-native SQLite checkpointer + +**Status:** Design approved, ready for implementation plan +**Date:** 2026-05-22 +**Phase:** 3 (Opinionated Agent Harness) +**Depends on:** Sub-projects 1–4.5 (planning, agents-md, skills, capability state mutation, subagents, workspace, permissions) + +## Goal + +Replace Dawn's ad-hoc `POST /runs/stream` surface with a minimal-viable subset of LangGraph's Agent Protocol (AP), backed by a Dawn-native SQLite checkpointer and a SQLite thread-metadata store. Conversation state survives process restart; thread lifecycle is explicit; the HTTP shape is interoperable with AP clients. + +## Why now + +- Sub-project 4.5 wired interrupt/resume via process-local `MemorySaver`. Resume works but state vanishes on restart — unacceptable for the upcoming subagents-as-async-tasks work (sub-project 7's downstream). +- Async subagents (deferred from sub-project 3) require a thread-keyed HTTP surface and durable checkpoints to dispatch and poll. +- AP-compatible HTTP makes Dawn routes consumable from langgraph-sdk clients and the LangGraph Studio UI without a custom adapter. + +## Non-goals + +- Assistants resource (`POST /assistants`, etc.) — Dawn routes are the assistants; no registry needed. +- Cron / scheduled runs. +- Multi-tenant auth on the HTTP surface. +- Postgres checkpointer (pluggable interface makes this a follow-on). +- Streaming protocols other than SSE. +- Migration tooling for existing in-memory threads. +- Wrapping `@langchain/langgraph-checkpoint-sqlite`. Dawn ships its own. + +## Architecture + +Three layers: + +1. **HTTP surface** (`packages/cli/src/lib/dev/runtime-server.ts`): native `node:http` server exposes AP-shaped routes. Replaces existing `/runs/stream` block. +2. **Storage** (`packages/sqlite-storage/`): new package providing `sqliteCheckpointer` (a `BaseCheckpointSaver` subclass) and `createThreadsStore` (thread CRUD). Driver is `node:sqlite` (Node 22+ built-in, no native deps). +3. **Wiring** (`packages/cli/src/lib/runtime/execute-route.ts`, `packages/langchain/src/agent-adapter.ts`): default checkpointer + threads store instantiated from `dawn.config.ts`; both pluggable. + +## Endpoint surface + +All routes namespaced under the Dawn dev server root. + +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/threads` | Create thread. Body: `{metadata?}`. Returns `{thread_id, created_at, metadata, status}`. | +| `GET` | `/threads/{thread_id}` | Fetch thread metadata. 404 if unknown. | +| `DELETE` | `/threads/{thread_id}` | Delete thread + its checkpoints. | +| `POST` | `/threads/{thread_id}/runs/stream` | Start a run; stream SSE events (existing `/runs/stream` semantics, now thread-keyed). Body: `{input, route, config?}`. | +| `POST` | `/threads/{thread_id}/runs/wait` | Start a run; block until done; return final state. Body same as stream. | +| `GET` | `/threads/{thread_id}/state` | Return latest checkpoint as `{values, next, config, metadata, created_at, parent_config}`. | +| `POST` | `/threads/{thread_id}/resume` | Resume an interrupted run. Body: `{interruptId, decision}`. Replaces sub-project 4.5's `/api/permission-resume` proxy target. | + +SSE event shape on `/runs/stream` is unchanged from current Dawn (preserves `event: interrupt` + capability-emitted envelopes). + +## Request/response shapes + +**Create thread** +```http +POST /threads +Content-Type: application/json + +{"metadata": {"user": "brian"}} +``` +```json +{ + "thread_id": "t-7f3c2a1b", + "created_at": "2026-05-22T14:03:11.412Z", + "updated_at": "2026-05-22T14:03:11.412Z", + "metadata": {"user": "brian"}, + "status": "idle" +} +``` + +**Stream run** +```http +POST /threads/t-7f3c2a1b/runs/stream +Content-Type: application/json + +{"input": {"messages": [{"role": "user", "content": "hi"}]}, "route": "chat"} +``` +Returns `text/event-stream` (unchanged shape). + +**State** +```json +{ + "values": { "messages": [...] }, + "next": [], + "config": {"configurable": {"thread_id": "t-7f3c2a1b", "checkpoint_id": "1ef..."}}, + "metadata": {"source": "loop", "step": 4}, + "created_at": "2026-05-22T14:03:14.901Z", + "parent_config": {"configurable": {"checkpoint_id": "1ee..."}} +} +``` + +**Resume** (sub-project 4.5 contract preserved, moved under thread path): +```json +{"interruptId": "perm-9a2", "decision": "once"} +``` + +Error responses are `{"error": "", "code": ""}` with appropriate HTTP status. + +## File structure + +**New package: `@dawn-ai/sqlite-storage`** + +``` +packages/sqlite-storage/ + package.json # peer dep: @langchain/langgraph-checkpoint (for BaseCheckpointSaver) + src/ + index.ts # re-exports sqliteCheckpointer + createThreadsStore + types + checkpointer/ + index.ts # sqliteCheckpointer({path}) factory + saver.ts # DawnSqliteSaver extends BaseCheckpointSaver + schema.ts # CREATE TABLE statements + serde.ts # checkpoint <-> Uint8Array via existing langgraph serde + threads/ + index.ts # createThreadsStore({path}) factory + store.ts # CRUD impl + schema.ts + internal/ + db.ts # shared DatabaseSync open + pragmas (WAL, foreign_keys=ON) + migrate.ts # shared schema_version runner +``` + +**Checkpointer schema** (`checkpoints.sqlite`): +```sql +CREATE TABLE IF NOT EXISTS checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + parent_checkpoint_id TEXT, + type TEXT, + checkpoint BLOB NOT NULL, + metadata BLOB NOT NULL, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) +); +CREATE INDEX IF NOT EXISTS idx_checkpoints_thread ON checkpoints(thread_id, checkpoint_ns); + +CREATE TABLE IF NOT EXISTS writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + value BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) +); + +CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY); +``` + +Mirrors LangGraph's canonical shape so `DawnSqliteSaver` is a thin adapter over the four `BaseCheckpointSaver` methods (`getTuple`, `list`, `put`, `putWrites`). + +**Threads schema** (`threads.sqlite`): +```sql +CREATE TABLE IF NOT EXISTS threads ( + thread_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'idle' +); +CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC); + +CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY); +``` + +**Updates to existing packages** + +- `packages/core/src/types.ts` — add to `DawnConfig`: + ```ts + readonly checkpointer?: BaseCheckpointSaver + readonly threadsStore?: ThreadsStore + ``` +- `packages/cli/src/lib/dev/runtime-server.ts` — remove the current single `POST /runs/stream` block; add AP routes above. Permissions resume endpoint relocates to `/threads/:thread_id/resume`. +- `packages/cli/src/lib/runtime/execute-route.ts` — instantiate defaults when config omits them: + ```ts + const checkpointer = config.checkpointer + ?? sqliteCheckpointer({ path: join(appRoot, ".dawn/checkpoints.sqlite") }) + const threadsStore = config.threadsStore + ?? createThreadsStore({ path: join(appRoot, ".dawn/threads.sqlite") }) + ``` +- `packages/langchain/src/agent-adapter.ts` — accept `checkpointer` from caller instead of constructing `MemorySaver` internally. +- `examples/chat/web/app/api/permission-resume/route.ts` — proxy target updated to `/threads/{thread_id}/resume`. + +**On-disk layout** + +``` +/.dawn/ + checkpoints.sqlite # LangGraph checkpoint hot path + threads.sqlite # thread metadata + permissions.json # unchanged from sub-project 4.5 +``` + +`.dawn/` is auto-gitignored (permissions capability already does this; the check is idempotent). + +## Testing strategy + +- **Unit (`packages/sqlite-storage/`):** vitest against `:memory:` DB. + - `BaseCheckpointSaver` contract: put → getTuple round-trip, list pagination + ordering, putWrites idempotence, parent-chain traversal. + - Threads-store CRUD. + - Migration: open v0 schema, run migrator, assert v1. +- **Integration (`test/runtime/run-agent-protocol.test.ts`):** packs `@dawn-ai/sqlite-storage` + cli + langchain. Exercises full HTTP shape: create thread → stream run → assert SSE → GET state → restart server → fetch state again → assert messages persist. +- **Smoke (`test/smoke/`):** extend existing smoke to issue two `runs/stream` calls against the same `thread_id` and confirm conversation memory survives via AP, not in-process state. +- **Resume regression:** packed test that uses the new `/threads/{id}/resume` endpoint URL to validate the sub-project 4.5 contract still works under the new path. + +## Verification harness packing + +Add `@dawn-ai/sqlite-storage` to every test that packs Dawn packages: +- `test/generated/run-generated-app.test.ts` +- `test/generated/harness.ts` +- `test/generated/cli-testing-export.test.ts` +- `test/runtime/run-runtime-contract.test.ts` +- `test/smoke/run-smoke.test.ts` +- `packages/create-dawn-app/src/index.ts` (internal-mode replacement + override entry) + +## Open questions + +None at design close. All decisions resolved: +- Driver: `node:sqlite` direct (no shim). +- Package boundary: one combined `@dawn-ai/sqlite-storage`. +- Storage: one SQLite per concern (checkpoints + threads); permissions stays JSON. +- Backward compat: full migration; no preservation of legacy `POST /runs/stream`. + +## References + +- LangGraph Agent Protocol: https://langchain-ai.github.io/langgraph/cloud/reference/api/api_ref.html +- `BaseCheckpointSaver`: `@langchain/langgraph-checkpoint` +- Node SQLite: https://nodejs.org/api/sqlite.html +- Sub-project 4.5 design: `docs/superpowers/specs/2026-05-21-phase3-permissions-design.md` diff --git a/examples/chat/web/app/api/chat/route.ts b/examples/chat/web/app/api/chat/route.ts index 9aff0eed..dd885fc6 100644 --- a/examples/chat/web/app/api/chat/route.ts +++ b/examples/chat/web/app/api/chat/route.ts @@ -13,29 +13,21 @@ export async function POST(req: NextRequest): Promise { // Route picker: default to /chat for back-compat. /coordinator demonstrates // the subagents capability with research + summarizer specialists. + // The route field must be the mode-qualified assistant_id (e.g. "/chat#agent"). const routeId = body.route === "coordinator" ? "/coordinator" : "/chat" - const routePath = - routeId === "/coordinator" ? "src/app/coordinator/index.ts" : "src/app/chat/index.ts" + const route = `${routeId}#agent` - const upstream = await fetch(`${serverUrl}/runs/stream`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - assistant_id: `${routeId}#agent`, - input: { - messages: [{ role: "user", content: body.message }], - }, - metadata: { - dawn: { - mode: "agent", - route_id: routeId, - route_path: routePath, - thread_id: body.threadId, - }, - }, - on_completion: "delete", - }), - }) + const upstream = await fetch( + `${serverUrl}/threads/${encodeURIComponent(body.threadId)}/runs/stream`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: { messages: [{ role: "user", content: body.message }] }, + route, + }), + }, + ) if (!upstream.ok || !upstream.body) { return new Response(`Upstream error: ${upstream.status}`, { status: 502 }) diff --git a/packages/cli/package.json b/packages/cli/package.json index 99b8a51a..7a6bf053 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,6 +47,7 @@ "@dawn-ai/langchain": "workspace:*", "@dawn-ai/langgraph": "workspace:*", "@dawn-ai/permissions": "workspace:*", + "@dawn-ai/sqlite-storage": "workspace:*", "commander": "14.0.3", "tsx": "^4.8.1" }, @@ -54,7 +55,8 @@ "@dawn-ai/config-typescript": "workspace:*", "@dawn-ai/sdk": "workspace:*", "@dawn-ai/workspace": "workspace:*", - "@langchain/core": "1.1.46", + "@langchain/core": "1.1.47", + "@langchain/langgraph-checkpoint": "^1.0.2", "@types/node": "25.6.0" } } diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index de10bb02..c503dd03 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -1,7 +1,14 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http" import type { AddressInfo } from "node:net" import type { DawnMiddleware, MiddlewareRequest } from "@dawn-ai/sdk" -import { executeResolvedRoute, streamResolvedRoute } from "../runtime/execute-route.js" +import type { Thread, ThreadsStore } from "@dawn-ai/sqlite-storage" +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" +import { + invokeResolvedRoute, + resolveCheckpointer, + resolveThreadsStore, + streamResolvedRoute, +} from "../runtime/execute-route.js" import { clearPending, getPending } from "../runtime/pending-interrupts.js" import { type StreamChunk, toSseEvent } from "../runtime/stream-types.js" import { loadMiddleware, runMiddleware } from "./middleware.js" @@ -18,11 +25,34 @@ export interface StartRuntimeServerOptions { readonly port?: number } +// --------------------------------------------------------------------------- +// Route-table types +// --------------------------------------------------------------------------- + +type RouteHandler = ( + req: IncomingMessage, + res: ServerResponse, + params: Record, +) => Promise + +interface RouteMatcher { + readonly method: string + readonly pattern: RegExp + readonly handle: RouteHandler +} + +// --------------------------------------------------------------------------- +// Server factory +// --------------------------------------------------------------------------- + export async function startRuntimeServer( options: StartRuntimeServerOptions, ): Promise { const registry = await createRuntimeRegistry(options.appRoot) const middleware = await loadMiddleware(options.appRoot) + const threadsStore = await resolveThreadsStore(options.appRoot) + const checkpointer = await resolveCheckpointer(options.appRoot) + const state = { acceptingRequests: true, activeRequests: 0, @@ -30,6 +60,15 @@ export async function startRuntimeServer( } const shutdownController = new AbortController() + const routes = buildRouteTable({ + appRoot: options.appRoot, + checkpointer, + middleware, + registry, + signal: shutdownController.signal, + threadsStore, + }) + const server = createServer(async (request, response) => { if (!state.acceptingRequests) { sendJson(response, 503, createRequestErrorBody("Server is shutting down")) @@ -38,13 +77,7 @@ export async function startRuntimeServer( state.activeRequests++ try { - await handleRequest({ - middleware, - registry, - request, - response, - signal: shutdownController.signal, - }) + await dispatch(routes, request, response, shutdownController.signal) } catch (error) { if (shutdownController.signal.aborted) { sendJson( @@ -108,83 +141,360 @@ export async function startRuntimeServer( } } -async function handleRequest(options: { +// --------------------------------------------------------------------------- +// Route table builder +// --------------------------------------------------------------------------- + +function buildRouteTable(ctx: { + readonly appRoot: string + readonly checkpointer: BaseCheckpointSaver + readonly middleware: DawnMiddleware | undefined + readonly registry: RuntimeRegistry + readonly signal: AbortSignal + readonly threadsStore: ThreadsStore +}): RouteMatcher[] { + const { appRoot, checkpointer, middleware, registry, signal, threadsStore } = ctx + + return [ + // ------------------------------------------------------------------ + // GET /healthz + // ------------------------------------------------------------------ + { + handle: async (_req, res) => { + sendJson(res, 200, { status: "ready" }) + }, + method: "GET", + pattern: /^\/healthz(?:\?.*)?$/, + }, + + // ------------------------------------------------------------------ + // POST /threads — create a new thread + // ------------------------------------------------------------------ + { + handle: async (req, res) => { + const rawBody = await readRequestBody(req) + let metadata: Record | undefined + if (rawBody.trim()) { + const parsed = parseJson(rawBody) + if (!parsed.ok || !isRecord(parsed.value)) { + sendJson(res, 400, createRequestErrorBody("Malformed request body")) + return + } + const bodyMetadata = (parsed.value as Record).metadata + if (bodyMetadata !== undefined) { + if (!isRecord(bodyMetadata)) { + sendJson(res, 400, createRequestErrorBody("metadata must be an object")) + return + } + metadata = bodyMetadata + } + } + const thread = await threadsStore.createThread(metadata !== undefined ? { metadata } : {}) + sendJson(res, 200, thread) + }, + method: "POST", + pattern: /^\/threads(?:\?.*)?$/, + }, + + // ------------------------------------------------------------------ + // GET /threads/:thread_id — fetch a thread + // ------------------------------------------------------------------ + { + handle: async (_req, res, params) => { + const thread = await threadsStore.getThread(params.thread_id ?? "") + if (!thread) { + sendJson(res, 404, createRequestErrorBody("Thread not found")) + return + } + sendJson(res, 200, thread) + }, + method: "GET", + pattern: /^\/threads\/(?[^/?#]+)(?:\?.*)?$/, + }, + + // ------------------------------------------------------------------ + // DELETE /threads/:thread_id — delete thread + checkpoints + // ------------------------------------------------------------------ + { + handle: async (_req, res, params) => { + const threadId = params.thread_id ?? "" + await threadsStore.deleteThread(threadId) + // Best-effort: delete checkpoints if the saver supports it. + if ( + typeof (checkpointer as unknown as { deleteThread?: unknown }).deleteThread === "function" + ) { + await ( + checkpointer as unknown as { deleteThread(id: string): Promise } + ).deleteThread(threadId) + } + res.writeHead(204) + res.end() + }, + method: "DELETE", + pattern: /^\/threads\/(?[^/?#]+)(?:\?.*)?$/, + }, + + // ------------------------------------------------------------------ + // POST /threads/:thread_id/runs/stream — stream SSE + // ------------------------------------------------------------------ + { + handle: async (req, res, params) => { + await handleApStreamRequest({ + appRoot, + middleware, + registry, + request: req, + response: res, + signal, + threadId: params.thread_id ?? "", + threadsStore, + }) + }, + method: "POST", + pattern: /^\/threads\/(?[^/?#]+)\/runs\/stream(?:\?.*)?$/, + }, + + // ------------------------------------------------------------------ + // POST /threads/:thread_id/runs/wait — block and return final state + // ------------------------------------------------------------------ + { + handle: async (req, res, params) => { + await handleApWaitRequest({ + appRoot, + middleware, + registry, + request: req, + response: res, + signal, + threadId: params.thread_id ?? "", + threadsStore, + }) + }, + method: "POST", + pattern: /^\/threads\/(?[^/?#]+)\/runs\/wait(?:\?.*)?$/, + }, + + // ------------------------------------------------------------------ + // GET /threads/:thread_id/state — latest checkpoint state + // ------------------------------------------------------------------ + { + handle: async (_req, res, params) => { + const threadId = params.thread_id ?? "" + const tuple = await checkpointer.getTuple({ + configurable: { thread_id: threadId, checkpoint_ns: "" }, + }) + if (!tuple) { + sendJson(res, 404, createRequestErrorBody("No checkpoint found for thread")) + return + } + const apState = { + config: tuple.config, + created_at: new Date().toISOString(), + metadata: tuple.metadata, + next: tuple.pendingWrites?.map(([, channel]) => channel) ?? [], + parent_config: tuple.parentConfig ?? null, + values: tuple.checkpoint.channel_values ?? {}, + } + sendJson(res, 200, apState) + }, + method: "GET", + pattern: /^\/threads\/(?[^/?#]+)\/state(?:\?.*)?$/, + }, + + // ------------------------------------------------------------------ + // POST /threads/:thread_id/resume — resolve a parked interrupt + // ------------------------------------------------------------------ + { + handle: async (req, res, params) => { + await handleResumeRequest({ + request: req, + response: res, + threadId: params.thread_id ?? "", + }) + }, + method: "POST", + pattern: /^\/threads\/(?[^/?#]+)\/resume(?:\?.*)?$/, + }, + ] +} + +// --------------------------------------------------------------------------- +// Dispatcher +// --------------------------------------------------------------------------- + +async function dispatch( + routes: RouteMatcher[], + request: IncomingMessage, + response: ServerResponse, + _signal: AbortSignal, +): Promise { + const method = request.method ?? "" + const url = request.url ?? "/" + + for (const route of routes) { + if (route.method !== method) continue + const match = route.pattern.exec(url) + if (!match) continue + + // Collect named capture groups as params + const params: Record = {} + if (match.groups) { + for (const [key, value] of Object.entries(match.groups)) { + if (value !== undefined) { + params[key] = decodeURIComponent(value) + } + } + } + + await route.handle(request, response, params) + return + } + + sendJson(response, 404, createRequestErrorBody("Not found")) +} + +// --------------------------------------------------------------------------- +// AP stream handler +// --------------------------------------------------------------------------- + +async function handleApStreamRequest(options: { + readonly appRoot: string readonly middleware: DawnMiddleware | undefined readonly registry: RuntimeRegistry readonly request: IncomingMessage readonly response: ServerResponse readonly signal: AbortSignal + readonly threadId: string + readonly threadsStore: ThreadsStore }): Promise { - const { middleware, request, response, registry, signal } = options + const { appRoot, middleware, registry, request, response, signal, threadId, threadsStore } = + options - if (request.method === "GET" && request.url === "/healthz") { - sendJson(response, 200, { status: "ready" }) + const rawBody = await readRequestBody(request) + const parsedBody = parseJson(rawBody) + if (!parsedBody.ok || !isRecord(parsedBody.value)) { + sendJson(response, 400, createRequestErrorBody("Malformed request body")) return } - if (request.method === "POST" && request.url === "/runs/stream") { - await handleStreamRequest({ - middleware, - registry, - request, - response, - signal, - }) + const body = parsedBody.value + const validated = validateApRunBody(body) + if (!validated.ok) { + sendJson(response, 400, createRequestErrorBody(validated.message)) return } - const resumeMatch = - request.method === "POST" && request.url - ? /^\/threads\/([^/?#]+)\/resume(?:\?.*)?$/.exec(request.url) - : null - if (resumeMatch) { - await handleResumeRequest({ - request, - response, - threadId: decodeURIComponent(resumeMatch[1] ?? ""), - }) + const { input, routeKey } = validated + + const route = registry.lookup(routeKey) + if (!route) { + sendJson(response, 404, createRequestErrorBody(`Unknown route: ${routeKey}`)) return } - if (request.method !== "POST" || request.url !== "/runs/wait") { - sendJson(response, 404, createRequestErrorBody("Not found")) + // Run middleware + const mwRequest: MiddlewareRequest = { + assistantId: route.assistantId, + headers: parseHeaders(request), + method: request.method ?? "POST", + params: extractRouteParams(route.routeId, input), + routeId: route.routeId, + url: request.url ?? `/threads/${threadId}/runs/stream`, + } + const mwResult = await runMiddleware(middleware, mwRequest) + if (mwResult.action === "reject") { + sendJson(response, mwResult.status, mwResult.body) return } + // Idempotently ensure the thread exists + let thread: Thread | undefined = await threadsStore.getThread(threadId) + if (!thread) { + thread = await threadsStore.createThread({ thread_id: threadId }) + } + + // Mark thread busy + await threadsStore.updateStatus(threadId, "busy") + + response.writeHead(200, { + "cache-control": "no-cache", + connection: "keep-alive", + "content-type": "text/event-stream", + }) + + try { + for await (const chunk of streamResolvedRoute({ + appRoot, + input, + ...(mwResult.context ? { middlewareContext: mwResult.context } : {}), + routeFile: route.routeFile, + routeId: route.routeId, + routePath: route.routePath, + signal, + threadId, + })) { + response.write(toSseEvent(chunk)) + } + await threadsStore.updateStatus(threadId, "idle") + } catch (error) { + const errorChunk: StreamChunk = { + output: { error: error instanceof Error ? error.message : String(error) }, + type: "done", + } + response.write(toSseEvent(errorChunk)) + await threadsStore.updateStatus(threadId, "idle").catch(() => undefined) + } + + response.end() +} + +// --------------------------------------------------------------------------- +// AP wait handler +// --------------------------------------------------------------------------- + +async function handleApWaitRequest(options: { + readonly appRoot: string + readonly middleware: DawnMiddleware | undefined + readonly registry: RuntimeRegistry + readonly request: IncomingMessage + readonly response: ServerResponse + readonly signal: AbortSignal + readonly threadId: string + readonly threadsStore: ThreadsStore +}): Promise { + const { appRoot, middleware, registry, request, response, signal, threadId, threadsStore } = + options + const rawBody = await readRequestBody(request) const parsedBody = parseJson(rawBody) - - if (!parsedBody.ok) { + if (!parsedBody.ok || !isRecord(parsedBody.value)) { sendJson(response, 400, createRequestErrorBody("Malformed request body")) return } - const validatedBody = validateRunsWaitRequest(parsedBody.value) - - if (!validatedBody.ok) { - sendJson(response, 400, createRequestErrorBody(validatedBody.message, validatedBody.details)) + const body = parsedBody.value + const validated = validateApRunBody(body) + if (!validated.ok) { + sendJson(response, 400, createRequestErrorBody(validated.message)) return } - const route = registry.lookup(validatedBody.value.assistant_id) + const { input, routeKey } = validated + const route = registry.lookup(routeKey) if (!route) { - sendJson( - response, - 404, - createRequestErrorBody(`Unknown assistant_id: ${validatedBody.value.assistant_id}`), - ) + sendJson(response, 404, createRequestErrorBody(`Unknown route: ${routeKey}`)) return } - // Run middleware before execution + // Run middleware const mwRequest: MiddlewareRequest = { assistantId: route.assistantId, headers: parseHeaders(request), method: request.method ?? "POST", - params: extractRouteParams(route.routeId, validatedBody.value.input), + params: extractRouteParams(route.routeId, input), routeId: route.routeId, - url: request.url ?? "/runs/wait", + url: request.url ?? `/threads/${threadId}/runs/wait`, } const mwResult = await runMiddleware(middleware, mwRequest) if (mwResult.action === "reject") { @@ -192,45 +502,35 @@ async function handleRequest(options: { return } - if ( - validatedBody.value.metadata.dawn.route_id !== route.routeId || - validatedBody.value.metadata.dawn.route_path !== route.routePath || - validatedBody.value.metadata.dawn.mode !== route.mode || - validatedBody.value.assistant_id !== route.assistantId - ) { - sendJson( - response, - 400, - createRequestErrorBody("Request metadata does not match the registered route", { - assistant_id: validatedBody.value.assistant_id, - expected: { - assistant_id: route.assistantId, - mode: route.mode, - route_id: route.routeId, - route_path: route.routePath, - }, - received: validatedBody.value.metadata.dawn, - }), - ) - return + // Idempotently ensure the thread exists + let thread: Thread | undefined = await threadsStore.getThread(threadId) + if (!thread) { + thread = await threadsStore.createThread({ thread_id: threadId }) } - const resultPromise = executeResolvedRoute({ - appRoot: registry.appRoot, - input: validatedBody.value.input, + await threadsStore.updateStatus(threadId, "busy") + + const resultPromise = invokeResolvedRoute({ + appRoot, + input, ...(mwResult.context ? { middlewareContext: mwResult.context } : {}), - signal, routeFile: route.routeFile, routeId: route.routeId, routePath: route.routePath, + signal, + threadId, }) + const result = await raceRequestAgainstShutdown(resultPromise, signal) if (result === SHUTDOWN_ABORTED) { + await threadsStore.updateStatus(threadId, "idle").catch(() => undefined) sendJson(response, 503, createRequestErrorBody("Request canceled during server shutdown")) return } + await threadsStore.updateStatus(threadId, "idle").catch(() => undefined) + if (result.status === "failed") { if (signal.aborted) { sendJson( @@ -261,94 +561,9 @@ async function handleRequest(options: { sendJson(response, 200, result.output) } -async function handleStreamRequest(options: { - readonly middleware: DawnMiddleware | undefined - readonly registry: RuntimeRegistry - readonly request: IncomingMessage - readonly response: ServerResponse - readonly signal: AbortSignal -}): Promise { - const { middleware, request, response, registry, signal } = options - - const rawBody = await readRequestBody(request) - const parsedBody = parseJson(rawBody) - - if (!parsedBody.ok) { - sendJson(response, 400, createRequestErrorBody("Malformed request body")) - return - } - - const validatedBody = validateRunsWaitRequest(parsedBody.value) - - if (!validatedBody.ok) { - sendJson(response, 400, createRequestErrorBody(validatedBody.message, validatedBody.details)) - return - } - - const route = registry.lookup(validatedBody.value.assistant_id) - - if (!route) { - sendJson( - response, - 404, - createRequestErrorBody(`Unknown assistant_id: ${validatedBody.value.assistant_id}`), - ) - return - } - - // Run middleware before streaming - const mwRequest: MiddlewareRequest = { - assistantId: route.assistantId, - headers: parseHeaders(request), - method: request.method ?? "POST", - params: extractRouteParams(route.routeId, validatedBody.value.input), - routeId: route.routeId, - url: request.url ?? "/runs/stream", - } - const mwResult = await runMiddleware(middleware, mwRequest) - if (mwResult.action === "reject") { - sendJson(response, mwResult.status, mwResult.body) - return - } - - response.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }) - - try { - // The web client sends a stable per-conversation `thread_id` in - // `metadata.dawn.thread_id` (see examples/chat/web/app/api/chat/route.ts). - // We forward it so the agent-adapter can park interrupts in the - // checkpointer and the resume endpoint can replay them. - const threadId = - typeof validatedBody.value.metadata.dawn.thread_id === "string" - ? validatedBody.value.metadata.dawn.thread_id - : undefined - - for await (const chunk of streamResolvedRoute({ - appRoot: registry.appRoot, - input: validatedBody.value.input, - ...(mwResult.context ? { middlewareContext: mwResult.context } : {}), - signal, - routeFile: route.routeFile, - routeId: route.routeId, - routePath: route.routePath, - ...(threadId ? { threadId } : {}), - })) { - response.write(toSseEvent(chunk)) - } - } catch (error) { - const errorChunk: StreamChunk = { - type: "done", - output: { error: error instanceof Error ? error.message : String(error) }, - } - response.write(toSseEvent(errorChunk)) - } - - response.end() -} +// --------------------------------------------------------------------------- +// Resume handler (moved from /api/permission-resume → /threads/:id/resume) +// --------------------------------------------------------------------------- async function handleResumeRequest(options: { readonly request: IncomingMessage @@ -396,6 +611,36 @@ async function handleResumeRequest(options: { sendJson(response, 200, { ok: true }) } +// --------------------------------------------------------------------------- +// AP run body validation +// --------------------------------------------------------------------------- + +interface ApRunBody { + readonly input: unknown + readonly routeKey: string +} + +function validateApRunBody( + body: Record, +): ({ readonly ok: true } & ApRunBody) | { readonly ok: false; readonly message: string } { + // `route` must be a string identifying the assistant/route + if (typeof body.route !== "string") { + return { + message: "Request body must include route as a string (assistant_id or route_id)", + ok: false, + } + } + return { + input: Object.hasOwn(body, "input") ? body.input : {}, + ok: true, + routeKey: body.route, + } +} + +// --------------------------------------------------------------------------- +// Shared utilities +// --------------------------------------------------------------------------- + const SHUTDOWN_ABORTED = Symbol("shutdown-aborted") async function raceRequestAgainstShutdown( @@ -425,80 +670,6 @@ async function raceRequestAgainstShutdown( return result } -function validateRunsWaitRequest(value: unknown): - | { readonly ok: true; readonly value: RunsWaitRequest } - | { - readonly details?: Record - readonly message: string - readonly ok: false - } { - if (!isRecord(value)) { - return { message: "Request body must be an object", ok: false } - } - - if (typeof value.assistant_id !== "string") { - return { - message: "Request body must include assistant_id as a string", - ok: false, - } - } - - if (!isRecord(value.metadata) || !isRecord(value.metadata.dawn)) { - return { message: "Request body must include metadata.dawn", ok: false } - } - - if (typeof value.metadata.dawn.mode !== "string") { - return { - message: "Request body must include metadata.dawn.mode as a string", - ok: false, - } - } - - if (typeof value.metadata.dawn.route_id !== "string") { - return { - message: "Request body must include metadata.dawn.route_id as a string", - ok: false, - } - } - - if (typeof value.metadata.dawn.route_path !== "string") { - return { - message: "Request body must include metadata.dawn.route_path as a string", - ok: false, - } - } - - if (!Object.hasOwn(value, "input")) { - return { message: "Request body must include input", ok: false } - } - - if (value.on_completion !== "delete") { - return { - message: "Request body must set on_completion to delete", - ok: false, - } - } - - return { - ok: true as const, - value: value as unknown as RunsWaitRequest, - } -} - -interface RunsWaitRequest { - readonly assistant_id: string - readonly input: unknown - readonly metadata: { - readonly dawn: { - readonly mode: "agent" | "chain" | "graph" | "workflow" - readonly route_id: string - readonly route_path: string - readonly thread_id?: string - } - } - readonly on_completion: "delete" -} - function parseJson( input: string, ): { readonly ok: true; readonly value: unknown } | { readonly ok: false } { diff --git a/packages/cli/src/lib/runtime/execute-route-server.ts b/packages/cli/src/lib/runtime/execute-route-server.ts index ce98d973..817a1e29 100644 --- a/packages/cli/src/lib/runtime/execute-route-server.ts +++ b/packages/cli/src/lib/runtime/execute-route-server.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto" + import { normalizeServerResult } from "./normalize-server-result.js" import { createRuntimeFailureResult, @@ -31,18 +33,12 @@ export async function executeRouteServer( }, timeoutMs) try { - const response = await fetch(createRunsWaitUrl(options.baseUrl), { + const assistantId = createRouteAssistantId(options.routeId, options.mode) + const threadId = `t-cli-${randomUUID().slice(0, 8)}` + const response = await fetch(createRunsWaitUrl(options.baseUrl, threadId), { body: JSON.stringify({ - assistant_id: createRouteAssistantId(options.routeId, options.mode), input: options.input, - metadata: { - dawn: { - mode: options.mode, - route_id: options.routeId, - route_path: options.routePath, - }, - }, - on_completion: "delete", + route: assistantId, }), headers: { "content-type": "application/json", @@ -86,12 +82,9 @@ export async function executeRouteServer( } } -function createRunsWaitUrl(baseUrl: string): URL { +function createRunsWaitUrl(baseUrl: string, threadId: string): URL { const url = new URL(baseUrl) - url.pathname = `${ensureTrailingSlash(url.pathname)}runs/wait` + const base = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname + url.pathname = `${base}/threads/${encodeURIComponent(threadId)}/runs/wait` return url } - -function ensureTrailingSlash(pathname: string): string { - return pathname.endsWith("/") ? pathname : `${pathname}/` -} diff --git a/packages/cli/src/lib/runtime/execute-route.ts b/packages/cli/src/lib/runtime/execute-route.ts index 90ff4276..64a2985a 100644 --- a/packages/cli/src/lib/runtime/execute-route.ts +++ b/packages/cli/src/lib/runtime/execute-route.ts @@ -26,7 +26,9 @@ import { type PermissionsStore, } from "@dawn-ai/permissions" import { type DawnAgent, isDawnAgent } from "@dawn-ai/sdk" +import { createThreadsStore, sqliteCheckpointer, type ThreadsStore } from "@dawn-ai/sqlite-storage" import type { ExecBackend, FilesystemBackend } from "@dawn-ai/workspace" +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" import { checkToolNameUniqueness } from "./check-tool-name-uniqueness.js" import { createDawnContext } from "./dawn-context.js" import { normalizeRouteModule } from "./load-route-kind.js" @@ -132,6 +134,69 @@ export async function executeResolvedRoute(options: { }) } +/** + * Resolves the ThreadsStore for the given appRoot. + * + * Uses `config.threadsStore` if the user's `dawn.config.ts` provides one; + * otherwise falls back to the default SQLite-backed store at + * `/.dawn/threads.sqlite`. Exported so the HTTP server layer (T11+) + * can obtain the same store instance independently of route execution. + */ +export async function resolveThreadsStore(appRoot: string): Promise { + try { + const loaded = await loadDawnConfig({ appRoot }) + if (loaded.config.threadsStore) { + return loaded.config.threadsStore + } + } catch { + // No dawn.config.ts or unreadable — fall through to default. + } + return createThreadsStore({ path: join(appRoot, ".dawn/threads.sqlite") }) +} + +/** + * Resolves the checkpointer for the given appRoot. + * + * Uses `config.checkpointer` if the user's `dawn.config.ts` provides one; + * otherwise falls back to the default SQLite-backed saver at + * `/.dawn/checkpoints.sqlite`. Exported so the HTTP server layer + * (T11+) can obtain a checkpointer independently of route execution (e.g. + * for the GET /threads/:id/state endpoint). + */ +export async function resolveCheckpointer(appRoot: string): Promise { + try { + const loaded = await loadDawnConfig({ appRoot }) + if (loaded.config.checkpointer) { + return loaded.config.checkpointer + } + } catch { + // No dawn.config.ts or unreadable — fall through to default. + } + return sqliteCheckpointer({ path: join(appRoot, ".dawn/checkpoints.sqlite") }) +} + +/** + * Invoke a resolved route with a stable thread ID, returning the final + * execution result. Used by the AP `POST /threads/:id/runs/wait` endpoint. + * Behaves identically to `executeResolvedRoute` but forwards `threadId` to + * the agent-adapter so LangGraph parks state in the checkpointer. + */ +export async function invokeResolvedRoute(options: { + readonly appRoot: string + readonly input: unknown + readonly middlewareContext?: Readonly> + readonly routeFile: string + readonly routeId: string + readonly routePath: string + readonly signal?: AbortSignal + readonly threadId?: string +}): Promise { + return await executeRouteAtResolvedPath({ + ...options, + startedAt: Date.now(), + }) +} + export async function* streamResolvedRoute(options: { readonly appRoot: string readonly input: unknown @@ -156,8 +221,15 @@ export async function* streamResolvedRoute(options: { return } - const { normalized, tools, stateFields, promptFragments, streamTransformers, subagentResolver } = - prepared + const { + normalized, + tools, + stateFields, + promptFragments, + streamTransformers, + subagentResolver, + checkpointer, + } = prepared if (normalized.kind !== "agent") { // Non-agent routes don't support incremental streaming — execute and emit done @@ -174,6 +246,7 @@ export async function* streamResolvedRoute(options: { const routeParamNames = extractRouteParamNames(options.routeId) for await (const chunk of streamAgent({ + checkpointer, entry: normalized.entry, input: options.input, ...(options.middlewareContext ? { middlewareContext: options.middlewareContext } : {}), @@ -228,6 +301,8 @@ interface PreparedRoute { readonly entry: unknown } readonly ok: true + readonly checkpointer: BaseCheckpointSaver + readonly threadsStore: ThreadsStore readonly stateFields: readonly ResolvedStateField[] | undefined readonly tools: readonly DiscoveredToolDefinition[] readonly promptFragments?: ReadonlyArray> @@ -293,6 +368,38 @@ async function prepareRouteExecution(options: { let subagentResolver: SubagentResolver | undefined + // Load dawn.config.ts once — used for checkpointer, threadsStore, backends, + // and permissions. Falls back to defaults when the config is absent/unreadable. + let configBackends: + | { readonly filesystem?: FilesystemBackend; readonly exec?: ExecBackend } + | undefined + let permissionsConfig: + | { + readonly mode?: PermissionMode + readonly allow?: Readonly> + readonly deny?: Readonly> + } + | undefined + let configCheckpointer: BaseCheckpointSaver | undefined + let configThreadsStore: ThreadsStore | undefined + try { + const loaded = await loadDawnConfig({ appRoot: options.appRoot }) + configBackends = loaded.config.backends + permissionsConfig = loaded.config.permissions + configCheckpointer = loaded.config.checkpointer + configThreadsStore = loaded.config.threadsStore + } catch { + // No dawn.config.ts (or unreadable). Fall back to defaults for all fields. + } + + const checkpointer: BaseCheckpointSaver = + configCheckpointer ?? + sqliteCheckpointer({ path: join(options.appRoot, ".dawn/checkpoints.sqlite") }) + + const threadsStore: ThreadsStore = + configThreadsStore ?? + createThreadsStore({ path: join(options.appRoot, ".dawn/threads.sqlite") }) + if (normalized.kind === "agent") { const registry = createCapabilityRegistry([ createPlanningMarker(), @@ -312,26 +419,6 @@ async function prepareRouteExecution(options: { // invalidated in dev when the runtime rebuilds the manifest. const descriptorRouteMap = await getCachedDescriptorRouteMap(routeManifest) - let configBackends: - | { readonly filesystem?: FilesystemBackend; readonly exec?: ExecBackend } - | undefined - let permissionsConfig: - | { - readonly mode?: PermissionMode - readonly allow?: Readonly> - readonly deny?: Readonly> - } - | undefined - try { - const loaded = await loadDawnConfig({ appRoot: options.appRoot }) - configBackends = loaded.config.backends - permissionsConfig = loaded.config.permissions - } catch { - // No dawn.config.ts (or unreadable). The workspace capability falls - // back to its defaults (localFilesystem + localExec); permissions - // defaults to "interactive" with empty allow/deny. - } - const envMode = process.env.DAWN_PERMISSIONS_MODE const mode: PermissionMode = envMode === "interactive" || envMode === "non-interactive" || envMode === "bypass" @@ -446,6 +533,8 @@ async function prepareRouteExecution(options: { return { normalized, ok: true, + checkpointer, + threadsStore, ...(promptFragments.length > 0 ? { promptFragments } : {}), stateFields, ...(streamTransformers.length > 0 ? { streamTransformers } : {}), @@ -463,6 +552,7 @@ async function executeRouteAtResolvedPath(options: { readonly routePath: string readonly signal?: AbortSignal readonly startedAt: number + readonly threadId?: string }): Promise { let mode: RuntimeExecutionMode | null = null @@ -489,6 +579,7 @@ async function executeRouteAtResolvedPath(options: { promptFragments, streamTransformers, subagentResolver, + checkpointer, } = prepared mode = normalized.kind @@ -499,6 +590,7 @@ async function executeRouteAtResolvedPath(options: { }) const output = await invokeEntry(normalized.kind, normalized.entry, options.input, context, { + checkpointer, ...(options.middlewareContext ? { middlewareContext: options.middlewareContext } : {}), routeId: options.routeId, ...(stateFields ? { stateFields } : {}), @@ -507,6 +599,7 @@ async function executeRouteAtResolvedPath(options: { ...(promptFragments && promptFragments.length > 0 ? { promptFragments } : {}), ...(streamTransformers && streamTransformers.length > 0 ? { streamTransformers } : {}), ...(subagentResolver ? { subagentResolver } : {}), + ...(options.threadId ? { threadId: options.threadId } : {}), }) return createRuntimeSuccessResult({ @@ -541,6 +634,7 @@ async function invokeEntry( input: unknown, context: unknown, agentContext?: { + readonly checkpointer?: BaseCheckpointSaver readonly middlewareContext?: Readonly> readonly routeId: string readonly signal?: AbortSignal @@ -562,11 +656,18 @@ async function invokeEntry( NonNullable[number] > readonly subagentResolver?: SubagentResolver + readonly threadId?: string }, ): Promise { if (kind === "agent") { + if (!agentContext?.checkpointer) { + throw new Error( + "[dawn] invokeEntry called for an agent route without a checkpointer. This is an internal bug — please report it.", + ) + } const routeParamNames = extractRouteParamNames(agentContext?.routeId ?? "") return await executeAgent({ + checkpointer: agentContext.checkpointer, entry, input, ...(agentContext?.middlewareContext @@ -585,6 +686,7 @@ async function invokeEntry( ...(agentContext?.subagentResolver ? { subagentResolver: agentContext.subagentResolver } : {}), + ...(agentContext?.threadId ? { threadId: agentContext.threadId } : {}), }) } diff --git a/packages/cli/test/check-command.test.ts b/packages/cli/test/check-command.test.ts index fc3554f3..45275eb4 100644 --- a/packages/cli/test/check-command.test.ts +++ b/packages/cli/test/check-command.test.ts @@ -86,6 +86,7 @@ async function executeCli(entryPath: string, args: readonly string[]) { readonly stderr: string }>((resolvePromise, rejectPromise) => { const child = spawn(entryPath, [...args], { + env: { ...process.env, NODE_NO_WARNINGS: "1" }, stdio: ["ignore", "pipe", "pipe"], }) diff --git a/packages/cli/test/dev-command.test.ts b/packages/cli/test/dev-command.test.ts index 8eacd05f..c8397138 100644 --- a/packages/cli/test/dev-command.test.ts +++ b/packages/cli/test/dev-command.test.ts @@ -45,18 +45,10 @@ describe("dawn dev runtime server", () => { const server = await startRuntimeServer({ appRoot }) servers.push(server) - const graphResponse = await fetch(new URL("/runs/wait", server.url), { + const graphResponse = await fetch(new URL("/threads/thread-test-graph/runs/wait", server.url), { body: JSON.stringify({ - assistant_id: "/support/[tenant]#graph", input: { tenant: "graph" }, - metadata: { - dawn: { - mode: "graph", - route_id: "/support/[tenant]", - route_path: "src/app/support/[tenant]/index.ts", - }, - }, - on_completion: "delete", + route: "/support/[tenant]#graph", }), headers: { "content-type": "application/json", @@ -68,7 +60,7 @@ describe("dawn dev runtime server", () => { expect(await graphResponse.json()).toMatchObject({ mode: "graph", tenant: "graph" }) }) - test("rejects metadata mismatches as non-execution request failures", async () => { + test("rejects unknown route as not found", async () => { const appRoot = await createFixtureApp({ "dawn.config.ts": "export default {};\n", "package.json": "{}\n", @@ -78,18 +70,10 @@ describe("dawn dev runtime server", () => { const server = await startRuntimeServer({ appRoot }) servers.push(server) - const response = await fetch(new URL("/runs/wait", server.url), { + const response = await fetch(new URL("/threads/t1/runs/wait", server.url), { body: JSON.stringify({ - assistant_id: "/support/[tenant]#graph", - input: { tenant: "graph" }, - metadata: { - dawn: { - mode: "workflow", - route_id: "/support/[tenant]", - route_path: "src/app/support/[tenant]/index.ts", - }, - }, - on_completion: "delete", + input: {}, + route: "/support/[tenant]#workflow", }), headers: { "content-type": "application/json", @@ -97,7 +81,7 @@ describe("dawn dev runtime server", () => { method: "POST", }) - expect(response.status).toBe(400) + expect(response.status).toBe(404) expect(await response.json()).toMatchObject({ error: { kind: "request_error", @@ -115,7 +99,7 @@ describe("dawn dev runtime server", () => { const server = await startRuntimeServer({ appRoot }) servers.push(server) - const malformedResponse = await fetch(new URL("/runs/wait", server.url), { + const malformedResponse = await fetch(new URL("/threads/t1/runs/wait", server.url), { body: "{not-json", headers: { "content-type": "application/json", @@ -123,18 +107,10 @@ describe("dawn dev runtime server", () => { method: "POST", }) - const unknownAssistantResponse = await fetch(new URL("/runs/wait", server.url), { + const unknownAssistantResponse = await fetch(new URL("/threads/t1/runs/wait", server.url), { body: JSON.stringify({ - assistant_id: "/support/[tenant]#workflow", input: { tenant: "graph" }, - metadata: { - dawn: { - mode: "workflow", - route_id: "/support/[tenant]", - route_path: "src/app/support/[tenant]/index.ts", - }, - }, - on_completion: "delete", + route: "/support/[tenant]#workflow", }), headers: { "content-type": "application/json", @@ -156,18 +132,10 @@ describe("dawn dev runtime server", () => { const server = await startRuntimeServer({ appRoot }) servers.push(server) - const response = await fetch(new URL("/runs/wait", server.url), { + const response = await fetch(new URL("/threads/t1/runs/wait", server.url), { body: JSON.stringify({ - assistant_id: "/support/[tenant]#graph", input: { tenant: "graph" }, - metadata: { - dawn: { - mode: "graph", - route_id: "/support/[tenant]", - route_path: "src/app/support/[tenant]/index.ts", - }, - }, - on_completion: "delete", + route: "/support/[tenant]#graph", }), headers: { "content-type": "application/json", @@ -210,18 +178,10 @@ describe("dawn dev runtime server", () => { const server = await startRuntimeServer({ appRoot }) servers.push(server) - const responsePromise = fetch(new URL("/runs/wait", server.url), { + const responsePromise = fetch(new URL("/threads/t1/runs/wait", server.url), { body: JSON.stringify({ - assistant_id: "/support/[tenant]#graph", input: {}, - metadata: { - dawn: { - mode: "graph", - route_id: "/support/[tenant]", - route_path: "src/app/support/[tenant]/index.ts", - }, - }, - on_completion: "delete", + route: "/support/[tenant]#graph", }), headers: { "content-type": "application/json", @@ -642,18 +602,12 @@ async function invokeRunsWait( readonly routePath: string }, ) { - return await fetch(new URL("/runs/wait", baseUrl), { + // Use a stable test thread ID derived from the assistant + routeId. + const threadId = `test-${options.assistantId.replace(/[^a-z0-9]/gi, "-")}` + return await fetch(new URL(`/threads/${encodeURIComponent(threadId)}/runs/wait`, baseUrl), { body: JSON.stringify({ - assistant_id: options.assistantId, input: options.input, - metadata: { - dawn: { - mode: options.mode, - route_id: options.routeId, - route_path: options.routePath, - }, - }, - on_completion: "delete", + route: options.assistantId, }), headers: { "content-type": "application/json", diff --git a/packages/cli/test/run-command.test.ts b/packages/cli/test/run-command.test.ts index 46dc16e0..574c9af2 100644 --- a/packages/cli/test/run-command.test.ts +++ b/packages/cli/test/run-command.test.ts @@ -538,18 +538,10 @@ export const graph = async () => ({ ok: true }) expect(result.exitCode).toBe(0) expect(receivedRequest).toMatchObject({ - assistant_id: "/support/[tenant]#workflow", input: { tenant: "assistant-id", }, - metadata: { - dawn: { - mode: "workflow", - route_id: "/support/[tenant]", - route_path: "src/app/support/[tenant]/index.ts", - }, - }, - on_completion: "delete", + route: "/support/[tenant]#workflow", }) }) @@ -569,7 +561,7 @@ export const graph = async () => ({ ok: true }) }, statusCode: 200, } - }, "/api/runs/wait") + }, /^\/api\/threads\/[^/]+\/runs\/wait$/) const result = await invoke( [ @@ -587,7 +579,7 @@ export const graph = async () => ({ ok: true }) expect(result.exitCode).toBe(0) expect(result.stderr).toBe("") - expect(receivedRequestPath).toBe("/api/runs/wait") + expect(receivedRequestPath).toMatch(/^\/api\/threads\/[^/]+\/runs\/wait$/) const payload = JSON.parse(result.stdout) as Record expectTiming(payload) @@ -610,7 +602,7 @@ export const graph = async () => ({ ok: true }) "package.json": "{}\n", "dawn.config.ts": "export default {};\n", }) - const server = await startHangingAgentServer("/runs/wait") + const server = await startHangingAgentServer() const result = await executeRouteServer({ appRoot, @@ -839,10 +831,12 @@ async function startFakeAgentServer( readonly rawBody?: string readonly statusCode: number }>, - requestPath = "/runs/wait", + requestPath: string | RegExp = /^\/threads\/[^/]+\/runs\/wait$/, ): Promise<{ readonly close: () => Promise; readonly url: string }> { + const matches = (url: string) => + typeof requestPath === "string" ? url === requestPath : requestPath.test(url) const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { - if (request.method !== "POST" || request.url !== requestPath) { + if (request.method !== "POST" || !matches(request.url ?? "")) { response.statusCode = 404 response.setHeader("content-type", "application/json") response.end(JSON.stringify({ error: "not found" })) @@ -894,10 +888,12 @@ async function startFakeAgentServer( } async function startHangingAgentServer( - requestPath = "/runs/wait", + requestPath: string | RegExp = /^\/threads\/[^/]+\/runs\/wait$/, ): Promise<{ readonly close: () => Promise; readonly url: string }> { + const matches = (url: string) => + typeof requestPath === "string" ? url === requestPath : requestPath.test(url) const server = createServer((request: IncomingMessage, response: ServerResponse) => { - if (request.method !== "POST" || request.url !== requestPath) { + if (request.method !== "POST" || !matches(request.url ?? "")) { response.statusCode = 404 response.setHeader("content-type", "application/json") response.end(JSON.stringify({ error: "not found" })) diff --git a/packages/cli/test/test-command.test.ts b/packages/cli/test/test-command.test.ts index e5111cf6..c2afec72 100644 --- a/packages/cli/test/test-command.test.ts +++ b/packages/cli/test/test-command.test.ts @@ -791,7 +791,7 @@ async function startFakeAgentServer( }>, ): Promise<{ readonly close: () => Promise; readonly url: string }> { const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { - if (request.method !== "POST" || request.url !== "/runs/wait") { + if (request.method !== "POST" || !/^\/threads\/[^/]+\/runs\/wait$/.test(request.url ?? "")) { response.statusCode = 404 response.setHeader("content-type", "application/json") response.end(JSON.stringify({ error: "not found" })) diff --git a/packages/cli/test/typegen-command.test.ts b/packages/cli/test/typegen-command.test.ts index b89c7387..9046320d 100644 --- a/packages/cli/test/typegen-command.test.ts +++ b/packages/cli/test/typegen-command.test.ts @@ -67,6 +67,7 @@ async function runCommand(command: string, args: readonly string[], cwd: string) }>((resolvePromise, rejectPromise) => { const child = spawn(command, args, { cwd, + env: { ...process.env, NODE_NO_WARNINGS: "1" }, stdio: "pipe", }) @@ -188,6 +189,7 @@ describe("dawn typegen", () => { const langgraphTarball = await packPackage("@dawn-ai/langgraph", packsRoot) const permissionsTarball = await packPackage("@dawn-ai/permissions", packsRoot) const sdkTarball = await packPackage("@dawn-ai/sdk", packsRoot) + const sqliteStorageTarball = await packPackage("@dawn-ai/sqlite-storage", packsRoot) const workspaceTarball = await packPackage("@dawn-ai/workspace", packsRoot) await writeFile( @@ -202,6 +204,7 @@ describe("dawn typegen", () => { "@dawn-ai/core": `file:${coreTarball}`, "@dawn-ai/langchain": `file:${langchainTarball}`, "@dawn-ai/langgraph": `file:${langgraphTarball}`, + "@dawn-ai/sqlite-storage": `file:${sqliteStorageTarball}`, }, pnpm: { overrides: { @@ -210,6 +213,7 @@ describe("dawn typegen", () => { "@dawn-ai/langgraph": `file:${langgraphTarball}`, "@dawn-ai/permissions": `file:${permissionsTarball}`, "@dawn-ai/sdk": `file:${sdkTarball}`, + "@dawn-ai/sqlite-storage": `file:${sqliteStorageTarball}`, "@dawn-ai/workspace": `file:${workspaceTarball}`, }, }, diff --git a/packages/core/package.json b/packages/core/package.json index 5c33687f..c02a09c0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,8 +44,14 @@ "typescript": "5.8.3", "zod": "^4.4.3" }, + "peerDependencies": { + "@dawn-ai/sqlite-storage": "workspace:*", + "@langchain/langgraph-checkpoint": "^1.0.2" + }, "devDependencies": { "@dawn-ai/config-typescript": "workspace:*", + "@dawn-ai/sqlite-storage": "workspace:*", + "@langchain/langgraph-checkpoint": "^1.0.2", "@types/node": "25.6.0" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9b226532..e54cb7b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export type { ThreadsStore } from "@dawn-ai/sqlite-storage" export { createAgentsMdMarker } from "./capabilities/built-in/agents-md.js" export type { RuntimeTodo } from "./capabilities/built-in/planning.js" export { createPlanningMarker } from "./capabilities/built-in/planning.js" diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 672dcc07..db5bf3e5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,6 +1,8 @@ import type { PermissionMode } from "@dawn-ai/permissions" import type { RouteKind } from "@dawn-ai/sdk" +import type { ThreadsStore } from "@dawn-ai/sqlite-storage" import type { ExecBackend, FilesystemBackend } from "@dawn-ai/workspace" +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" export type { RouteKind } @@ -15,6 +17,8 @@ export interface DawnConfig { readonly allow?: Readonly> readonly deny?: Readonly> } + readonly checkpointer?: BaseCheckpointSaver + readonly threadsStore?: ThreadsStore } export type RouteSegment = diff --git a/packages/create-dawn-app/src/index.ts b/packages/create-dawn-app/src/index.ts index d5bbc4f1..2e0878a5 100644 --- a/packages/create-dawn-app/src/index.ts +++ b/packages/create-dawn-app/src/index.ts @@ -180,6 +180,7 @@ function createTemplateReplacements( readonly dawnLanggraphSpecifier: string readonly dawnPermissionsSpecifier: string readonly dawnSdkSpecifier: string + readonly dawnSqliteStorageSpecifier: string readonly dawnWorkspaceSpecifier: string } { if (options.mode === "internal") { @@ -196,6 +197,9 @@ function createTemplateReplacements( resolve(repoRoot, "packages/permissions"), ), dawnSdkSpecifier: createAbsoluteFileSpecifier(resolve(repoRoot, "packages/sdk")), + dawnSqliteStorageSpecifier: createAbsoluteFileSpecifier( + resolve(repoRoot, "packages/sqlite-storage"), + ), dawnWorkspaceSpecifier: createAbsoluteFileSpecifier(resolve(repoRoot, "packages/workspace")), } } @@ -209,6 +213,7 @@ function createTemplateReplacements( dawnLanggraphSpecifier: options.distTag, dawnPermissionsSpecifier: options.distTag, dawnSdkSpecifier: options.distTag, + dawnSqliteStorageSpecifier: options.distTag, dawnWorkspaceSpecifier: options.distTag, } } @@ -237,6 +242,7 @@ async function applyInternalModePackageOverrides( "@dawn-ai/langgraph": replacements.dawnLanggraphSpecifier, "@dawn-ai/permissions": replacements.dawnPermissionsSpecifier, "@dawn-ai/sdk": replacements.dawnSdkSpecifier, + "@dawn-ai/sqlite-storage": replacements.dawnSqliteStorageSpecifier, "@dawn-ai/workspace": replacements.dawnWorkspaceSpecifier, }, } diff --git a/packages/langchain/package.json b/packages/langchain/package.json index 5c88b292..fc1b2405 100644 --- a/packages/langchain/package.json +++ b/packages/langchain/package.json @@ -43,6 +43,7 @@ }, "peerDependencies": { "@langchain/core": "^1.1.47", + "@langchain/langgraph-checkpoint": "^1.0.2", "@langchain/anthropic": "^1.4.0", "@langchain/google-genai": "^2.1.31", "@langchain/mistralai": "^1.0.8", @@ -52,6 +53,9 @@ "@langchain/openrouter": "^0.2.5" }, "peerDependenciesMeta": { + "@langchain/langgraph-checkpoint": { + "optional": false + }, "@langchain/anthropic": { "optional": true }, @@ -77,6 +81,7 @@ "devDependencies": { "@dawn-ai/config-typescript": "workspace:*", "@langchain/anthropic": "^1.4.0", + "@langchain/langgraph-checkpoint": "^1.0.2", "@langchain/core": "1.1.47", "@langchain/google-genai": "^2.1.31", "@langchain/groq": "^1.2.1", diff --git a/packages/langchain/src/agent-adapter.ts b/packages/langchain/src/agent-adapter.ts index 5e46619e..8f9625a6 100644 --- a/packages/langchain/src/agent-adapter.ts +++ b/packages/langchain/src/agent-adapter.ts @@ -2,7 +2,8 @@ import type { PromptFragment, StreamTransformer } from "@dawn-ai/core" import type { DawnAgent, RetryConfig } from "@dawn-ai/sdk" import { isDawnAgent } from "@dawn-ai/sdk" import { type BaseMessageLike, HumanMessage } from "@langchain/core/messages" -import { Command, MemorySaver } from "@langchain/langgraph" +import { Command } from "@langchain/langgraph" +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" import { createChatModel } from "./chat-model-factory.js" import { resolveProvider } from "./model-provider-resolver.js" import { @@ -57,20 +58,6 @@ function assertAgentLike(entry: unknown): asserts entry is AgentLike { // changes, the cache key must include a hash of the fragments/transformers. const materializedAgents = new WeakMap() -/** - * Process-level checkpointer shared by every materialized agent. LangGraph - * requires a checkpointer + a stable `thread_id` for `interrupt()` to park - * graph state and for `new Command({resume})` to replay from the parked - * step. The dev/runtime server passes the client-supplied - * `metadata.dawn.thread_id` through to `streamAgent`, which forwards it to - * `config.configurable.thread_id`. - * - * Single shared instance is fine for in-process runtimes; revisit if the - * runtime ever runs across processes (each would have its own saver and - * resume would need a distributed checkpointer like SQLite/Postgres). - */ -const sharedCheckpointer = new MemorySaver() - export function composePromptMessages( systemPrompt: string, promptFragments: readonly PromptFragment[], @@ -88,6 +75,7 @@ export function composePromptMessages( async function materializeAgent( descriptor: DawnAgent, tools: readonly DawnToolDefinition[], + checkpointer: BaseCheckpointSaver, stateFields?: readonly ResolvedStateField[], middlewareContext?: Readonly>, promptFragments?: readonly PromptFragment[], @@ -127,7 +115,7 @@ async function materializeAgent( : descriptor.systemPrompt, // Required so `interrupt()` can park graph state and `Command({resume})` // can replay it. Paired with `config.configurable.thread_id`. - checkpointer: sharedCheckpointer, + checkpointer, } if (stateFields && stateFields.length > 0) { @@ -144,6 +132,7 @@ async function materializeAgent( } export async function materializeAgentGraph(options: { + readonly checkpointer: BaseCheckpointSaver readonly descriptor: DawnAgent readonly tools?: readonly DawnToolDefinition[] readonly stateFields?: readonly ResolvedStateField[] @@ -152,6 +141,7 @@ export async function materializeAgentGraph(options: { return materializeAgent( options.descriptor, options.tools ?? [], + options.checkpointer, options.stateFields, undefined, options.promptFragments, @@ -278,6 +268,13 @@ function parseInterruptStringMessage(text: string): readonly RawInterruptEntry[] } export interface AgentOptions { + /** + * Checkpointer used by LangGraph to park interrupted graph state and replay + * from it on resume. Required — the CLI runtime supplies a SQLite-backed + * instance by default. If you call agent-adapter directly (e.g. in tests), + * pass `new MemorySaver()` from `@langchain/langgraph`. + */ + readonly checkpointer: BaseCheckpointSaver readonly entry: unknown readonly input: unknown readonly middlewareContext?: Readonly> @@ -317,6 +314,12 @@ export async function executeAgent(options: AgentOptions): Promise { } export async function* streamAgent(options: AgentOptions): AsyncGenerator { + if (!options.checkpointer) { + throw new Error( + "[dawn] agent-adapter requires a checkpointer in AgentOptions. The CLI runtime instantiates sqliteCheckpointer by default; if you're calling agent-adapter directly, pass one explicitly.", + ) + } + const { agentInput, config } = prepareAgentCall(options) const messages = extractMessages(agentInput) @@ -367,6 +370,7 @@ export async function* streamAgent(options: AgentOptions): AsyncGenerator { const chunks: Array<{ type: string; data: unknown }> = [] for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), entry: mockRunnable, input: { messages: [{ role: "user", content: "test" }] }, routeParamNames: [], @@ -109,6 +110,7 @@ describe("streamAgent — interrupt propagation", () => { const chunks: Array<{ type: string; data: unknown }> = [] for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), entry: mockRunnable, input: { messages: [{ role: "user", content: "test" }] }, routeParamNames: [], @@ -139,6 +141,7 @@ describe("streamAgent — interrupt propagation", () => { const chunks: Array<{ type: string; data: unknown }> = [] for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), entry: mockRunnable, input: { messages: [{ role: "user", content: "test" }] }, routeParamNames: [], @@ -166,6 +169,7 @@ describe("streamAgent — interrupt propagation", () => { const chunks: Array<{ type: string }> = [] for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), entry: mockRunnable, input: { messages: [{ role: "user", content: "test" }] }, routeParamNames: [], @@ -197,6 +201,7 @@ describe("streamAgent — interrupt propagation", () => { const chunks: Array<{ type: string }> = [] for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), entry: mockRunnable, input: { messages: [{ role: "user", content: "test" }] }, routeParamNames: [], @@ -259,6 +264,7 @@ describe("streamAgent — interrupt propagation", () => { const chunks: Array<{ type: string; data?: unknown }> = [] const consumer = (async () => { for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), entry: mockRunnable, input: { messages: [{ role: "user", content: "test" }] }, routeParamNames: [], @@ -318,6 +324,7 @@ describe("streamAgent — interrupt propagation", () => { const chunks: Array<{ type: string }> = [] for await (const chunk of streamAgent({ + checkpointer: new MemorySaver(), entry: mockRunnable, input: { messages: [{ role: "user", content: "test" }] }, routeParamNames: [], diff --git a/packages/langchain/test/agent-adapter.test.ts b/packages/langchain/test/agent-adapter.test.ts index 0b7c7cd1..d313de60 100644 --- a/packages/langchain/test/agent-adapter.test.ts +++ b/packages/langchain/test/agent-adapter.test.ts @@ -1,5 +1,6 @@ import { agent } from "@dawn-ai/sdk" import { AIMessage } from "@langchain/core/messages" +import { MemorySaver } from "@langchain/langgraph" import { describe, expect, test, vi } from "vitest" import { executeAgent } from "../src/agent-adapter.js" @@ -31,6 +32,7 @@ describe("executeAgent with DawnAgent descriptors", () => { }) const result = await executeAgent({ + checkpointer: new MemorySaver(), entry: descriptor, input: { question: "hi" }, routeParamNames: [], @@ -82,6 +84,7 @@ describe("executeAgent with DawnAgent descriptors", () => { }) const result = await executeAgent({ + checkpointer: new MemorySaver(), entry: descriptor, input: { question: "hi" }, routeParamNames: [], @@ -115,6 +118,7 @@ describe("executeAgent with DawnAgent descriptors", () => { }) const error = await executeAgent({ + checkpointer: new MemorySaver(), entry: descriptor, input: { question: "hi" }, routeParamNames: [], @@ -165,6 +169,7 @@ describe("executeAgent with DawnAgent descriptors", () => { }) const result = await executeAgent({ + checkpointer: new MemorySaver(), entry: descriptor, input: { question: "hi" }, routeParamNames: [], @@ -188,6 +193,7 @@ describe("executeAgent with DawnAgent descriptors", () => { } const result = await executeAgent({ + checkpointer: new MemorySaver(), entry: mockAgent, input: { question: "hi" }, routeParamNames: [], @@ -205,6 +211,7 @@ describe("executeAgent with DawnAgent descriptors", () => { } await executeAgent({ + checkpointer: new MemorySaver(), entry: mockAgent, input: { tenant: "acme", question: "hello" }, routeParamNames: ["tenant"], diff --git a/packages/langchain/test/agent-descriptor-integration.test.ts b/packages/langchain/test/agent-descriptor-integration.test.ts index afaea8e6..62491d9b 100644 --- a/packages/langchain/test/agent-descriptor-integration.test.ts +++ b/packages/langchain/test/agent-descriptor-integration.test.ts @@ -1,5 +1,6 @@ import { agent } from "@dawn-ai/sdk" import { AIMessage } from "@langchain/core/messages" +import { MemorySaver } from "@langchain/langgraph" import { describe, expect, test, vi } from "vitest" import { executeAgent } from "../src/agent-adapter.js" @@ -31,6 +32,7 @@ describe("agent() descriptor integration", () => { }) const result = await executeAgent({ + checkpointer: new MemorySaver(), entry: descriptor, input: { question: "hi" }, routeParamNames: [], @@ -76,6 +78,7 @@ describe("agent() descriptor integration", () => { ] const result = await executeAgent({ + checkpointer: new MemorySaver(), entry: descriptor, input: { query: "test" }, routeParamNames: [], diff --git a/packages/sqlite-storage/package.json b/packages/sqlite-storage/package.json new file mode 100644 index 00000000..a2dcf9c7 --- /dev/null +++ b/packages/sqlite-storage/package.json @@ -0,0 +1,48 @@ +{ + "name": "@dawn-ai/sqlite-storage", + "version": "0.1.0", + "private": false, + "type": "module", + "license": "MIT", + "homepage": "https://github.com/cacheplane/dawnai/tree/main/packages/sqlite-storage#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/cacheplane/dawnai.git", + "directory": "packages/sqlite-storage" + }, + "bugs": { + "url": "https://github.com/cacheplane/dawnai/issues" + }, + "engines": { + "node": ">=22.12.0" + }, + "files": [ + "dist" + ], + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "lint": "biome check --config-path ../config-biome/biome.json package.json src tsconfig.json vitest.config.ts", + "test": "vitest --run --config vitest.config.ts --passWithNoTests", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@langchain/core": "^1.1.44", + "@langchain/langgraph-checkpoint": "^1.0.2" + }, + "devDependencies": { + "@dawn-ai/config-typescript": "workspace:*", + "@langchain/core": "^1.1.47", + "@langchain/langgraph-checkpoint": "^1.0.2", + "@types/node": "25.6.0" + } +} diff --git a/packages/sqlite-storage/src/checkpointer/index.ts b/packages/sqlite-storage/src/checkpointer/index.ts new file mode 100644 index 00000000..f8d09890 --- /dev/null +++ b/packages/sqlite-storage/src/checkpointer/index.ts @@ -0,0 +1,16 @@ +import { openDb } from "../internal/db.js" +import { runMigrations } from "../internal/migrate.js" +import { DawnSqliteSaver } from "./saver.js" +import { CHECKPOINTER_MIGRATIONS } from "./schema.js" + +export interface SqliteCheckpointerOptions { + readonly path: string +} + +export function sqliteCheckpointer(options: SqliteCheckpointerOptions): DawnSqliteSaver { + const db = openDb(options.path) + runMigrations(db, CHECKPOINTER_MIGRATIONS) + return new DawnSqliteSaver(db) +} + +export { DawnSqliteSaver } from "./saver.js" diff --git a/packages/sqlite-storage/src/checkpointer/saver.ts b/packages/sqlite-storage/src/checkpointer/saver.ts new file mode 100644 index 00000000..afaaba0a --- /dev/null +++ b/packages/sqlite-storage/src/checkpointer/saver.ts @@ -0,0 +1,221 @@ +import type { RunnableConfig } from "@langchain/core/runnables" +import type { + Checkpoint, + CheckpointListOptions, + CheckpointMetadata, + CheckpointTuple, +} from "@langchain/langgraph-checkpoint" +import { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint" +import type { Db } from "../internal/db.js" +import { decodeBlob, encodeBlob } from "./serde.js" + +interface CheckpointRow { + thread_id: string + checkpoint_ns: string + checkpoint_id: string + parent_checkpoint_id: string | null + type: string | null + checkpoint: Uint8Array + metadata: Uint8Array +} + +interface WriteRow { + task_id: string + channel: string + type: string | null + value: Uint8Array | null +} + +function buildTuple(row: CheckpointRow, writes: WriteRow[]): CheckpointTuple { + const checkpoint = decodeBlob(row.checkpoint) as Checkpoint + const metadata = decodeBlob(row.metadata) as CheckpointMetadata + const pendingWrites: [string, string, unknown][] = writes.map((w) => [ + w.task_id, + w.channel, + w.value != null ? decodeBlob(w.value) : null, + ]) + + const config: RunnableConfig = { + configurable: { + thread_id: row.thread_id, + checkpoint_ns: row.checkpoint_ns, + checkpoint_id: row.checkpoint_id, + }, + } + + const base: CheckpointTuple = { config, checkpoint, metadata, pendingWrites } + + if (row.parent_checkpoint_id != null) { + return { + ...base, + parentConfig: { + configurable: { + thread_id: row.thread_id, + checkpoint_ns: row.checkpoint_ns, + checkpoint_id: row.parent_checkpoint_id, + }, + }, + } + } + return base +} + +export class DawnSqliteSaver extends BaseCheckpointSaver { + constructor(private readonly db: Db) { + super() + } + + async getTuple(config: RunnableConfig): Promise { + const threadId = config.configurable?.thread_id as string | undefined + if (!threadId) return undefined + const ns = (config.configurable?.checkpoint_ns as string | undefined) ?? "" + const ckptId = config.configurable?.checkpoint_id as string | undefined + + let row: unknown + if (ckptId) { + row = this.db + .prepare( + "SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?", + ) + .get(threadId, ns, ckptId) + } else { + row = this.db + .prepare( + "SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? ORDER BY checkpoint_id DESC LIMIT 1", + ) + .get(threadId, ns) + } + if (!row) return undefined + + const typedRow = row as CheckpointRow + const writeRows = this.db + .prepare( + "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx", + ) + .all( + typedRow.thread_id, + typedRow.checkpoint_ns, + typedRow.checkpoint_id, + ) as unknown as WriteRow[] + + return buildTuple(typedRow, writeRows) + } + + async *list( + config: RunnableConfig, + options?: CheckpointListOptions, + ): AsyncGenerator { + const threadId = config.configurable?.thread_id as string | undefined + if (!threadId) return + const ns = (config.configurable?.checkpoint_ns as string | undefined) ?? "" + const before = options?.before?.configurable?.checkpoint_id as string | undefined + const limit = options?.limit ?? -1 + + const params: (string | number)[] = [threadId, ns] + let sql = + "SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ?" + if (before) { + sql += " AND checkpoint_id < ?" + params.push(before) + } + sql += " ORDER BY checkpoint_id DESC" + if (limit > 0) { + sql += " LIMIT ?" + params.push(limit) + } + const rows = this.db.prepare(sql).all(...params) as unknown as CheckpointRow[] + for (const row of rows) { + // Note: list returns lightweight tuples without pendingWrites. Callers that + // need writes should call getTuple(specificCheckpointId) for full hydration. + // This matches the @langchain/langgraph-checkpoint-sqlite reference behavior. + yield buildTuple(row, []) + } + } + + async put( + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + _newVersions: Record, + ): Promise { + const threadId = config.configurable?.thread_id as string | undefined + if (!threadId) { + throw new Error("[DawnSqliteSaver] config.configurable.thread_id is required") + } + const ns = (config.configurable?.checkpoint_ns as string | undefined) ?? "" + const parentId = (config.configurable?.checkpoint_id as string | undefined) ?? null + // _newVersions is provided by LangGraph for version-tracking purposes but is + // not persisted separately — versions live inside the serialized checkpoint payload. + this.db + .prepare( + `INSERT OR REPLACE INTO checkpoints + (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + threadId, + ns, + checkpoint.id, + parentId, + null, + encodeBlob(checkpoint), + encodeBlob(metadata), + ) + return { + configurable: { thread_id: threadId, checkpoint_ns: ns, checkpoint_id: checkpoint.id }, + } + } + + async putWrites( + config: RunnableConfig, + writes: [string, unknown][], + taskId: string, + ): Promise { + const threadId = config.configurable?.thread_id as string | undefined + if (!threadId) { + throw new Error("[DawnSqliteSaver] config.configurable.thread_id is required") + } + const ns = (config.configurable?.checkpoint_ns as string | undefined) ?? "" + const ckptId = config.configurable?.checkpoint_id as string | undefined + if (!ckptId) { + throw new Error("[DawnSqliteSaver] config.configurable.checkpoint_id is required") + } + const stmt = this.db.prepare( + `INSERT OR REPLACE INTO writes + (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + this.db.exec("BEGIN") + try { + writes.forEach(([channel, value], idx) => { + stmt.run( + threadId, + ns, + ckptId, + taskId, + idx, + channel, + null, + value == null ? null : encodeBlob(value), + ) + }) + this.db.exec("COMMIT") + } catch (err) { + this.db.exec("ROLLBACK") + throw err + } + } + + async deleteThread(threadId: string): Promise { + if (!threadId) throw new Error("[DawnSqliteSaver] deleteThread requires a thread_id") + this.db.exec("BEGIN") + try { + this.db.prepare("DELETE FROM writes WHERE thread_id = ?").run(threadId) + this.db.prepare("DELETE FROM checkpoints WHERE thread_id = ?").run(threadId) + this.db.exec("COMMIT") + } catch (err) { + this.db.exec("ROLLBACK") + throw err + } + } +} diff --git a/packages/sqlite-storage/src/checkpointer/schema.ts b/packages/sqlite-storage/src/checkpointer/schema.ts new file mode 100644 index 00000000..ae341a61 --- /dev/null +++ b/packages/sqlite-storage/src/checkpointer/schema.ts @@ -0,0 +1,31 @@ +import type { Migration } from "../internal/migrate.js" + +export const CHECKPOINTER_MIGRATIONS: readonly Migration[] = [ + { + version: 1, + up: ` + CREATE TABLE checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + parent_checkpoint_id TEXT, + type TEXT, + checkpoint BLOB NOT NULL, + metadata BLOB NOT NULL, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) + ); + CREATE INDEX idx_checkpoints_thread ON checkpoints(thread_id, checkpoint_ns); + CREATE TABLE writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + value BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) + ); + `, + }, +] diff --git a/packages/sqlite-storage/src/checkpointer/serde.ts b/packages/sqlite-storage/src/checkpointer/serde.ts new file mode 100644 index 00000000..e12c8871 --- /dev/null +++ b/packages/sqlite-storage/src/checkpointer/serde.ts @@ -0,0 +1,10 @@ +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +export function encodeBlob(value: unknown): Uint8Array { + return encoder.encode(JSON.stringify(value)) +} + +export function decodeBlob(buf: Uint8Array): unknown { + return JSON.parse(decoder.decode(buf)) +} diff --git a/packages/sqlite-storage/src/index.ts b/packages/sqlite-storage/src/index.ts new file mode 100644 index 00000000..e3aac934 --- /dev/null +++ b/packages/sqlite-storage/src/index.ts @@ -0,0 +1,10 @@ +export type { SqliteCheckpointerOptions } from "./checkpointer/index.js" +export { DawnSqliteSaver, sqliteCheckpointer } from "./checkpointer/index.js" +export type { + CreateThreadInput, + Thread, + ThreadStatus, + ThreadsStore, + ThreadsStoreOptions, +} from "./threads/index.js" +export { createThreadsStore } from "./threads/index.js" diff --git a/packages/sqlite-storage/src/internal/db.ts b/packages/sqlite-storage/src/internal/db.ts new file mode 100644 index 00000000..7d1d81a3 --- /dev/null +++ b/packages/sqlite-storage/src/internal/db.ts @@ -0,0 +1,19 @@ +import { mkdirSync } from "node:fs" +import { dirname } from "node:path" +import { DatabaseSync } from "node:sqlite" + +export type Db = DatabaseSync + +export function openDb(path: string): Db { + const isMemory = path === ":memory:" + if (!isMemory) { + mkdirSync(dirname(path), { recursive: true }) + } + const db = new DatabaseSync(path) + if (!isMemory) { + db.exec("PRAGMA journal_mode = WAL") + } + db.exec("PRAGMA foreign_keys = ON") + db.exec("PRAGMA synchronous = NORMAL") + return db +} diff --git a/packages/sqlite-storage/src/internal/migrate.ts b/packages/sqlite-storage/src/internal/migrate.ts new file mode 100644 index 00000000..7f842ea4 --- /dev/null +++ b/packages/sqlite-storage/src/internal/migrate.ts @@ -0,0 +1,27 @@ +import type { DatabaseSync } from "node:sqlite" + +export interface Migration { + readonly version: number + readonly up: string +} + +export function runMigrations(db: DatabaseSync, migrations: readonly Migration[]): void { + db.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)") + const row = db.prepare("SELECT max(version) AS v FROM schema_version").get() as { + v: number | null + } + const current = row?.v ?? 0 + const sorted = [...migrations].sort((a, b) => a.version - b.version) + for (const m of sorted) { + if (m.version <= current) continue + db.exec("BEGIN") + try { + db.exec(m.up) + db.prepare("INSERT INTO schema_version(version) VALUES (?)").run(m.version) + db.exec("COMMIT") + } catch (err) { + db.exec("ROLLBACK") + throw err + } + } +} diff --git a/packages/sqlite-storage/src/threads/index.ts b/packages/sqlite-storage/src/threads/index.ts new file mode 100644 index 00000000..db0fa0d5 --- /dev/null +++ b/packages/sqlite-storage/src/threads/index.ts @@ -0,0 +1,16 @@ +import { openDb } from "../internal/db.js" +import { runMigrations } from "../internal/migrate.js" +import { THREADS_MIGRATIONS } from "./schema.js" +import { makeThreadsStore } from "./store.js" + +export interface ThreadsStoreOptions { + readonly path: string +} + +export function createThreadsStore(options: ThreadsStoreOptions) { + const db = openDb(options.path) + runMigrations(db, THREADS_MIGRATIONS) + return makeThreadsStore(db) +} + +export type { CreateThreadInput, Thread, ThreadStatus, ThreadsStore } from "./store.js" diff --git a/packages/sqlite-storage/src/threads/schema.ts b/packages/sqlite-storage/src/threads/schema.ts new file mode 100644 index 00000000..e8533c29 --- /dev/null +++ b/packages/sqlite-storage/src/threads/schema.ts @@ -0,0 +1,17 @@ +import type { Migration } from "../internal/migrate.js" + +export const THREADS_MIGRATIONS: readonly Migration[] = [ + { + version: 1, + up: ` + CREATE TABLE threads ( + thread_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'idle' + ); + CREATE INDEX idx_threads_updated ON threads(updated_at DESC); + `, + }, +] diff --git a/packages/sqlite-storage/src/threads/store.ts b/packages/sqlite-storage/src/threads/store.ts new file mode 100644 index 00000000..6955b36d --- /dev/null +++ b/packages/sqlite-storage/src/threads/store.ts @@ -0,0 +1,94 @@ +import { randomBytes } from "node:crypto" +import type { Db } from "../internal/db.js" + +export type ThreadStatus = "idle" | "busy" | "interrupted" + +export interface Thread { + readonly thread_id: string + readonly created_at: string + readonly updated_at: string + readonly metadata: Record + readonly status: ThreadStatus +} + +export interface CreateThreadInput { + readonly thread_id?: string + readonly metadata?: Record +} + +export interface ThreadsStore { + createThread(input: CreateThreadInput): Promise + getThread(threadId: string): Promise + deleteThread(threadId: string): Promise + listThreads(): Promise + updateStatus(threadId: string, status: ThreadStatus): Promise +} + +interface ThreadRow { + thread_id: string + created_at: string + updated_at: string + metadata: string + status: ThreadStatus +} + +function rowToThread(row: ThreadRow): Thread { + return { + thread_id: row.thread_id, + created_at: row.created_at, + updated_at: row.updated_at, + metadata: JSON.parse(row.metadata) as Record, + status: row.status, + } +} + +function newThreadId(): string { + return `t-${randomBytes(4).toString("hex")}` +} + +export function makeThreadsStore(db: Db): ThreadsStore { + return { + async createThread(input) { + const now = new Date().toISOString() + const threadId = input.thread_id ?? newThreadId() + const metadata = JSON.stringify(input.metadata ?? {}) + db.prepare( + "INSERT INTO threads(thread_id, created_at, updated_at, metadata, status) VALUES (?, ?, ?, ?, 'idle')", + ).run(threadId, now, now, metadata) + return { + thread_id: threadId, + created_at: now, + updated_at: now, + metadata: input.metadata ?? {}, + status: "idle", + } + }, + async getThread(threadId) { + const row = db + .prepare( + "SELECT thread_id, created_at, updated_at, metadata, status FROM threads WHERE thread_id = ?", + ) + .get(threadId) as unknown as ThreadRow | undefined + return row ? rowToThread(row) : undefined + }, + async deleteThread(threadId) { + db.prepare("DELETE FROM threads WHERE thread_id = ?").run(threadId) + }, + async listThreads() { + const rows = db + .prepare( + "SELECT thread_id, created_at, updated_at, metadata, status FROM threads ORDER BY updated_at DESC", + ) + .all() as unknown as ThreadRow[] + return rows.map(rowToThread) + }, + async updateStatus(threadId, status) { + const now = new Date().toISOString() + db.prepare("UPDATE threads SET status = ?, updated_at = ? WHERE thread_id = ?").run( + status, + now, + threadId, + ) + }, + } +} diff --git a/packages/sqlite-storage/test/checkpointer.test.ts b/packages/sqlite-storage/test/checkpointer.test.ts new file mode 100644 index 00000000..83c9d013 --- /dev/null +++ b/packages/sqlite-storage/test/checkpointer.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { sqliteCheckpointer } from "../src/checkpointer/index.js" + +describe("DawnSqliteSaver", () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "dawn-ckpt-")) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + + function newSaver() { return sqliteCheckpointer({ path: join(dir, "ckpt.sqlite") }) } + + it("put + getTuple round-trip preserves checkpoint payload", async () => { + const saver = newSaver() + const config = { configurable: { thread_id: "t1", checkpoint_ns: "" } } + const checkpoint = { + v: 1, + id: "ckpt-1", + ts: "2026-05-22T00:00:00Z", + channel_values: { messages: ["hi"] }, + channel_versions: { messages: 1 }, + versions_seen: {}, + pending_sends: [], + } + const metadata = { source: "input", step: 0, writes: null, parents: {} } + await saver.put(config, checkpoint as never, metadata as never, {}) + const tuple = await saver.getTuple({ + configurable: { thread_id: "t1", checkpoint_ns: "", checkpoint_id: "ckpt-1" }, + }) + expect(tuple).toBeDefined() + expect(tuple?.checkpoint.id).toBe("ckpt-1") + expect(tuple?.checkpoint.channel_values).toEqual({ messages: ["hi"] }) + }) + + it("getTuple without checkpoint_id returns the latest by checkpoint_id desc", async () => { + const saver = newSaver() + const cfg = { configurable: { thread_id: "t1", checkpoint_ns: "" } } + const mk = (id: string) => ({ + v: 1, id, ts: "x", channel_values: {}, channel_versions: {}, versions_seen: {}, pending_sends: [], + }) + await saver.put(cfg, mk("a") as never, { source: "input", step: 0, writes: null, parents: {} } as never, {}) + await saver.put(cfg, mk("b") as never, { source: "input", step: 1, writes: null, parents: {} } as never, {}) + const t = await saver.getTuple(cfg) + expect(t?.checkpoint.id).toBe("b") + }) + + it("list yields checkpoints in reverse id order", async () => { + const saver = newSaver() + const cfg = { configurable: { thread_id: "t1", checkpoint_ns: "" } } + const mk = (id: string) => ({ + v: 1, id, ts: "x", channel_values: {}, channel_versions: {}, versions_seen: {}, pending_sends: [], + }) + await saver.put(cfg, mk("a") as never, { source: "input", step: 0, writes: null, parents: {} } as never, {}) + await saver.put(cfg, mk("b") as never, { source: "input", step: 1, writes: null, parents: {} } as never, {}) + const ids: string[] = [] + for await (const t of saver.list(cfg)) ids.push(t.checkpoint.id) + expect(ids).toEqual(["b", "a"]) + }) + + it("putWrites is idempotent on (thread_id, ns, ckpt_id, task_id, idx)", async () => { + const saver = newSaver() + const cfg = { configurable: { thread_id: "t1", checkpoint_ns: "", checkpoint_id: "ckpt-1" } } + await saver.putWrites(cfg, [["messages", "a"]], "task-1") + await saver.putWrites(cfg, [["messages", "a"]], "task-1") // must not throw + expect(true).toBe(true) + }) + + it("persists across saver instances (file-backed)", async () => { + const path = join(dir, "ckpt.sqlite") + const s1 = sqliteCheckpointer({ path }) + const cfg = { configurable: { thread_id: "t1", checkpoint_ns: "" } } + const c = { v: 1, id: "c1", ts: "x", channel_values: { x: 1 }, channel_versions: {}, versions_seen: {}, pending_sends: [] } + await s1.put(cfg, c as never, { source: "input", step: 0, writes: null, parents: {} } as never, {}) + const s2 = sqliteCheckpointer({ path }) + const t = await s2.getTuple({ configurable: { thread_id: "t1", checkpoint_ns: "", checkpoint_id: "c1" } }) + expect(t?.checkpoint.channel_values).toEqual({ x: 1 }) + }) +}) diff --git a/packages/sqlite-storage/test/db.test.ts b/packages/sqlite-storage/test/db.test.ts new file mode 100644 index 00000000..3cb21c6c --- /dev/null +++ b/packages/sqlite-storage/test/db.test.ts @@ -0,0 +1,29 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { openDb } from "../src/internal/db.js" + +describe("openDb", () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "dawn-sqlite-")) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + + it("opens a database with WAL journal_mode, foreign_keys ON, and synchronous=NORMAL", () => { + const db = openDb(join(dir, "test.sqlite")) + const journal = db.prepare("PRAGMA journal_mode").get() as { journal_mode: string } + const fk = db.prepare("PRAGMA foreign_keys").get() as { foreign_keys: number } + const sync = db.prepare("PRAGMA synchronous").get() as { synchronous: number } + expect(journal.journal_mode).toBe("wal") + expect(fk.foreign_keys).toBe(1) + expect(sync.synchronous).toBe(1) + db.close() + }) + + it("creates parent directory if missing", () => { + const path = join(dir, "nested", "deep", "test.sqlite") + const db = openDb(path) + expect(db).toBeDefined() + db.close() + }) +}) diff --git a/packages/sqlite-storage/test/migrate.test.ts b/packages/sqlite-storage/test/migrate.test.ts new file mode 100644 index 00000000..546a625b --- /dev/null +++ b/packages/sqlite-storage/test/migrate.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest" +import { DatabaseSync } from "node:sqlite" +import { runMigrations } from "../src/internal/migrate.js" + +function memDb(): DatabaseSync { + return new DatabaseSync(":memory:") +} + +describe("runMigrations", () => { + it("creates schema_version table and applies all migrations on fresh db", () => { + const db = memDb() + runMigrations(db, [ + { version: 1, up: "CREATE TABLE t1(id INTEGER)" }, + { version: 2, up: "CREATE TABLE t2(id INTEGER)" }, + ]) + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as { name: string }[] + expect(tables.map((t) => t.name)).toEqual(["schema_version", "t1", "t2"]) + const v = db.prepare("SELECT max(version) AS v FROM schema_version").get() as { v: number } + expect(v.v).toBe(2) + }) + + it("skips migrations already applied", () => { + const db = memDb() + runMigrations(db, [{ version: 1, up: "CREATE TABLE t1(id INTEGER)" }]) + runMigrations(db, [ + { version: 1, up: "CREATE TABLE t1(id INTEGER)" }, + { version: 2, up: "CREATE TABLE t2(id INTEGER)" }, + ]) + const v = db.prepare("SELECT max(version) AS v FROM schema_version").get() as { v: number } + expect(v.v).toBe(2) + }) +}) diff --git a/packages/sqlite-storage/test/serde.test.ts b/packages/sqlite-storage/test/serde.test.ts new file mode 100644 index 00000000..1811ce83 --- /dev/null +++ b/packages/sqlite-storage/test/serde.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" +import { decodeBlob, encodeBlob } from "../src/checkpointer/serde.js" + +describe("checkpoint serde", () => { + it("round-trips a simple object", () => { + const obj = { messages: [{ role: "user", content: "hi" }], step: 3 } + const buf = encodeBlob(obj) + expect(buf).toBeInstanceOf(Uint8Array) + expect(decodeBlob(buf)).toEqual(obj) + }) + + it("round-trips null and undefined values", () => { + expect(decodeBlob(encodeBlob({ a: null }))).toEqual({ a: null }) + }) + + it("preserves nested structure", () => { + const obj = { a: { b: { c: [1, 2, 3] } } } + expect(decodeBlob(encodeBlob(obj))).toEqual(obj) + }) +}) diff --git a/packages/sqlite-storage/test/threads.test.ts b/packages/sqlite-storage/test/threads.test.ts new file mode 100644 index 00000000..3c470aae --- /dev/null +++ b/packages/sqlite-storage/test/threads.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { createThreadsStore } from "../src/threads/index.js" + +describe("createThreadsStore", () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "dawn-threads-")) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + + function newStore() { return createThreadsStore({ path: join(dir, "threads.sqlite") }) } + + it("create + get round-trips metadata and assigns timestamps", async () => { + const store = newStore() + const t = await store.createThread({ metadata: { user: "brian" } }) + expect(t.thread_id).toMatch(/^t-/) + expect(t.status).toBe("idle") + expect(t.metadata).toEqual({ user: "brian" }) + const fetched = await store.getThread(t.thread_id) + expect(fetched?.thread_id).toBe(t.thread_id) + expect(fetched?.metadata).toEqual({ user: "brian" }) + }) + + it("accepts explicit thread_id", async () => { + const store = newStore() + const t = await store.createThread({ thread_id: "t-explicit" }) + expect(t.thread_id).toBe("t-explicit") + }) + + it("getThread returns undefined for unknown id", async () => { + const store = newStore() + expect(await store.getThread("t-missing")).toBeUndefined() + }) + + it("deleteThread removes the thread", async () => { + const store = newStore() + const t = await store.createThread({}) + await store.deleteThread(t.thread_id) + expect(await store.getThread(t.thread_id)).toBeUndefined() + }) + + it("listThreads returns most-recently-updated first", async () => { + const store = newStore() + const a = await store.createThread({ thread_id: "t-a" }) + await new Promise((r) => setTimeout(r, 10)) + const b = await store.createThread({ thread_id: "t-b" }) + const list = await store.listThreads() + expect(list[0]?.thread_id).toBe(b.thread_id) + expect(list[1]?.thread_id).toBe(a.thread_id) + }) +}) diff --git a/packages/sqlite-storage/tsconfig.json b/packages/sqlite-storage/tsconfig.json new file mode 100644 index 00000000..a112f0e1 --- /dev/null +++ b/packages/sqlite-storage/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../config-typescript/node.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sqlite-storage/vitest.config.ts b/packages/sqlite-storage/vitest.config.ts new file mode 100644 index 00000000..c19dea2b --- /dev/null +++ b/packages/sqlite-storage/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + passWithNoTests: true, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 539c2115..bc68a978 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: '@dawn-ai/permissions': specifier: workspace:* version: link:../permissions + '@dawn-ai/sqlite-storage': + specifier: workspace:* + version: link:../sqlite-storage commander: specifier: 14.0.3 version: 14.0.3 @@ -197,8 +200,11 @@ importers: specifier: workspace:* version: link:../workspace '@langchain/core': - specifier: 1.1.46 - version: 1.1.46(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) + specifier: 1.1.47 + version: 1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) + '@langchain/langgraph-checkpoint': + specifier: ^1.0.2 + version: 1.0.2(@langchain/core@1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) '@types/node': specifier: 25.6.0 version: 25.6.0 @@ -244,6 +250,12 @@ importers: '@dawn-ai/config-typescript': specifier: workspace:* version: link:../config-typescript + '@dawn-ai/sqlite-storage': + specifier: workspace:* + version: link:../sqlite-storage + '@langchain/langgraph-checkpoint': + specifier: ^1.0.2 + version: 1.0.2(@langchain/core@1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) '@types/node': specifier: 25.6.0 version: 25.6.0 @@ -300,6 +312,9 @@ importers: '@langchain/groq': specifier: ^1.2.1 version: 1.2.1(@langchain/core@1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) + '@langchain/langgraph-checkpoint': + specifier: ^1.0.2 + version: 1.0.2(@langchain/core@1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) '@langchain/mistralai': specifier: ^1.0.8 version: 1.0.8(@langchain/core@1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) @@ -350,6 +365,21 @@ importers: specifier: 25.6.0 version: 25.6.0 + packages/sqlite-storage: + devDependencies: + '@dawn-ai/config-typescript': + specifier: workspace:* + version: link:../config-typescript + '@langchain/core': + specifier: ^1.1.47 + version: 1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) + '@langchain/langgraph-checkpoint': + specifier: ^1.0.2 + version: 1.0.2(@langchain/core@1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) + '@types/node': + specifier: 25.6.0 + version: 25.6.0 + packages/vite-plugin: dependencies: '@dawn-ai/core': @@ -1003,10 +1033,6 @@ packages: peerDependencies: '@langchain/core': ^1.1.47 - '@langchain/core@1.1.46': - resolution: {integrity: sha512-i8rDC83BpItxChCw4Lf+6tAr+k+OUcbirc5ZkrhI9ywYWmvxegUljLGOGYvtJNTbEAIFkhYIODPE5QRqyjF6sA==} - engines: {node: '>=20'} - '@langchain/core@1.1.47': resolution: {integrity: sha512-+fiPu6ZFnJMrZyKeM77OIVPoMPAY6OKWacnPlojHtXTbMMzb2cEOKAJV0U07cDl86NHSCIYYa0i4CyKZzXbHQQ==} engines: {node: '>=20'} @@ -3721,22 +3747,6 @@ snapshots: '@langchain/core': 1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) zod: 4.4.3 - '@langchain/core@1.1.46(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)': - dependencies: - '@cfworker/json-schema': 4.1.1 - '@standard-schema/spec': 1.1.0 - js-tiktoken: 1.0.21 - langsmith: 0.5.19(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) - mustache: 4.2.0 - p-queue: 6.6.2 - zod: 4.4.3 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@opentelemetry/exporter-trace-otlp-proto' - - '@opentelemetry/sdk-trace-base' - - openai - - ws - '@langchain/core@1.1.47(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)': dependencies: '@cfworker/json-schema': 4.1.1 diff --git a/test/generated/cli-testing-export.test.ts b/test/generated/cli-testing-export.test.ts index f677d3b7..812f5d5c 100644 --- a/test/generated/cli-testing-export.test.ts +++ b/test/generated/cli-testing-export.test.ts @@ -32,6 +32,7 @@ describe.each([ "@dawn-ai/langgraph", "@dawn-ai/permissions", "@dawn-ai/sdk", + "@dawn-ai/sqlite-storage", "@dawn-ai/workspace", "@dawn-ai/cli", ], @@ -48,6 +49,7 @@ describe.each([ requiredTarball(tarballs, "@dawn-ai/langgraph"), requiredTarball(tarballs, "@dawn-ai/permissions"), requiredTarball(tarballs, "@dawn-ai/sdk"), + requiredTarball(tarballs, "@dawn-ai/sqlite-storage"), requiredTarball(tarballs, "@dawn-ai/workspace"), requiredTarball(tarballs, "@dawn-ai/cli"), ], @@ -148,6 +150,7 @@ async function writeInstallerOverrides( "@dawn-ai/langgraph": requiredTarball(tarballs, "@dawn-ai/langgraph"), "@dawn-ai/permissions": requiredTarball(tarballs, "@dawn-ai/permissions"), "@dawn-ai/sdk": requiredTarball(tarballs, "@dawn-ai/sdk"), + "@dawn-ai/sqlite-storage": requiredTarball(tarballs, "@dawn-ai/sqlite-storage"), "@dawn-ai/workspace": requiredTarball(tarballs, "@dawn-ai/workspace"), } diff --git a/test/generated/fixtures/basic-runtime.expected.json b/test/generated/fixtures/basic-runtime.expected.json index 0b37f0e2..1bdc0945 100644 --- a/test/generated/fixtures/basic-runtime.expected.json +++ b/test/generated/fixtures/basic-runtime.expected.json @@ -24,18 +24,10 @@ "status": "passed" }, "serverRequest": { - "assistant_id": "/hello/[tenant]#agent", "input": { "tenant": "basic-tenant" }, - "metadata": { - "dawn": { - "mode": "agent", - "route_id": "/hello/[tenant]", - "route_path": "src/app/(public)/hello/[tenant]/index.ts" - } - }, - "on_completion": "delete" + "route": "/hello/[tenant]#agent" }, "testStdout": "PASS basic in-process scenario\nPASS basic server scenario\nSummary: 2 passed, 0 failed" } diff --git a/test/generated/fixtures/basic.expected.json b/test/generated/fixtures/basic.expected.json index fd9b94ba..6021f7ae 100644 --- a/test/generated/fixtures/basic.expected.json +++ b/test/generated/fixtures/basic.expected.json @@ -13,6 +13,7 @@ "@dawn-ai/cli": "", "@dawn-ai/langchain": "", "@dawn-ai/sdk": "", + "@dawn-ai/sqlite-storage": "", "zod": "^3.24.0" }, "devDependencies": { @@ -29,6 +30,7 @@ "@dawn-ai/langgraph": "", "@dawn-ai/permissions": "", "@dawn-ai/sdk": "", + "@dawn-ai/sqlite-storage": "", "@dawn-ai/workspace": "" } } diff --git a/test/generated/fixtures/custom-app-dir-runtime.expected.json b/test/generated/fixtures/custom-app-dir-runtime.expected.json index b18360ce..ea3c211c 100644 --- a/test/generated/fixtures/custom-app-dir-runtime.expected.json +++ b/test/generated/fixtures/custom-app-dir-runtime.expected.json @@ -24,18 +24,10 @@ "status": "passed" }, "serverRequest": { - "assistant_id": "/support/[tenant]#graph", "input": { "tenant": "custom-tenant" }, - "metadata": { - "dawn": { - "mode": "graph", - "route_id": "/support/[tenant]", - "route_path": "src/dawn-app/support/[tenant]/index.ts" - } - }, - "on_completion": "delete" + "route": "/support/[tenant]#graph" }, "testStdout": "PASS custom appDir in-process scenario\nPASS custom appDir server scenario\nSummary: 2 passed, 0 failed" } diff --git a/test/generated/fixtures/custom-app-dir.expected.json b/test/generated/fixtures/custom-app-dir.expected.json index 5263e013..e7c0c706 100644 --- a/test/generated/fixtures/custom-app-dir.expected.json +++ b/test/generated/fixtures/custom-app-dir.expected.json @@ -13,6 +13,7 @@ "@dawn-ai/cli": "", "@dawn-ai/langchain": "", "@dawn-ai/sdk": "", + "@dawn-ai/sqlite-storage": "", "zod": "^3.24.0" }, "devDependencies": { @@ -29,6 +30,7 @@ "@dawn-ai/langgraph": "", "@dawn-ai/permissions": "", "@dawn-ai/sdk": "", + "@dawn-ai/sqlite-storage": "", "@dawn-ai/workspace": "" } } diff --git a/test/generated/fixtures/handwritten-runtime.expected.json b/test/generated/fixtures/handwritten-runtime.expected.json index 15b1883c..1af14010 100644 --- a/test/generated/fixtures/handwritten-runtime.expected.json +++ b/test/generated/fixtures/handwritten-runtime.expected.json @@ -24,18 +24,10 @@ "status": "passed" }, "serverRequest": { - "assistant_id": "/hello/[tenant]#graph", "input": { "tenant": "handwritten-tenant" }, - "metadata": { - "dawn": { - "mode": "graph", - "route_id": "/hello/[tenant]", - "route_path": "src/app/(public)/hello/[tenant]/index.ts" - } - }, - "on_completion": "delete" + "route": "/hello/[tenant]#graph" }, "testStdout": "PASS handwritten in-process scenario\nPASS handwritten server scenario\nSummary: 2 passed, 0 failed" } diff --git a/test/generated/harness.ts b/test/generated/harness.ts index e1f99b4c..17321867 100644 --- a/test/generated/harness.ts +++ b/test/generated/harness.ts @@ -41,6 +41,7 @@ interface PackedTarballs { readonly langgraph: string readonly permissions: string readonly sdk: string + readonly sqliteStorage: string readonly workspace: string } @@ -77,16 +78,8 @@ export interface GeneratedRuntimeScenarioResult { readonly runJson: unknown readonly runServerJson: unknown readonly serverRequest: { - readonly assistant_id: string readonly input: unknown - readonly metadata: { - readonly dawn: { - readonly mode: "agent" | "chain" | "graph" | "workflow" - readonly route_id: string - readonly route_path: string - } - } - readonly on_completion: "delete" + readonly route: string } readonly serverRequestUrl: string | null readonly testStdout: string @@ -177,6 +170,7 @@ export async function prepareGeneratedRuntimeApp(options: { "@dawn-ai/langgraph", "@dawn-ai/permissions", "@dawn-ai/sdk", + "@dawn-ai/sqlite-storage", "@dawn-ai/workspace", ], tempRoot: options.tempRoot, @@ -459,6 +453,7 @@ async function rewriteDependenciesToTarballs(options: { "@dawn-ai/core": options.tarballs.core, "@dawn-ai/langgraph": options.tarballs.langgraph, "@dawn-ai/sdk": options.tarballs.sdk, + "@dawn-ai/sqlite-storage": options.tarballs.sqliteStorage, } packageJson.devDependencies = { ...packageJson.devDependencies, @@ -475,6 +470,7 @@ async function rewriteDependenciesToTarballs(options: { "@dawn-ai/langgraph": options.tarballs.langgraph, "@dawn-ai/permissions": options.tarballs.permissions, "@dawn-ai/sdk": options.tarballs.sdk, + "@dawn-ai/sqlite-storage": options.tarballs.sqliteStorage, "@dawn-ai/workspace": options.tarballs.workspace, }, } @@ -671,6 +667,7 @@ function toPackedTarballs(tarballs: Readonly>): PackedTar langgraph: tarballs["@dawn-ai/langgraph"], permissions: tarballs["@dawn-ai/permissions"]!, sdk: tarballs["@dawn-ai/sdk"], + sqliteStorage: tarballs["@dawn-ai/sqlite-storage"]!, workspace: tarballs["@dawn-ai/workspace"]!, } } diff --git a/test/generated/run-generated-app.test.ts b/test/generated/run-generated-app.test.ts index ca10e403..ca427d61 100644 --- a/test/generated/run-generated-app.test.ts +++ b/test/generated/run-generated-app.test.ts @@ -33,6 +33,7 @@ interface PackedTarballs { readonly langgraph: string readonly permissions: string readonly sdk: string + readonly sqliteStorage: string readonly workspace: string } @@ -174,6 +175,7 @@ async function runGeneratedAppScenario( "@dawn-ai/langgraph", "@dawn-ai/permissions", "@dawn-ai/sdk", + "@dawn-ai/sqlite-storage", "@dawn-ai/workspace", ], tempRoot, @@ -300,6 +302,7 @@ async function rewriteDependenciesToTarballs(options: { "@dawn-ai/core": options.tarballs.core, "@dawn-ai/langchain": options.tarballs.langchain, "@dawn-ai/sdk": options.tarballs.sdk, + "@dawn-ai/sqlite-storage": options.tarballs.sqliteStorage, } packageJson.devDependencies = { ...packageJson.devDependencies, @@ -316,6 +319,7 @@ async function rewriteDependenciesToTarballs(options: { "@dawn-ai/langgraph": options.tarballs.langgraph, "@dawn-ai/permissions": options.tarballs.permissions, "@dawn-ai/sdk": options.tarballs.sdk, + "@dawn-ai/sqlite-storage": options.tarballs.sqliteStorage, "@dawn-ai/workspace": options.tarballs.workspace, }, } @@ -510,13 +514,18 @@ async function createExpectedInternalFixture( packageJson: { ...expected.packageJson, name: appName, - dependencies: { - ...expected.packageJson.dependencies, - "@dawn-ai/cli": "", - "@dawn-ai/core": "", - "@dawn-ai/langchain": "", - "@dawn-ai/sdk": "", - }, + dependencies: (() => { + const deps = { + ...expected.packageJson.dependencies, + "@dawn-ai/cli": "", + "@dawn-ai/core": "", + "@dawn-ai/langchain": "", + "@dawn-ai/sdk": "", + } + // sqlite-storage is only in overrides for internal mode, not in direct deps + delete deps["@dawn-ai/sqlite-storage"] + return deps + })(), devDependencies: { ...expected.packageJson.devDependencies, "@dawn-ai/config-typescript": "", @@ -530,6 +539,7 @@ async function createExpectedInternalFixture( "@dawn-ai/langgraph": "", "@dawn-ai/permissions": "", "@dawn-ai/sdk": "", + "@dawn-ai/sqlite-storage": "", "@dawn-ai/workspace": "", }, }, @@ -548,6 +558,7 @@ function toPackedTarballs(tarballs: Readonly>): PackedTar langgraph: tarballs["@dawn-ai/langgraph"], permissions: tarballs["@dawn-ai/permissions"]!, sdk: tarballs["@dawn-ai/sdk"], + sqliteStorage: tarballs["@dawn-ai/sqlite-storage"]!, workspace: tarballs["@dawn-ai/workspace"]!, } } @@ -568,6 +579,7 @@ function normalizeForFixture( [context.tarballs.langgraph, ""], [context.tarballs.permissions, ""], [context.tarballs.sdk, ""], + [context.tarballs.sqliteStorage, ""], [context.tarballs.workspace, ""], [`/private${dirname(context.tarballs.cli)}`, ""], [dirname(context.tarballs.cli), ""], @@ -590,6 +602,7 @@ function normalizeForInternalFixture( [pathToRepoPackageFileSpecifier("@dawn-ai/langgraph"), ""], [pathToRepoPackageFileSpecifier("@dawn-ai/permissions"), ""], [pathToRepoPackageFileSpecifier("@dawn-ai/sdk"), ""], + [pathToRepoPackageFileSpecifier("@dawn-ai/sqlite-storage"), ""], [pathToRepoPackageFileSpecifier("@dawn-ai/workspace"), ""], ["25.6.0", ""], ["6.0.2", ""], @@ -605,6 +618,7 @@ function pathToRepoPackageFileSpecifier( | "@dawn-ai/langgraph" | "@dawn-ai/permissions" | "@dawn-ai/sdk" + | "@dawn-ai/sqlite-storage" | "@dawn-ai/workspace", ): string { const packageDirByName = { @@ -615,6 +629,7 @@ function pathToRepoPackageFileSpecifier( "@dawn-ai/langgraph": "packages/langgraph", "@dawn-ai/permissions": "packages/permissions", "@dawn-ai/sdk": "packages/sdk", + "@dawn-ai/sqlite-storage": "packages/sqlite-storage", "@dawn-ai/workspace": "packages/workspace", } as const diff --git a/test/generated/run-generated-runtime-contract.test.ts b/test/generated/run-generated-runtime-contract.test.ts index 648dde84..ffc5cfe6 100644 --- a/test/generated/run-generated-runtime-contract.test.ts +++ b/test/generated/run-generated-runtime-contract.test.ts @@ -108,7 +108,7 @@ function expectGeneratedRuntimeScenario(result: unknown, expected: unknown): voi devServerHealth: { status: "ready", }, - serverRequestUrl: "/runs/wait", + serverRequestUrl: expect.stringMatching(/^\/threads\/[^/]+\/runs\/wait$/), }) expect(stripGeneratedRuntimeProof(result)).toEqual(expected) } diff --git a/test/harness/packaged-app.ts b/test/harness/packaged-app.ts index a865909d..65636653 100644 --- a/test/harness/packaged-app.ts +++ b/test/harness/packaged-app.ts @@ -166,6 +166,11 @@ export async function runPackagedCommand(options: { args: options.args, command: options.command, cwd: options.cwd, + env: { + // Suppress Node.js experimental-feature warnings (e.g. node:sqlite) + // so the harness does not treat non-empty stderr as a failure. + NODE_NO_WARNINGS: "1", + }, }) : await spawnWithStdin(options) @@ -241,7 +246,12 @@ async function spawnWithStdin(options: { return await new Promise((resolve, reject) => { const child = spawn(options.command, [...options.args], { cwd: options.cwd, - env: process.env, + env: { + ...process.env, + // Suppress Node.js experimental-feature warnings (e.g. node:sqlite) + // so the harness does not treat non-empty stderr as a failure. + NODE_NO_WARNINGS: "1", + }, stdio: ["pipe", "pipe", "pipe"], }) diff --git a/test/runtime/run-agent-protocol.test.ts b/test/runtime/run-agent-protocol.test.ts new file mode 100644 index 00000000..f537e80e --- /dev/null +++ b/test/runtime/run-agent-protocol.test.ts @@ -0,0 +1,294 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { dirname, join, resolve } from "node:path" + +import { afterEach, describe, expect, it } from "vitest" + +import { + cleanupTrackedTempDirs, + createPackagedInstaller, + createTrackedTempDir, + markTrackedTempDirForPreserve, + type TrackedTempDir, +} from "../harness/packaged-app.ts" +import { + allocatePort, + appendDevServerTranscript, + startDevServer, + type DevServerHandle, +} from "./support/dev-server.ts" +import { createGeneratedApp } from "../../packages/devkit/src/testing/index.ts" + +const RUNTIME_ROOT = resolve(import.meta.dirname) +const HARNESS_RUNTIME_ARTIFACT_BASE_DIR_ENV = "DAWN_RUNTIME_ARTIFACT_BASE_DIR" +const tempDirs: TrackedTempDir[] = [] + +afterEach(async () => { + await cleanupTrackedTempDirs(tempDirs) +}) + +// --------------------------------------------------------------------------- +// Echo-agent overlay: a zero-LLM LangGraph StateGraph that checkpoints. +// +// The graph is compiled with the same sqliteCheckpointer path that Dawn uses +// (.dawn/checkpoints.sqlite), so every runs/wait call writes a real checkpoint +// that survives server restarts. +// --------------------------------------------------------------------------- + +function echoAgentOverlaySource(): string { + return ` +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Annotation, StateGraph } from "@langchain/langgraph"; +import { sqliteCheckpointer } from "@dawn-ai/sqlite-storage"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +// src/app/echo/index.ts → up 3 levels to +const appRoot = resolve(__dir, "../../.."); + +const EchoAnnotation = Annotation.Root({ + messages: Annotation({ + reducer: (a, b) => [...(a ?? []), ...(b ?? [])], + default: () => [], + }), +}); + +const checkpointer = sqliteCheckpointer({ + path: resolve(appRoot, ".dawn/checkpoints.sqlite"), +}); + +const echoGraph = new StateGraph(EchoAnnotation) + .addNode("echo", (state) => ({ + messages: state.messages, + })) + .addEdge("__start__", "echo") + .addEdge("echo", "__end__") + .compile({ checkpointer }); + +export const agent = echoGraph; +`.trimStart() +} + +describe("agent protocol state persistence", () => { + it( + "state survives server kill + restart on a new port", + { timeout: 300_000 }, + async () => { + // ------------------------------------------------------------------ + // 1. Build a packed app with the echo-agent overlay + // ------------------------------------------------------------------ + const tempRoot = await createTrackedTempDir("dap-", tempDirs) + const artifactBaseDir = + process.env[HARNESS_RUNTIME_ARTIFACT_BASE_DIR_ENV] ?? tempRoot + const transcriptPath = join(artifactBaseDir, "transcripts", "ap-persist.log") + await mkdir(dirname(transcriptPath), { recursive: true }) + + let appRoot: string + + try { + const { tarballs } = await createPackagedInstaller({ + packageNames: [ + "@dawn-ai/cli", + "@dawn-ai/config-typescript", + "@dawn-ai/core", + "@dawn-ai/langchain", + "@dawn-ai/langgraph", + "@dawn-ai/permissions", + "@dawn-ai/sdk", + "@dawn-ai/sqlite-storage", + "@dawn-ai/workspace", + ], + tempRoot, + transcriptPath, + }) + + const generatedApp = await createGeneratedApp({ + appName: "ap-persist", + artifactRoot: artifactBaseDir, + specifiers: { + dawnCli: tarballs["@dawn-ai/cli"], + dawnConfigTypescript: tarballs["@dawn-ai/config-typescript"], + dawnCore: tarballs["@dawn-ai/core"], + dawnLangchain: tarballs["@dawn-ai/langchain"], + }, + template: "basic", + }) + + appRoot = generatedApp.appRoot + + // Rewrite dependencies to tarballs (mirrors run-runtime-contract.test.ts) + await rewriteDependenciesToTarballs({ appRoot, tarballs }) + + // Write the echo-agent route overlay + const routeFile = join(appRoot, "src/app/echo/index.ts") + await mkdir(dirname(routeFile), { recursive: true }) + await writeFile(routeFile, echoAgentOverlaySource(), "utf8") + + // Install + const { spawnProcess } = await import("../../packages/devkit/src/testing/index.ts") + const installResult = await spawnProcess({ + args: ["install"], + command: "pnpm", + cwd: appRoot, + env: { NODE_NO_WARNINGS: "1" }, + }) + if (!installResult.ok) { + throw new Error(`pnpm install failed:\n${installResult.stdout}\n${installResult.stderr}`) + } + } catch (error) { + markTrackedTempDirForPreserve(tempDirs, tempRoot) + throw error + } + + // ------------------------------------------------------------------ + // 2. First server start — create thread, run it, capture state + // ------------------------------------------------------------------ + const threadId = `t-ap-persist-${Date.now()}` + const routeKey = "/echo#agent" + const input = { messages: [{ role: "user", content: "hello from persistence test" }] } + + const port1 = await allocatePort() + const server1 = await startDevServer({ cwd: appRoot, port: port1 }) + + let stateBefore: Record + try { + const url1 = await server1.waitForReady(30_000) + + // Create the thread + const createThreadResp = await fetch(new URL("/threads", url1), { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "POST", + }) + expect(createThreadResp.status).toBe(200) + const createdThread = (await createThreadResp.json()) as { thread_id?: string } + // Use our deterministic thread_id by calling runs/wait directly (the + // server will idempotently create the thread if it doesn't exist). + + // Run the agent — this writes a checkpoint to .dawn/checkpoints.sqlite + const runsWaitResp = await fetch( + new URL(`/threads/${encodeURIComponent(threadId)}/runs/wait`, url1), + { + body: JSON.stringify({ input, route: routeKey }), + headers: { "content-type": "application/json" }, + method: "POST", + }, + ) + expect( + runsWaitResp.status, + `runs/wait failed with ${runsWaitResp.status}: ${await runsWaitResp.clone().text()}`, + ).toBe(200) + + // Fetch the state from the first server + const stateResp1 = await fetch( + new URL(`/threads/${encodeURIComponent(threadId)}/state`, url1), + ) + expect( + stateResp1.status, + `GET /state failed with ${stateResp1.status} on first server: ${await stateResp1.clone().text()}`, + ).toBe(200) + stateBefore = (await stateResp1.json()) as Record + + // Sanity-check: the checkpoint has messages + const values = stateBefore.values as Record | undefined + expect(values).toBeDefined() + expect(Array.isArray(values?.messages)).toBe(true) + expect((values?.messages as unknown[]).length).toBeGreaterThan(0) + } finally { + await server1.stop() + await appendDevServerTranscript(transcriptPath, server1) + } + + // ------------------------------------------------------------------ + // 3. Restart on a new port (same appRoot → same .dawn directory) + // ------------------------------------------------------------------ + const port2 = await allocatePort() + const server2 = await startDevServer({ cwd: appRoot, port: port2 }) + + try { + const url2 = await server2.waitForReady(30_000) + + // Re-fetch state from the second server + const stateResp2 = await fetch( + new URL(`/threads/${encodeURIComponent(threadId)}/state`, url2), + ) + expect( + stateResp2.status, + `GET /state failed with ${stateResp2.status} on second server: ${await stateResp2.clone().text()}`, + ).toBe(200) + const stateAfter = (await stateResp2.json()) as Record + + // ------------------------------------------------------------------ + // 4. Assert state matches across restart + // ------------------------------------------------------------------ + const valuesBefore = stateBefore.values as Record + const valuesAfter = stateAfter.values as Record + + const msgsBefore = valuesBefore.messages as unknown[] + const msgsAfter = valuesAfter.messages as unknown[] + + expect(msgsAfter.length).toBe(msgsBefore.length) + // Verify the config (thread_id) is preserved + expect((stateAfter.config as Record | undefined)?.configurable).toEqual( + (stateBefore.config as Record | undefined)?.configurable, + ) + } finally { + await server2.stop() + await appendDevServerTranscript(transcriptPath, server2) + } + }, + ) +}) + +// --------------------------------------------------------------------------- +// Helpers (mirrors run-runtime-contract.test.ts) +// --------------------------------------------------------------------------- + +async function rewriteDependenciesToTarballs(options: { + readonly appRoot: string + readonly tarballs: Readonly> +}): Promise { + const packageJsonPath = join(options.appRoot, "package.json") + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { + dependencies?: Record + devDependencies?: Record + pnpm?: { + overrides?: Record + } + } + + delete packageJson.dependencies?.langchain + delete packageJson.dependencies?.["@langchain/openai"] + packageJson.dependencies = { + ...packageJson.dependencies, + "@dawn-ai/cli": options.tarballs["@dawn-ai/cli"], + "@dawn-ai/core": options.tarballs["@dawn-ai/core"], + "@dawn-ai/langchain": options.tarballs["@dawn-ai/langchain"], + "@dawn-ai/permissions": options.tarballs["@dawn-ai/permissions"], + "@dawn-ai/sdk": options.tarballs["@dawn-ai/sdk"], + "@dawn-ai/sqlite-storage": options.tarballs["@dawn-ai/sqlite-storage"], + "@dawn-ai/workspace": options.tarballs["@dawn-ai/workspace"], + // Required so the echo-agent route can import it directly (pnpm strict isolation) + "@langchain/langgraph": "1.3.0", + } + packageJson.devDependencies = { + ...packageJson.devDependencies, + "@dawn-ai/config-typescript": options.tarballs["@dawn-ai/config-typescript"], + } + packageJson.pnpm = { + ...(packageJson.pnpm ?? {}), + overrides: { + ...(packageJson.pnpm?.overrides ?? {}), + "@dawn-ai/cli": options.tarballs["@dawn-ai/cli"], + "@dawn-ai/config-typescript": options.tarballs["@dawn-ai/config-typescript"], + "@dawn-ai/core": options.tarballs["@dawn-ai/core"], + "@dawn-ai/langchain": options.tarballs["@dawn-ai/langchain"], + "@dawn-ai/langgraph": options.tarballs["@dawn-ai/langgraph"], + "@dawn-ai/permissions": options.tarballs["@dawn-ai/permissions"], + "@dawn-ai/sdk": options.tarballs["@dawn-ai/sdk"], + "@dawn-ai/sqlite-storage": options.tarballs["@dawn-ai/sqlite-storage"], + "@dawn-ai/workspace": options.tarballs["@dawn-ai/workspace"], + }, + } + + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8") +} diff --git a/test/runtime/run-runtime-contract.test.ts b/test/runtime/run-runtime-contract.test.ts index cb17c796..4370f96f 100644 --- a/test/runtime/run-runtime-contract.test.ts +++ b/test/runtime/run-runtime-contract.test.ts @@ -484,6 +484,7 @@ async function withRuntimeScenario( "@dawn-ai/langgraph", "@dawn-ai/permissions", "@dawn-ai/sdk", + "@dawn-ai/sqlite-storage", "@dawn-ai/workspace", ], tempRoot, @@ -716,6 +717,7 @@ async function rewriteDependenciesToTarballs(options: { "@dawn-ai/langchain": options.tarballs["@dawn-ai/langchain"], "@dawn-ai/permissions": options.tarballs["@dawn-ai/permissions"], "@dawn-ai/sdk": options.tarballs["@dawn-ai/sdk"], + "@dawn-ai/sqlite-storage": options.tarballs["@dawn-ai/sqlite-storage"], "@dawn-ai/workspace": options.tarballs["@dawn-ai/workspace"], } packageJson.devDependencies = { @@ -733,6 +735,7 @@ async function rewriteDependenciesToTarballs(options: { "@dawn-ai/langgraph": options.tarballs["@dawn-ai/langgraph"], "@dawn-ai/permissions": options.tarballs["@dawn-ai/permissions"], "@dawn-ai/sdk": options.tarballs["@dawn-ai/sdk"], + "@dawn-ai/sqlite-storage": options.tarballs["@dawn-ai/sqlite-storage"], "@dawn-ai/workspace": options.tarballs["@dawn-ai/workspace"], }, } @@ -810,6 +813,11 @@ async function runCommand(options: { args: options.args, command: options.command, cwd: options.cwd, + env: { + // Suppress Node.js experimental-feature warnings (e.g. node:sqlite) + // so the harness does not treat non-empty stderr as a failure. + NODE_NO_WARNINGS: "1", + }, }) await appendTranscript(options.transcriptPath, result) @@ -960,6 +968,12 @@ async function runCommandWithInput(options: { }>((resolvePromise, rejectPromise) => { const child = spawn(options.command, [...options.args], { cwd: options.cwd, + env: { + ...process.env, + // Suppress Node.js experimental-feature warnings (e.g. node:sqlite) + // so the harness does not treat non-empty stderr as a failure. + NODE_NO_WARNINGS: "1", + }, stdio: ["pipe", "pipe", "pipe"], }) diff --git a/test/runtime/support/dev-server.ts b/test/runtime/support/dev-server.ts index fc09662a..aabbac69 100644 --- a/test/runtime/support/dev-server.ts +++ b/test/runtime/support/dev-server.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process" +import { randomUUID } from "node:crypto" import { access, appendFile, mkdir, writeFile } from "node:fs/promises" import { createServer } from "node:net" import { dirname, join } from "node:path" @@ -68,18 +69,11 @@ export async function delay(ms: number): Promise { } export async function invokeRunsWait(baseUrl: string, invocation: RunsWaitInvocation): Promise { - return await fetch(new URL("/runs/wait", baseUrl), { + const threadId = `t-test-${randomUUID().slice(0, 8)}` + return await fetch(new URL(`/threads/${encodeURIComponent(threadId)}/runs/wait`, baseUrl), { body: JSON.stringify({ - assistant_id: invocation.assistantId, input: invocation.input, - metadata: { - dawn: { - mode: invocation.mode, - route_id: invocation.routeId, - route_path: invocation.routePath, - }, - }, - on_completion: "delete", + route: invocation.assistantId, }), headers: { "content-type": "application/json", @@ -92,7 +86,8 @@ export async function postRunsWait(baseUrl: string, options: { readonly body: string readonly headers?: Readonly> }): Promise { - return await fetch(new URL("/runs/wait", baseUrl), { + const threadId = `t-test-${randomUUID().slice(0, 8)}` + return await fetch(new URL(`/threads/${encodeURIComponent(threadId)}/runs/wait`, baseUrl), { body: options.body, headers: { "content-type": "application/json", @@ -117,6 +112,9 @@ export async function startDevServer(options: { cwd: options.cwd, env: { ...process.env, + // Suppress Node.js experimental-feature warnings (e.g. node:sqlite) + // so the test harness does not treat stderr output as a failure. + NODE_NO_WARNINGS: "1", ...(options.env ?? {}), }, stdio: ["ignore", "pipe", "pipe"], diff --git a/test/runtime/support/fake-agent-server.ts b/test/runtime/support/fake-agent-server.ts index e7f209a8..ce2305ab 100644 --- a/test/runtime/support/fake-agent-server.ts +++ b/test/runtime/support/fake-agent-server.ts @@ -21,13 +21,14 @@ export interface FakeAgentServer { readonly url: string } +const AP_RUNS_WAIT_PATTERN = /^\/threads\/[^/?#]+\/runs\/wait(?:\?.*)?$/ + export async function startFakeAgentServer( handler: (request: FakeAgentServerRequest) => Promise, - requestPath = "/runs/wait", ): Promise { const requests: FakeAgentServerRequest[] = [] const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { - if (request.method !== "POST" || request.url !== requestPath) { + if (request.method !== "POST" || !AP_RUNS_WAIT_PATTERN.test(request.url ?? "")) { response.statusCode = 404 response.setHeader("content-type", "application/json") response.end(JSON.stringify({ error: "not found" })) diff --git a/test/runtime/vitest.config.ts b/test/runtime/vitest.config.ts index dc5bea63..9e7e53e6 100644 --- a/test/runtime/vitest.config.ts +++ b/test/runtime/vitest.config.ts @@ -18,7 +18,10 @@ export default defineConfig({ test: { environment: "node", hookTimeout: 60_000, - include: ["test/runtime/run-runtime-contract.test.ts"], + include: [ + "test/runtime/run-runtime-contract.test.ts", + "test/runtime/run-agent-protocol.test.ts", + ], testTimeout: 240_000, }, }) diff --git a/test/smoke/run-smoke.test.ts b/test/smoke/run-smoke.test.ts index a6f78d2a..431c2c0d 100644 --- a/test/smoke/run-smoke.test.ts +++ b/test/smoke/run-smoke.test.ts @@ -161,6 +161,7 @@ async function runSmokeScenario(fixtureName: SmokeFixtureName): Promise