diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 07bd55a..64e4de4 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -22,36 +22,15 @@ icon: "rocket" - - Each coding agent requires API keys to connect to their respective LLM providers. - - ```bash - export ANTHROPIC_API_KEY="sk-ant-..." - export OPENAI_API_KEY="sk-..." - ``` - - - - Use `sandbox-agent credentials extract-env --export` to extract your existing API keys (Anthropic, OpenAI, etc.) from local Claude Code or Codex config files. - - - Use the `mock` agent for SDK and integration testing without provider credentials. - - - For per-tenant token tracking, budget enforcement, or usage-based billing, see [LLM Credentials](/llm-credentials) for gateway options like OpenRouter, LiteLLM, and Portkey. - - - - - `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. + `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. Pass your LLM API keys so the agent can reach its provider. ```typescript Local import { SandboxAgent } from "sandbox-agent"; import { local } from "sandbox-agent/local"; - // Runs on your machine. Best for local development and testing. + // Runs on your machine. Inherits process.env automatically. const sdk = await SandboxAgent.start({ sandbox: local(), }); @@ -62,7 +41,15 @@ icon: "rocket" import { e2b } from "sandbox-agent/e2b"; const sdk = await SandboxAgent.start({ - sandbox: e2b({ create: { envs } }), + sandbox: e2b({ + create: { + // Pass whichever keys your agent needs + envs: { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + }, + }, + }), }); ``` @@ -71,7 +58,14 @@ icon: "rocket" import { daytona } from "sandbox-agent/daytona"; const sdk = await SandboxAgent.start({ - sandbox: daytona({ create: { envVars } }), + sandbox: daytona({ + create: { + envVars: { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + }, + }, + }), }); ``` @@ -80,7 +74,15 @@ icon: "rocket" import { vercel } from "sandbox-agent/vercel"; const sdk = await SandboxAgent.start({ - sandbox: vercel({ create: { runtime: "node24", env } }), + sandbox: vercel({ + create: { + runtime: "node24", + env: { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + }, + }, + }), }); ``` @@ -100,13 +102,16 @@ icon: "rocket" // Good for testing. Not security-hardened like cloud sandboxes. const sdk = await SandboxAgent.start({ sandbox: docker({ - env: [`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`], + env: [ + `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, + `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, + ], }), }); ``` - Each provider handles provisioning, server installation, and networking. Install the provider's peer dependency (e.g. `@e2b/code-interpreter`, `dockerode`) in your project. See the [Deploy](/deploy/local) guides for full setup details. + Each provider handles provisioning, server installation, and networking. Install the provider's peer dependency (e.g. `@e2b/code-interpreter`, `dockerode`) in your project. See the [Deploy](/deploy/local) guides for full setup details. For multi-tenant billing, per-user keys, and gateway options, see [LLM Credentials](/llm-credentials). @@ -212,10 +217,14 @@ icon: "rocket" ```typescript import { SandboxAgent } from "sandbox-agent"; -import { local } from "sandbox-agent/local"; +import { e2b } from "sandbox-agent/e2b"; const sdk = await SandboxAgent.start({ - sandbox: local(), + sandbox: e2b({ + create: { + envs: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }, + }, + }), }); try { diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index 1b8a707..a0f9b84 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -23,12 +23,6 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. -## Optional persistence drivers - -```bash -npm install @sandbox-agent/persist-indexeddb@0.3.x @sandbox-agent/persist-sqlite@0.3.x @sandbox-agent/persist-postgres@0.3.x -``` - ## Optional React components ```bash @@ -68,15 +62,12 @@ const sdk = await SandboxAgent.connect({ controller.abort(); ``` -With persistence: +With persistence (see [Persisting Sessions](/session-persistence) for driver options): ```ts -import { SandboxAgent } from "sandbox-agent"; -import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite"; +import { SandboxAgent, InMemorySessionPersistDriver } from "sandbox-agent"; -const persist = new SQLiteSessionPersistDriver({ - filename: "./sessions.db", -}); +const persist = new InMemorySessionPersistDriver(); const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468", diff --git a/docs/session-persistence.mdx b/docs/session-persistence.mdx index b8328ec..8f26457 100644 --- a/docs/session-persistence.mdx +++ b/docs/session-persistence.mdx @@ -15,9 +15,9 @@ Each driver stores: ## Persistence drivers -### In-memory +### In-memory (built-in) -Best for local dev and ephemeral workloads. +Best for local dev and ephemeral workloads. No extra dependencies required. ```ts import { InMemorySessionPersistDriver, SandboxAgent } from "sandbox-agent"; @@ -33,91 +33,17 @@ const sdk = await SandboxAgent.connect({ }); ``` -### Rivet - -Recommended for sandbox orchestration with actor state. - -```bash -npm install @sandbox-agent/persist-rivet@0.3.x -``` - -```ts -import { actor } from "rivetkit"; -import { SandboxAgent } from "sandbox-agent"; -import { RivetSessionPersistDriver, type RivetPersistState } from "@sandbox-agent/persist-rivet"; - -type PersistedState = RivetPersistState & { - sandboxId: string; - baseUrl: string; -}; - -export default actor({ - createState: async () => { - return { - sandboxId: "sbx_123", - baseUrl: "http://127.0.0.1:2468", - } satisfies Partial; - }, - createVars: async (c) => { - const persist = new RivetSessionPersistDriver(c); - const sdk = await SandboxAgent.connect({ - baseUrl: c.state.baseUrl, - persist, - }); - - const session = await sdk.resumeOrCreateSession({ id: "default", agent: "codex" }); - - const unsubscribe = session.onEvent((event) => { - c.broadcast("session.event", event); - }); - - return { sdk, session, unsubscribe }; - }, - actions: { - sendMessage: async (c, message: string) => { - await c.vars.session.prompt([{ type: "text", text: message }]); - }, - }, - onSleep: async (c) => { - c.vars.unsubscribe?.(); - await c.vars.sdk.dispose(); - }, -}); -``` - -### IndexedDB - -Best for browser apps that should survive reloads. - -```bash -npm install @sandbox-agent/persist-indexeddb@0.3.x -``` - -```ts -import { SandboxAgent } from "sandbox-agent"; -import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; - -const persist = new IndexedDbSessionPersistDriver({ - databaseName: "sandbox-agent-session-store", -}); - -const sdk = await SandboxAgent.connect({ - baseUrl: "http://127.0.0.1:2468", - persist, -}); -``` - ### SQLite Best for local/server Node apps that need durable storage without a DB server. ```bash -npm install @sandbox-agent/persist-sqlite@0.3.x +npm install better-sqlite3 ``` ```ts import { SandboxAgent } from "sandbox-agent"; -import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite"; +import { SQLiteSessionPersistDriver } from "./persist.ts"; const persist = new SQLiteSessionPersistDriver({ filename: "./sandbox-agent.db", @@ -129,17 +55,19 @@ const sdk = await SandboxAgent.connect({ }); ``` +See the [full SQLite example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite) for the complete driver implementation you can copy into your project. + ### Postgres Use when you already run Postgres and want shared relational storage. ```bash -npm install @sandbox-agent/persist-postgres@0.3.x +npm install pg ``` ```ts import { SandboxAgent } from "sandbox-agent"; -import { PostgresSessionPersistDriver } from "@sandbox-agent/persist-postgres"; +import { PostgresSessionPersistDriver } from "./persist.ts"; const persist = new PostgresSessionPersistDriver({ connectionString: process.env.DATABASE_URL, @@ -152,6 +80,16 @@ const sdk = await SandboxAgent.connect({ }); ``` +See the [full Postgres example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres) for the complete driver implementation you can copy into your project. + +### IndexedDB (browser) + +Best for browser apps that should survive reloads. See the [Inspector source](https://github.com/nichochar/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts) for a complete IndexedDB driver you can copy into your project. + +### Rivet + +Recommended for sandbox orchestration with actor state. See [Multiplayer](/multiplayer) for a full Rivet actor example with inline persistence. + ### Custom driver Implement `SessionPersistDriver` for custom backends. diff --git a/examples/persist-postgres/package.json b/examples/persist-postgres/package.json index 8114ffb..8445516 100644 --- a/examples/persist-postgres/package.json +++ b/examples/persist-postgres/package.json @@ -8,7 +8,6 @@ }, "dependencies": { "@sandbox-agent/example-shared": "workspace:*", - "@sandbox-agent/persist-postgres": "workspace:*", "pg": "latest", "sandbox-agent": "workspace:*" }, diff --git a/examples/persist-postgres/src/index.ts b/examples/persist-postgres/src/index.ts index 73f9f04..43eecbd 100644 --- a/examples/persist-postgres/src/index.ts +++ b/examples/persist-postgres/src/index.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { Client } from "pg"; import { setTimeout as delay } from "node:timers/promises"; import { SandboxAgent } from "sandbox-agent"; -import { PostgresSessionPersistDriver } from "@sandbox-agent/persist-postgres"; +import { PostgresSessionPersistDriver } from "./persist.ts"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import { detectAgent } from "@sandbox-agent/example-shared"; diff --git a/examples/persist-postgres/src/persist.ts b/examples/persist-postgres/src/persist.ts new file mode 100644 index 0000000..8b79791 --- /dev/null +++ b/examples/persist-postgres/src/persist.ts @@ -0,0 +1,316 @@ +import { Pool, type PoolConfig } from "pg"; +import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; + +const DEFAULT_LIST_LIMIT = 100; + +export interface PostgresSessionPersistDriverOptions { + connectionString?: string; + pool?: Pool; + poolConfig?: PoolConfig; + schema?: string; +} + +export class PostgresSessionPersistDriver implements SessionPersistDriver { + private readonly pool: Pool; + private readonly ownsPool: boolean; + private readonly schema: string; + private readonly initialized: Promise; + + constructor(options: PostgresSessionPersistDriverOptions = {}) { + this.schema = normalizeSchema(options.schema ?? "public"); + + if (options.pool) { + this.pool = options.pool; + this.ownsPool = false; + } else { + this.pool = new Pool({ + connectionString: options.connectionString, + ...options.poolConfig, + }); + this.ownsPool = true; + } + + this.initialized = this.initialize(); + } + + async getSession(id: string): Promise { + await this.ready(); + + const result = await this.pool.query( + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + FROM ${this.table("sessions")} + WHERE id = $1`, + [id], + ); + + if (result.rows.length === 0) { + return undefined; + } + + return decodeSessionRow(result.rows[0]); + } + + async listSessions(request: ListPageRequest = {}): Promise> { + await this.ready(); + + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + + const rowsResult = await this.pool.query( + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + FROM ${this.table("sessions")} + ORDER BY created_at ASC, id ASC + LIMIT $1 OFFSET $2`, + [limit, offset], + ); + + const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("sessions")}`); + const total = parseInteger(countResult.rows[0]?.count ?? "0"); + const nextOffset = offset + rowsResult.rows.length; + + return { + items: rowsResult.rows.map(decodeSessionRow), + nextCursor: nextOffset < total ? String(nextOffset) : undefined, + }; + } + + async updateSession(session: SessionRecord): Promise { + await this.ready(); + + await this.pool.query( + `INSERT INTO ${this.table("sessions")} ( + id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT(id) DO UPDATE SET + agent = EXCLUDED.agent, + agent_session_id = EXCLUDED.agent_session_id, + last_connection_id = EXCLUDED.last_connection_id, + created_at = EXCLUDED.created_at, + destroyed_at = EXCLUDED.destroyed_at, + sandbox_id = EXCLUDED.sandbox_id, + session_init_json = EXCLUDED.session_init_json`, + [ + session.id, + session.agent, + session.agentSessionId, + session.lastConnectionId, + session.createdAt, + session.destroyedAt ?? null, + session.sandboxId ?? null, + session.sessionInit ?? null, + ], + ); + } + + async listEvents(request: ListEventsRequest): Promise> { + await this.ready(); + + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + + const rowsResult = await this.pool.query( + `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json + FROM ${this.table("events")} + WHERE session_id = $1 + ORDER BY event_index ASC, id ASC + LIMIT $2 OFFSET $3`, + [request.sessionId, limit, offset], + ); + + const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("events")} WHERE session_id = $1`, [ + request.sessionId, + ]); + const total = parseInteger(countResult.rows[0]?.count ?? "0"); + const nextOffset = offset + rowsResult.rows.length; + + return { + items: rowsResult.rows.map(decodeEventRow), + nextCursor: nextOffset < total ? String(nextOffset) : undefined, + }; + } + + async insertEvent(_sessionId: string, event: SessionEvent): Promise { + await this.ready(); + + await this.pool.query( + `INSERT INTO ${this.table("events")} ( + id, event_index, session_id, created_at, connection_id, sender, payload_json + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT(id) DO UPDATE SET + event_index = EXCLUDED.event_index, + session_id = EXCLUDED.session_id, + created_at = EXCLUDED.created_at, + connection_id = EXCLUDED.connection_id, + sender = EXCLUDED.sender, + payload_json = EXCLUDED.payload_json`, + [event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, event.payload], + ); + } + + async close(): Promise { + if (!this.ownsPool) { + return; + } + await this.pool.end(); + } + + private async ready(): Promise { + await this.initialized; + } + + private table(name: "sessions" | "events"): string { + return `"${this.schema}"."${name}"`; + } + + private async initialize(): Promise { + await this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS ${this.table("sessions")} ( + id TEXT PRIMARY KEY, + agent TEXT NOT NULL, + agent_session_id TEXT NOT NULL, + last_connection_id TEXT NOT NULL, + created_at BIGINT NOT NULL, + destroyed_at BIGINT, + sandbox_id TEXT, + session_init_json JSONB + ) + `); + + await this.pool.query(` + ALTER TABLE ${this.table("sessions")} + ADD COLUMN IF NOT EXISTS sandbox_id TEXT + `); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS ${this.table("events")} ( + id TEXT PRIMARY KEY, + event_index BIGINT NOT NULL, + session_id TEXT NOT NULL, + created_at BIGINT NOT NULL, + connection_id TEXT NOT NULL, + sender TEXT NOT NULL, + payload_json JSONB NOT NULL + ) + `); + + await this.pool.query(` + ALTER TABLE ${this.table("events")} + ALTER COLUMN id TYPE TEXT USING id::TEXT + `); + + await this.pool.query(` + ALTER TABLE ${this.table("events")} + ADD COLUMN IF NOT EXISTS event_index BIGINT + `); + + await this.pool.query(` + WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC) AS ranked_index + FROM ${this.table("events")} + ) + UPDATE ${this.table("events")} AS current_events + SET event_index = ranked.ranked_index + FROM ranked + WHERE current_events.id = ranked.id + AND current_events.event_index IS NULL + `); + + await this.pool.query(` + ALTER TABLE ${this.table("events")} + ALTER COLUMN event_index SET NOT NULL + `); + + await this.pool.query(` + CREATE INDEX IF NOT EXISTS idx_events_session_order + ON ${this.table("events")}(session_id, event_index, id) + `); + } +} + +type SessionRow = { + id: string; + agent: string; + agent_session_id: string; + last_connection_id: string; + created_at: string | number; + destroyed_at: string | number | null; + sandbox_id: string | null; + session_init_json: unknown | null; +}; + +type EventRow = { + id: string | number; + event_index: string | number; + session_id: string; + created_at: string | number; + connection_id: string; + sender: string; + payload_json: unknown; +}; + +function decodeSessionRow(row: SessionRow): SessionRecord { + return { + id: row.id, + agent: row.agent, + agentSessionId: row.agent_session_id, + lastConnectionId: row.last_connection_id, + createdAt: parseInteger(row.created_at), + destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at), + sandboxId: row.sandbox_id ?? undefined, + sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined, + }; +} + +function decodeEventRow(row: EventRow): SessionEvent { + return { + id: String(row.id), + eventIndex: parseInteger(row.event_index), + sessionId: row.session_id, + createdAt: parseInteger(row.created_at), + connectionId: row.connection_id, + sender: parseSender(row.sender), + payload: row.payload_json as SessionEvent["payload"], + }; +} + +function normalizeLimit(limit: number | undefined): number { + if (!Number.isFinite(limit) || (limit ?? 0) < 1) { + return DEFAULT_LIST_LIMIT; + } + return Math.floor(limit as number); +} + +function parseCursor(cursor: string | undefined): number { + if (!cursor) { + return 0; + } + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +} + +function parseInteger(value: string | number): number { + const parsed = typeof value === "number" ? value : Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid integer value returned by postgres: ${String(value)}`); + } + return parsed; +} + +function parseSender(value: string): SessionEvent["sender"] { + if (value === "agent" || value === "client") { + return value; + } + throw new Error(`Invalid sender value returned by postgres: ${value}`); +} + +function normalizeSchema(schema: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) { + throw new Error(`Invalid schema name '${schema}'. Use letters, numbers, and underscores only.`); + } + return schema; +} diff --git a/examples/persist-sqlite/package.json b/examples/persist-sqlite/package.json index 8b7b822..be6bf0d 100644 --- a/examples/persist-sqlite/package.json +++ b/examples/persist-sqlite/package.json @@ -8,10 +8,11 @@ }, "dependencies": { "@sandbox-agent/example-shared": "workspace:*", - "@sandbox-agent/persist-sqlite": "workspace:*", + "better-sqlite3": "^11.0.0", "sandbox-agent": "workspace:*" }, "devDependencies": { + "@types/better-sqlite3": "^7.0.0", "@types/node": "latest", "tsx": "latest", "typescript": "latest" diff --git a/examples/persist-sqlite/src/index.ts b/examples/persist-sqlite/src/index.ts index d2c4ef2..943e902 100644 --- a/examples/persist-sqlite/src/index.ts +++ b/examples/persist-sqlite/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite"; +import { SQLiteSessionPersistDriver } from "./persist.ts"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import { detectAgent } from "@sandbox-agent/example-shared"; diff --git a/examples/persist-sqlite/src/persist.ts b/examples/persist-sqlite/src/persist.ts new file mode 100644 index 0000000..b04b0fc --- /dev/null +++ b/examples/persist-sqlite/src/persist.ts @@ -0,0 +1,294 @@ +import Database from "better-sqlite3"; +import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; + +const DEFAULT_LIST_LIMIT = 100; + +export interface SQLiteSessionPersistDriverOptions { + filename?: string; +} + +export class SQLiteSessionPersistDriver implements SessionPersistDriver { + private readonly db: Database.Database; + + constructor(options: SQLiteSessionPersistDriverOptions = {}) { + this.db = new Database(options.filename ?? ":memory:"); + this.initialize(); + } + + async getSession(id: string): Promise { + const row = this.db + .prepare( + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + FROM sessions WHERE id = ?`, + ) + .get(id) as SessionRow | undefined; + + if (!row) { + return undefined; + } + + return decodeSessionRow(row); + } + + async listSessions(request: ListPageRequest = {}): Promise> { + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + + const rows = this.db + .prepare( + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + FROM sessions + ORDER BY created_at ASC, id ASC + LIMIT ? OFFSET ?`, + ) + .all(limit, offset) as SessionRow[]; + + const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM sessions`).get() as { count: number }; + const nextOffset = offset + rows.length; + + return { + items: rows.map(decodeSessionRow), + nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined, + }; + } + + async updateSession(session: SessionRecord): Promise { + this.db + .prepare( + `INSERT INTO sessions ( + id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + agent = excluded.agent, + agent_session_id = excluded.agent_session_id, + last_connection_id = excluded.last_connection_id, + created_at = excluded.created_at, + destroyed_at = excluded.destroyed_at, + sandbox_id = excluded.sandbox_id, + session_init_json = excluded.session_init_json`, + ) + .run( + session.id, + session.agent, + session.agentSessionId, + session.lastConnectionId, + session.createdAt, + session.destroyedAt ?? null, + session.sandboxId ?? null, + session.sessionInit ? JSON.stringify(session.sessionInit) : null, + ); + } + + async listEvents(request: ListEventsRequest): Promise> { + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + + const rows = this.db + .prepare( + `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json + FROM events + WHERE session_id = ? + ORDER BY event_index ASC, id ASC + LIMIT ? OFFSET ?`, + ) + .all(request.sessionId, limit, offset) as EventRow[]; + + const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM events WHERE session_id = ?`).get(request.sessionId) as { count: number }; + + const nextOffset = offset + rows.length; + + return { + items: rows.map(decodeEventRow), + nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined, + }; + } + + async insertEvent(_sessionId: string, event: SessionEvent): Promise { + this.db + .prepare( + `INSERT INTO events ( + id, event_index, session_id, created_at, connection_id, sender, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + event_index = excluded.event_index, + session_id = excluded.session_id, + created_at = excluded.created_at, + connection_id = excluded.connection_id, + sender = excluded.sender, + payload_json = excluded.payload_json`, + ) + .run(event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, JSON.stringify(event.payload)); + } + + close(): void { + this.db.close(); + } + + private initialize(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + agent TEXT NOT NULL, + agent_session_id TEXT NOT NULL, + last_connection_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + destroyed_at INTEGER, + sandbox_id TEXT, + session_init_json TEXT + ) + `); + + const sessionColumns = this.db.prepare(`PRAGMA table_info(sessions)`).all() as TableInfoRow[]; + if (!sessionColumns.some((column) => column.name === "sandbox_id")) { + this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`); + } + + this.ensureEventsTable(); + } + + private ensureEventsTable(): void { + const tableInfo = this.db.prepare(`PRAGMA table_info(events)`).all() as TableInfoRow[]; + if (tableInfo.length === 0) { + this.createEventsTable(); + return; + } + + const idColumn = tableInfo.find((column) => column.name === "id"); + const hasEventIndex = tableInfo.some((column) => column.name === "event_index"); + const idType = (idColumn?.type ?? "").trim().toUpperCase(); + const idIsText = idType === "TEXT"; + + if (!idIsText || !hasEventIndex) { + this.rebuildEventsTable(hasEventIndex); + } + + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_events_session_order + ON events(session_id, event_index, id) + `); + } + + private createEventsTable(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + event_index INTEGER NOT NULL, + session_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + connection_id TEXT NOT NULL, + sender TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_events_session_order + ON events(session_id, event_index, id) + `); + } + + private rebuildEventsTable(hasEventIndex: boolean): void { + this.db.exec(` + ALTER TABLE events RENAME TO events_legacy; + `); + + this.createEventsTable(); + + if (hasEventIndex) { + this.db.exec(` + INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json) + SELECT + CAST(id AS TEXT), + COALESCE(event_index, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC)), + session_id, + created_at, + connection_id, + sender, + payload_json + FROM events_legacy + `); + } else { + this.db.exec(` + INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json) + SELECT + CAST(id AS TEXT), + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC), + session_id, + created_at, + connection_id, + sender, + payload_json + FROM events_legacy + `); + } + + this.db.exec(`DROP TABLE events_legacy`); + } +} + +type SessionRow = { + id: string; + agent: string; + agent_session_id: string; + last_connection_id: string; + created_at: number; + destroyed_at: number | null; + sandbox_id: string | null; + session_init_json: string | null; +}; + +type EventRow = { + id: string; + event_index: number; + session_id: string; + created_at: number; + connection_id: string; + sender: "client" | "agent"; + payload_json: string; +}; + +type TableInfoRow = { + name: string; + type: string; +}; + +function decodeSessionRow(row: SessionRow): SessionRecord { + return { + id: row.id, + agent: row.agent, + agentSessionId: row.agent_session_id, + lastConnectionId: row.last_connection_id, + createdAt: row.created_at, + destroyedAt: row.destroyed_at ?? undefined, + sandboxId: row.sandbox_id ?? undefined, + sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined, + }; +} + +function decodeEventRow(row: EventRow): SessionEvent { + return { + id: row.id, + eventIndex: row.event_index, + sessionId: row.session_id, + createdAt: row.created_at, + connectionId: row.connection_id, + sender: row.sender, + payload: JSON.parse(row.payload_json), + }; +} + +function normalizeLimit(limit: number | undefined): number { + if (!Number.isFinite(limit) || (limit ?? 0) < 1) { + return DEFAULT_LIST_LIMIT; + } + return Math.floor(limit as number); +} + +function parseCursor(cursor: string | undefined): number { + if (!cursor) { + return 0; + } + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +} diff --git a/frontend/packages/inspector/package.json b/frontend/packages/inspector/package.json index 9671ecb..45b7224 100644 --- a/frontend/packages/inspector/package.json +++ b/frontend/packages/inspector/package.json @@ -6,10 +6,10 @@ "type": "module", "scripts": { "dev": "vite", - "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vite build", + "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && vite build", "preview": "vite preview", - "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && tsc --noEmit", - "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vitest run" + "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && tsc --noEmit", + "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && vitest run" }, "devDependencies": { "@sandbox-agent/react": "workspace:*", @@ -23,7 +23,6 @@ "vitest": "^3.0.0" }, "dependencies": { - "@sandbox-agent/persist-indexeddb": "workspace:*", "lucide-react": "^0.469.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index ac06904..f6e319c 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -24,7 +24,7 @@ type ConfigOption = { }; type AgentModeInfo = { id: string; name: string; description: string }; type AgentModelInfo = { id: string; name?: string }; -import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; +import { IndexedDbSessionPersistDriver } from "./persist-indexeddb"; import ChatPanel from "./components/chat/ChatPanel"; import ConnectScreen from "./components/ConnectScreen"; import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel"; diff --git a/frontend/packages/inspector/src/persist-indexeddb.ts b/frontend/packages/inspector/src/persist-indexeddb.ts new file mode 100644 index 0000000..b6af1c8 --- /dev/null +++ b/frontend/packages/inspector/src/persist-indexeddb.ts @@ -0,0 +1,314 @@ +import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; + +const DEFAULT_DB_NAME = "sandbox-agent-session-store"; +const DEFAULT_DB_VERSION = 2; +const SESSIONS_STORE = "sessions"; +const EVENTS_STORE = "events"; +const EVENTS_BY_SESSION_INDEX = "by_session_index"; +const DEFAULT_LIST_LIMIT = 100; + +export interface IndexedDbSessionPersistDriverOptions { + databaseName?: string; + databaseVersion?: number; + indexedDb?: IDBFactory; +} + +export class IndexedDbSessionPersistDriver implements SessionPersistDriver { + private readonly indexedDb: IDBFactory; + private readonly dbName: string; + private readonly dbVersion: number; + private readonly dbPromise: Promise; + + constructor(options: IndexedDbSessionPersistDriverOptions = {}) { + const indexedDb = options.indexedDb ?? globalThis.indexedDB; + if (!indexedDb) { + throw new Error("IndexedDB is not available in this runtime."); + } + + this.indexedDb = indexedDb; + this.dbName = options.databaseName ?? DEFAULT_DB_NAME; + this.dbVersion = options.databaseVersion ?? DEFAULT_DB_VERSION; + this.dbPromise = this.openDatabase(); + } + + async getSession(id: string): Promise { + const db = await this.dbPromise; + const row = await requestToPromise(db.transaction(SESSIONS_STORE, "readonly").objectStore(SESSIONS_STORE).get(id)); + if (!row || typeof row !== "object") { + return undefined; + } + return decodeSessionRow(row as SessionRow); + } + + async listSessions(request: ListPageRequest = {}): Promise> { + const db = await this.dbPromise; + const rows = await getAllRows(db, SESSIONS_STORE); + + rows.sort((a, b) => { + if (a.createdAt !== b.createdAt) { + return a.createdAt - b.createdAt; + } + return a.id.localeCompare(b.id); + }); + + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + const slice = rows.slice(offset, offset + limit).map(decodeSessionRow); + const nextOffset = offset + slice.length; + + return { + items: slice, + nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined, + }; + } + + async updateSession(session: SessionRecord): Promise { + const db = await this.dbPromise; + await transactionPromise(db, [SESSIONS_STORE], "readwrite", (tx) => { + tx.objectStore(SESSIONS_STORE).put(encodeSessionRow(session)); + }); + } + + async listEvents(request: ListEventsRequest): Promise> { + const db = await this.dbPromise; + const rows = (await getAllRows(db, EVENTS_STORE)).filter((row) => row.sessionId === request.sessionId).sort(compareEventRowsByOrder); + + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + const slice = rows.slice(offset, offset + limit).map(decodeEventRow); + const nextOffset = offset + slice.length; + + return { + items: slice, + nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined, + }; + } + + async insertEvent(_sessionId: string, event: SessionEvent): Promise { + const db = await this.dbPromise; + await transactionPromise(db, [EVENTS_STORE], "readwrite", (tx) => { + tx.objectStore(EVENTS_STORE).put(encodeEventRow(event)); + }); + } + + async close(): Promise { + const db = await this.dbPromise; + db.close(); + } + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = this.indexedDb.open(this.dbName, this.dbVersion); + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(SESSIONS_STORE)) { + db.createObjectStore(SESSIONS_STORE, { keyPath: "id" }); + } + + if (!db.objectStoreNames.contains(EVENTS_STORE)) { + const events = db.createObjectStore(EVENTS_STORE, { keyPath: "id" }); + events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], { + unique: false, + }); + } else { + const tx = request.transaction; + if (!tx) { + return; + } + const events = tx.objectStore(EVENTS_STORE); + if (!events.indexNames.contains(EVENTS_BY_SESSION_INDEX)) { + events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], { + unique: false, + }); + } + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("Unable to open IndexedDB")); + }); + } +} + +type SessionRow = { + id: string; + agent: string; + agentSessionId: string; + lastConnectionId: string; + createdAt: number; + destroyedAt?: number; + sandboxId?: string; + sessionInit?: SessionRecord["sessionInit"]; +}; + +type EventRow = { + id: number | string; + eventIndex?: number; + sessionId: string; + createdAt: number; + connectionId: string; + sender: "client" | "agent"; + payload: unknown; +}; + +function encodeSessionRow(session: SessionRecord): SessionRow { + return { + id: session.id, + agent: session.agent, + agentSessionId: session.agentSessionId, + lastConnectionId: session.lastConnectionId, + createdAt: session.createdAt, + destroyedAt: session.destroyedAt, + sandboxId: session.sandboxId, + sessionInit: session.sessionInit, + }; +} + +function decodeSessionRow(row: SessionRow): SessionRecord { + return { + id: row.id, + agent: row.agent, + agentSessionId: row.agentSessionId, + lastConnectionId: row.lastConnectionId, + createdAt: row.createdAt, + destroyedAt: row.destroyedAt, + sandboxId: row.sandboxId, + sessionInit: row.sessionInit, + }; +} + +function encodeEventRow(event: SessionEvent): EventRow { + return { + id: event.id, + eventIndex: event.eventIndex, + sessionId: event.sessionId, + createdAt: event.createdAt, + connectionId: event.connectionId, + sender: event.sender, + payload: event.payload, + }; +} + +function decodeEventRow(row: EventRow): SessionEvent { + return { + id: String(row.id), + eventIndex: parseEventIndex(row.eventIndex, row.id), + sessionId: row.sessionId, + createdAt: row.createdAt, + connectionId: row.connectionId, + sender: row.sender, + payload: row.payload as SessionEvent["payload"], + }; +} + +async function getAllRows(db: IDBDatabase, storeName: string): Promise { + return await transactionPromise(db, [storeName], "readonly", async (tx) => { + const request = tx.objectStore(storeName).getAll(); + return (await requestToPromise(request)) as T[]; + }); +} + +function normalizeLimit(limit: number | undefined): number { + if (!Number.isFinite(limit) || (limit ?? 0) < 1) { + return DEFAULT_LIST_LIMIT; + } + return Math.floor(limit as number); +} + +function parseCursor(cursor: string | undefined): number { + if (!cursor) { + return 0; + } + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +} + +function compareEventRowsByOrder(a: EventRow, b: EventRow): number { + const indexA = parseEventIndex(a.eventIndex, a.id); + const indexB = parseEventIndex(b.eventIndex, b.id); + if (indexA !== indexB) { + return indexA - indexB; + } + return String(a.id).localeCompare(String(b.id)); +} + +function parseEventIndex(value: number | undefined, fallback: number | string): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + + const parsed = Number.parseInt(String(fallback), 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")); + }); +} + +function transactionPromise(db: IDBDatabase, stores: string[], mode: IDBTransactionMode, run: (tx: IDBTransaction) => T | Promise): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(stores, mode); + let settled = false; + let resultValue: T | undefined; + let runCompleted = false; + let txCompleted = false; + + function tryResolve() { + if (settled || !runCompleted || !txCompleted) { + return; + } + settled = true; + resolve(resultValue as T); + } + + tx.oncomplete = () => { + txCompleted = true; + tryResolve(); + }; + + tx.onerror = () => { + if (settled) { + return; + } + settled = true; + reject(tx.error ?? new Error("IndexedDB transaction failed")); + }; + + tx.onabort = () => { + if (settled) { + return; + } + settled = true; + reject(tx.error ?? new Error("IndexedDB transaction aborted")); + }; + + Promise.resolve(run(tx)) + .then((value) => { + resultValue = value; + runCompleted = true; + tryResolve(); + }) + .catch((error) => { + if (!settled) { + settled = true; + reject(error); + } + try { + tx.abort(); + } catch { + // no-op + } + }); + }); +} diff --git a/sdks/persist-indexeddb/README.md b/sdks/persist-indexeddb/README.md new file mode 100644 index 0000000..e2962ef --- /dev/null +++ b/sdks/persist-indexeddb/README.md @@ -0,0 +1,5 @@ +# @sandbox-agent/persist-indexeddb + +> **Deprecated:** This package has been deprecated and removed. + +Copy the driver source directly into your project. See the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance. diff --git a/sdks/persist-indexeddb/package.json b/sdks/persist-indexeddb/package.json index 179e0be..599b951 100644 --- a/sdks/persist-indexeddb/package.json +++ b/sdks/persist-indexeddb/package.json @@ -16,23 +16,16 @@ "import": "./dist/index.js" } }, - "dependencies": { - "sandbox-agent": "workspace:*" - }, "files": [ "dist" ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/node": "^22.0.0", - "fake-indexeddb": "^6.2.4", "tsup": "^8.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "typescript": "^5.7.0" } } diff --git a/sdks/persist-indexeddb/src/index.ts b/sdks/persist-indexeddb/src/index.ts index b6af1c8..d704d05 100644 --- a/sdks/persist-indexeddb/src/index.ts +++ b/sdks/persist-indexeddb/src/index.ts @@ -1,314 +1,5 @@ -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; - -const DEFAULT_DB_NAME = "sandbox-agent-session-store"; -const DEFAULT_DB_VERSION = 2; -const SESSIONS_STORE = "sessions"; -const EVENTS_STORE = "events"; -const EVENTS_BY_SESSION_INDEX = "by_session_index"; -const DEFAULT_LIST_LIMIT = 100; - -export interface IndexedDbSessionPersistDriverOptions { - databaseName?: string; - databaseVersion?: number; - indexedDb?: IDBFactory; -} - -export class IndexedDbSessionPersistDriver implements SessionPersistDriver { - private readonly indexedDb: IDBFactory; - private readonly dbName: string; - private readonly dbVersion: number; - private readonly dbPromise: Promise; - - constructor(options: IndexedDbSessionPersistDriverOptions = {}) { - const indexedDb = options.indexedDb ?? globalThis.indexedDB; - if (!indexedDb) { - throw new Error("IndexedDB is not available in this runtime."); - } - - this.indexedDb = indexedDb; - this.dbName = options.databaseName ?? DEFAULT_DB_NAME; - this.dbVersion = options.databaseVersion ?? DEFAULT_DB_VERSION; - this.dbPromise = this.openDatabase(); - } - - async getSession(id: string): Promise { - const db = await this.dbPromise; - const row = await requestToPromise(db.transaction(SESSIONS_STORE, "readonly").objectStore(SESSIONS_STORE).get(id)); - if (!row || typeof row !== "object") { - return undefined; - } - return decodeSessionRow(row as SessionRow); - } - - async listSessions(request: ListPageRequest = {}): Promise> { - const db = await this.dbPromise; - const rows = await getAllRows(db, SESSIONS_STORE); - - rows.sort((a, b) => { - if (a.createdAt !== b.createdAt) { - return a.createdAt - b.createdAt; - } - return a.id.localeCompare(b.id); - }); - - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - const slice = rows.slice(offset, offset + limit).map(decodeSessionRow); - const nextOffset = offset + slice.length; - - return { - items: slice, - nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined, - }; - } - - async updateSession(session: SessionRecord): Promise { - const db = await this.dbPromise; - await transactionPromise(db, [SESSIONS_STORE], "readwrite", (tx) => { - tx.objectStore(SESSIONS_STORE).put(encodeSessionRow(session)); - }); - } - - async listEvents(request: ListEventsRequest): Promise> { - const db = await this.dbPromise; - const rows = (await getAllRows(db, EVENTS_STORE)).filter((row) => row.sessionId === request.sessionId).sort(compareEventRowsByOrder); - - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - const slice = rows.slice(offset, offset + limit).map(decodeEventRow); - const nextOffset = offset + slice.length; - - return { - items: slice, - nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined, - }; - } - - async insertEvent(_sessionId: string, event: SessionEvent): Promise { - const db = await this.dbPromise; - await transactionPromise(db, [EVENTS_STORE], "readwrite", (tx) => { - tx.objectStore(EVENTS_STORE).put(encodeEventRow(event)); - }); - } - - async close(): Promise { - const db = await this.dbPromise; - db.close(); - } - - private openDatabase(): Promise { - return new Promise((resolve, reject) => { - const request = this.indexedDb.open(this.dbName, this.dbVersion); - - request.onupgradeneeded = () => { - const db = request.result; - - if (!db.objectStoreNames.contains(SESSIONS_STORE)) { - db.createObjectStore(SESSIONS_STORE, { keyPath: "id" }); - } - - if (!db.objectStoreNames.contains(EVENTS_STORE)) { - const events = db.createObjectStore(EVENTS_STORE, { keyPath: "id" }); - events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], { - unique: false, - }); - } else { - const tx = request.transaction; - if (!tx) { - return; - } - const events = tx.objectStore(EVENTS_STORE); - if (!events.indexNames.contains(EVENTS_BY_SESSION_INDEX)) { - events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], { - unique: false, - }); - } - } - }; - - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error ?? new Error("Unable to open IndexedDB")); - }); - } -} - -type SessionRow = { - id: string; - agent: string; - agentSessionId: string; - lastConnectionId: string; - createdAt: number; - destroyedAt?: number; - sandboxId?: string; - sessionInit?: SessionRecord["sessionInit"]; -}; - -type EventRow = { - id: number | string; - eventIndex?: number; - sessionId: string; - createdAt: number; - connectionId: string; - sender: "client" | "agent"; - payload: unknown; -}; - -function encodeSessionRow(session: SessionRecord): SessionRow { - return { - id: session.id, - agent: session.agent, - agentSessionId: session.agentSessionId, - lastConnectionId: session.lastConnectionId, - createdAt: session.createdAt, - destroyedAt: session.destroyedAt, - sandboxId: session.sandboxId, - sessionInit: session.sessionInit, - }; -} - -function decodeSessionRow(row: SessionRow): SessionRecord { - return { - id: row.id, - agent: row.agent, - agentSessionId: row.agentSessionId, - lastConnectionId: row.lastConnectionId, - createdAt: row.createdAt, - destroyedAt: row.destroyedAt, - sandboxId: row.sandboxId, - sessionInit: row.sessionInit, - }; -} - -function encodeEventRow(event: SessionEvent): EventRow { - return { - id: event.id, - eventIndex: event.eventIndex, - sessionId: event.sessionId, - createdAt: event.createdAt, - connectionId: event.connectionId, - sender: event.sender, - payload: event.payload, - }; -} - -function decodeEventRow(row: EventRow): SessionEvent { - return { - id: String(row.id), - eventIndex: parseEventIndex(row.eventIndex, row.id), - sessionId: row.sessionId, - createdAt: row.createdAt, - connectionId: row.connectionId, - sender: row.sender, - payload: row.payload as SessionEvent["payload"], - }; -} - -async function getAllRows(db: IDBDatabase, storeName: string): Promise { - return await transactionPromise(db, [storeName], "readonly", async (tx) => { - const request = tx.objectStore(storeName).getAll(); - return (await requestToPromise(request)) as T[]; - }); -} - -function normalizeLimit(limit: number | undefined): number { - if (!Number.isFinite(limit) || (limit ?? 0) < 1) { - return DEFAULT_LIST_LIMIT; - } - return Math.floor(limit as number); -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} - -function compareEventRowsByOrder(a: EventRow, b: EventRow): number { - const indexA = parseEventIndex(a.eventIndex, a.id); - const indexB = parseEventIndex(b.eventIndex, b.id); - if (indexA !== indexB) { - return indexA - indexB; - } - return String(a.id).localeCompare(String(b.id)); -} - -function parseEventIndex(value: number | undefined, fallback: number | string): number { - if (typeof value === "number" && Number.isFinite(value)) { - return Math.max(0, Math.floor(value)); - } - - const parsed = Number.parseInt(String(fallback), 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} - -function requestToPromise(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")); - }); -} - -function transactionPromise(db: IDBDatabase, stores: string[], mode: IDBTransactionMode, run: (tx: IDBTransaction) => T | Promise): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(stores, mode); - let settled = false; - let resultValue: T | undefined; - let runCompleted = false; - let txCompleted = false; - - function tryResolve() { - if (settled || !runCompleted || !txCompleted) { - return; - } - settled = true; - resolve(resultValue as T); - } - - tx.oncomplete = () => { - txCompleted = true; - tryResolve(); - }; - - tx.onerror = () => { - if (settled) { - return; - } - settled = true; - reject(tx.error ?? new Error("IndexedDB transaction failed")); - }; - - tx.onabort = () => { - if (settled) { - return; - } - settled = true; - reject(tx.error ?? new Error("IndexedDB transaction aborted")); - }; - - Promise.resolve(run(tx)) - .then((value) => { - resultValue = value; - runCompleted = true; - tryResolve(); - }) - .catch((error) => { - if (!settled) { - settled = true; - reject(error); - } - try { - tx.abort(); - } catch { - // no-op - } - }); - }); -} +throw new Error( + "@sandbox-agent/persist-indexeddb has been deprecated and removed. " + + "Copy the reference implementation into your project instead. " + + "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-indexeddb", +); diff --git a/sdks/persist-indexeddb/tests/driver.test.ts b/sdks/persist-indexeddb/tests/driver.test.ts deleted file mode 100644 index 5c743be..0000000 --- a/sdks/persist-indexeddb/tests/driver.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import "fake-indexeddb/auto"; -import { describe, it, expect } from "vitest"; -import { IndexedDbSessionPersistDriver } from "../src/index.ts"; - -function uniqueDbName(prefix: string): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -} - -describe("IndexedDbSessionPersistDriver", () => { - it("stores and pages sessions and events", async () => { - const dbName = uniqueDbName("indexeddb-driver"); - const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - await driver.updateSession({ - id: "s-2", - agent: "mock", - agentSessionId: "a-2", - lastConnectionId: "c-2", - createdAt: 200, - destroyedAt: 300, - }); - - await driver.insertEvent("s-1", { - id: "evt-1", - eventIndex: 1, - sessionId: "s-1", - createdAt: 1, - connectionId: "c-1", - sender: "client", - payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } }, - }); - - await driver.insertEvent("s-1", { - id: "evt-2", - eventIndex: 2, - sessionId: "s-1", - createdAt: 2, - connectionId: "c-1", - sender: "agent", - payload: { jsonrpc: "2.0", method: "session/update", params: { sessionId: "a-1" } }, - }); - - const loaded = await driver.getSession("s-2"); - expect(loaded?.destroyedAt).toBe(300); - - const page1 = await driver.listSessions({ limit: 1 }); - expect(page1.items).toHaveLength(1); - expect(page1.items[0]?.id).toBe("s-1"); - expect(page1.nextCursor).toBeTruthy(); - - const page2 = await driver.listSessions({ cursor: page1.nextCursor, limit: 1 }); - expect(page2.items).toHaveLength(1); - expect(page2.items[0]?.id).toBe("s-2"); - expect(page2.nextCursor).toBeUndefined(); - - const eventsPage = await driver.listEvents({ sessionId: "s-1", limit: 10 }); - expect(eventsPage.items).toHaveLength(2); - expect(eventsPage.items[0]?.id).toBe("evt-1"); - expect(eventsPage.items[0]?.eventIndex).toBe(1); - expect(eventsPage.items[1]?.id).toBe("evt-2"); - expect(eventsPage.items[1]?.eventIndex).toBe(2); - - await driver.close(); - }); - - it("persists across driver instances for same database", async () => { - const dbName = uniqueDbName("indexeddb-reopen"); - - { - const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - await driver.close(); - } - - { - const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - const session = await driver.getSession("s-1"); - expect(session?.id).toBe("s-1"); - await driver.close(); - } - }); -}); diff --git a/sdks/persist-indexeddb/tests/integration.test.ts b/sdks/persist-indexeddb/tests/integration.test.ts deleted file mode 100644 index 4a27ac5..0000000 --- a/sdks/persist-indexeddb/tests/integration.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import "fake-indexeddb/auto"; -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { tmpdir } from "node:os"; -import { SandboxAgent } from "sandbox-agent"; -import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts"; -import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts"; -import { IndexedDbSessionPersistDriver } from "../src/index.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findBinary(): string | null { - if (process.env.SANDBOX_AGENT_BIN) { - return process.env.SANDBOX_AGENT_BIN; - } - - const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")]; - - for (const p of cargoPaths) { - if (existsSync(p)) { - return p; - } - } - - return null; -} - -function uniqueDbName(prefix: string): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -} - -const BINARY_PATH = findBinary(); -if (!BINARY_PATH) { - throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN."); -} -if (!process.env.SANDBOX_AGENT_BIN) { - process.env.SANDBOX_AGENT_BIN = BINARY_PATH; -} - -describe("IndexedDB persistence end-to-end", () => { - let handle: SandboxAgentSpawnHandle; - let baseUrl: string; - let token: string; - let dataHome: string; - - beforeAll(async () => { - dataHome = mkdtempSync(join(tmpdir(), "indexeddb-integration-")); - prepareMockAgentDataHome(dataHome); - - handle = await spawnSandboxAgent({ - enabled: true, - log: "silent", - timeoutMs: 30000, - env: { - XDG_DATA_HOME: dataHome, - HOME: dataHome, - USERPROFILE: dataHome, - APPDATA: join(dataHome, "AppData", "Roaming"), - LOCALAPPDATA: join(dataHome, "AppData", "Local"), - }, - }); - baseUrl = handle.baseUrl; - token = handle.token; - }); - - afterAll(async () => { - await handle.dispose(); - rmSync(dataHome, { recursive: true, force: true }); - }); - - it("restores sessions/events across sdk instances", async () => { - const dbName = uniqueDbName("sandbox-agent-browser-e2e"); - - const persist1 = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - const sdk1 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist1, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const created = await sdk1.createSession({ agent: "mock" }); - await created.prompt([{ type: "text", text: "indexeddb-first" }]); - const firstConnectionId = created.lastConnectionId; - - await sdk1.dispose(); - await persist1.close(); - - const persist2 = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - const sdk2 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist2, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const restored = await sdk2.resumeSession(created.id); - expect(restored.lastConnectionId).not.toBe(firstConnectionId); - - await restored.prompt([{ type: "text", text: "indexeddb-second" }]); - - const sessions = await sdk2.listSessions({ limit: 20 }); - expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true); - - const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 }); - expect(events.items.length).toBeGreaterThan(0); - - const replayInjected = events.items.find((event) => { - if (event.sender !== "client") { - return false; - } - const payload = event.payload as Record; - const method = payload.method; - const params = payload.params as Record | undefined; - const prompt = Array.isArray(params?.prompt) ? params?.prompt : []; - const firstBlock = prompt[0] as Record | undefined; - return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below"); - }); - - expect(replayInjected).toBeTruthy(); - - await sdk2.dispose(); - await persist2.close(); - }); -}); diff --git a/sdks/persist-postgres/README.md b/sdks/persist-postgres/README.md new file mode 100644 index 0000000..61550cd --- /dev/null +++ b/sdks/persist-postgres/README.md @@ -0,0 +1,5 @@ +# @sandbox-agent/persist-postgres + +> **Deprecated:** This package has been deprecated and removed. The implementation now lives as a copy-paste reference in [`examples/persist-postgres`](../../examples/persist-postgres). + +Install `pg` directly and copy the driver source into your project. See the [full example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres). diff --git a/sdks/persist-postgres/package.json b/sdks/persist-postgres/package.json index 49bd9f1..caa49f6 100644 --- a/sdks/persist-postgres/package.json +++ b/sdks/persist-postgres/package.json @@ -1,7 +1,7 @@ { "name": "@sandbox-agent/persist-postgres", "version": "0.3.2", - "description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK", + "description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)", "license": "Apache-2.0", "repository": { "type": "git", @@ -16,24 +16,16 @@ "import": "./dist/index.js" } }, - "dependencies": { - "pg": "^8.16.3", - "sandbox-agent": "workspace:*" - }, "files": [ "dist" ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/node": "^22.0.0", - "@types/pg": "^8.15.6", "tsup": "^8.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "typescript": "^5.7.0" } } diff --git a/sdks/persist-postgres/src/index.ts b/sdks/persist-postgres/src/index.ts index 8b79791..0ae3e99 100644 --- a/sdks/persist-postgres/src/index.ts +++ b/sdks/persist-postgres/src/index.ts @@ -1,316 +1,5 @@ -import { Pool, type PoolConfig } from "pg"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; - -const DEFAULT_LIST_LIMIT = 100; - -export interface PostgresSessionPersistDriverOptions { - connectionString?: string; - pool?: Pool; - poolConfig?: PoolConfig; - schema?: string; -} - -export class PostgresSessionPersistDriver implements SessionPersistDriver { - private readonly pool: Pool; - private readonly ownsPool: boolean; - private readonly schema: string; - private readonly initialized: Promise; - - constructor(options: PostgresSessionPersistDriverOptions = {}) { - this.schema = normalizeSchema(options.schema ?? "public"); - - if (options.pool) { - this.pool = options.pool; - this.ownsPool = false; - } else { - this.pool = new Pool({ - connectionString: options.connectionString, - ...options.poolConfig, - }); - this.ownsPool = true; - } - - this.initialized = this.initialize(); - } - - async getSession(id: string): Promise { - await this.ready(); - - const result = await this.pool.query( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json - FROM ${this.table("sessions")} - WHERE id = $1`, - [id], - ); - - if (result.rows.length === 0) { - return undefined; - } - - return decodeSessionRow(result.rows[0]); - } - - async listSessions(request: ListPageRequest = {}): Promise> { - await this.ready(); - - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - - const rowsResult = await this.pool.query( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json - FROM ${this.table("sessions")} - ORDER BY created_at ASC, id ASC - LIMIT $1 OFFSET $2`, - [limit, offset], - ); - - const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("sessions")}`); - const total = parseInteger(countResult.rows[0]?.count ?? "0"); - const nextOffset = offset + rowsResult.rows.length; - - return { - items: rowsResult.rows.map(decodeSessionRow), - nextCursor: nextOffset < total ? String(nextOffset) : undefined, - }; - } - - async updateSession(session: SessionRecord): Promise { - await this.ready(); - - await this.pool.query( - `INSERT INTO ${this.table("sessions")} ( - id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT(id) DO UPDATE SET - agent = EXCLUDED.agent, - agent_session_id = EXCLUDED.agent_session_id, - last_connection_id = EXCLUDED.last_connection_id, - created_at = EXCLUDED.created_at, - destroyed_at = EXCLUDED.destroyed_at, - sandbox_id = EXCLUDED.sandbox_id, - session_init_json = EXCLUDED.session_init_json`, - [ - session.id, - session.agent, - session.agentSessionId, - session.lastConnectionId, - session.createdAt, - session.destroyedAt ?? null, - session.sandboxId ?? null, - session.sessionInit ?? null, - ], - ); - } - - async listEvents(request: ListEventsRequest): Promise> { - await this.ready(); - - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - - const rowsResult = await this.pool.query( - `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json - FROM ${this.table("events")} - WHERE session_id = $1 - ORDER BY event_index ASC, id ASC - LIMIT $2 OFFSET $3`, - [request.sessionId, limit, offset], - ); - - const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("events")} WHERE session_id = $1`, [ - request.sessionId, - ]); - const total = parseInteger(countResult.rows[0]?.count ?? "0"); - const nextOffset = offset + rowsResult.rows.length; - - return { - items: rowsResult.rows.map(decodeEventRow), - nextCursor: nextOffset < total ? String(nextOffset) : undefined, - }; - } - - async insertEvent(_sessionId: string, event: SessionEvent): Promise { - await this.ready(); - - await this.pool.query( - `INSERT INTO ${this.table("events")} ( - id, event_index, session_id, created_at, connection_id, sender, payload_json - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT(id) DO UPDATE SET - event_index = EXCLUDED.event_index, - session_id = EXCLUDED.session_id, - created_at = EXCLUDED.created_at, - connection_id = EXCLUDED.connection_id, - sender = EXCLUDED.sender, - payload_json = EXCLUDED.payload_json`, - [event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, event.payload], - ); - } - - async close(): Promise { - if (!this.ownsPool) { - return; - } - await this.pool.end(); - } - - private async ready(): Promise { - await this.initialized; - } - - private table(name: "sessions" | "events"): string { - return `"${this.schema}"."${name}"`; - } - - private async initialize(): Promise { - await this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`); - - await this.pool.query(` - CREATE TABLE IF NOT EXISTS ${this.table("sessions")} ( - id TEXT PRIMARY KEY, - agent TEXT NOT NULL, - agent_session_id TEXT NOT NULL, - last_connection_id TEXT NOT NULL, - created_at BIGINT NOT NULL, - destroyed_at BIGINT, - sandbox_id TEXT, - session_init_json JSONB - ) - `); - - await this.pool.query(` - ALTER TABLE ${this.table("sessions")} - ADD COLUMN IF NOT EXISTS sandbox_id TEXT - `); - - await this.pool.query(` - CREATE TABLE IF NOT EXISTS ${this.table("events")} ( - id TEXT PRIMARY KEY, - event_index BIGINT NOT NULL, - session_id TEXT NOT NULL, - created_at BIGINT NOT NULL, - connection_id TEXT NOT NULL, - sender TEXT NOT NULL, - payload_json JSONB NOT NULL - ) - `); - - await this.pool.query(` - ALTER TABLE ${this.table("events")} - ALTER COLUMN id TYPE TEXT USING id::TEXT - `); - - await this.pool.query(` - ALTER TABLE ${this.table("events")} - ADD COLUMN IF NOT EXISTS event_index BIGINT - `); - - await this.pool.query(` - WITH ranked AS ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC) AS ranked_index - FROM ${this.table("events")} - ) - UPDATE ${this.table("events")} AS current_events - SET event_index = ranked.ranked_index - FROM ranked - WHERE current_events.id = ranked.id - AND current_events.event_index IS NULL - `); - - await this.pool.query(` - ALTER TABLE ${this.table("events")} - ALTER COLUMN event_index SET NOT NULL - `); - - await this.pool.query(` - CREATE INDEX IF NOT EXISTS idx_events_session_order - ON ${this.table("events")}(session_id, event_index, id) - `); - } -} - -type SessionRow = { - id: string; - agent: string; - agent_session_id: string; - last_connection_id: string; - created_at: string | number; - destroyed_at: string | number | null; - sandbox_id: string | null; - session_init_json: unknown | null; -}; - -type EventRow = { - id: string | number; - event_index: string | number; - session_id: string; - created_at: string | number; - connection_id: string; - sender: string; - payload_json: unknown; -}; - -function decodeSessionRow(row: SessionRow): SessionRecord { - return { - id: row.id, - agent: row.agent, - agentSessionId: row.agent_session_id, - lastConnectionId: row.last_connection_id, - createdAt: parseInteger(row.created_at), - destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at), - sandboxId: row.sandbox_id ?? undefined, - sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined, - }; -} - -function decodeEventRow(row: EventRow): SessionEvent { - return { - id: String(row.id), - eventIndex: parseInteger(row.event_index), - sessionId: row.session_id, - createdAt: parseInteger(row.created_at), - connectionId: row.connection_id, - sender: parseSender(row.sender), - payload: row.payload_json as SessionEvent["payload"], - }; -} - -function normalizeLimit(limit: number | undefined): number { - if (!Number.isFinite(limit) || (limit ?? 0) < 1) { - return DEFAULT_LIST_LIMIT; - } - return Math.floor(limit as number); -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} - -function parseInteger(value: string | number): number { - const parsed = typeof value === "number" ? value : Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) { - throw new Error(`Invalid integer value returned by postgres: ${String(value)}`); - } - return parsed; -} - -function parseSender(value: string): SessionEvent["sender"] { - if (value === "agent" || value === "client") { - return value; - } - throw new Error(`Invalid sender value returned by postgres: ${value}`); -} - -function normalizeSchema(schema: string): string { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) { - throw new Error(`Invalid schema name '${schema}'. Use letters, numbers, and underscores only.`); - } - return schema; -} +throw new Error( + "@sandbox-agent/persist-postgres has been deprecated and removed. " + + "Copy the reference implementation from examples/persist-postgres into your project instead. " + + "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres", +); diff --git a/sdks/persist-postgres/tests/integration.test.ts b/sdks/persist-postgres/tests/integration.test.ts deleted file mode 100644 index ddd4123..0000000 --- a/sdks/persist-postgres/tests/integration.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { tmpdir } from "node:os"; -import { randomUUID } from "node:crypto"; -import { Client } from "pg"; -import { SandboxAgent } from "sandbox-agent"; -import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts"; -import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts"; -import { PostgresSessionPersistDriver } from "../src/index.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findBinary(): string | null { - if (process.env.SANDBOX_AGENT_BIN) { - return process.env.SANDBOX_AGENT_BIN; - } - - const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")]; - - for (const p of cargoPaths) { - if (existsSync(p)) { - return p; - } - } - - return null; -} - -const BINARY_PATH = findBinary(); -if (!BINARY_PATH) { - throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN."); -} -if (!process.env.SANDBOX_AGENT_BIN) { - process.env.SANDBOX_AGENT_BIN = BINARY_PATH; -} - -interface PostgresContainer { - containerId: string; - connectionString: string; -} - -describe("Postgres persistence driver", () => { - let handle: SandboxAgentSpawnHandle; - let baseUrl: string; - let token: string; - let dataHome: string; - let postgres: PostgresContainer | null = null; - - beforeAll(async () => { - dataHome = mkdtempSync(join(tmpdir(), "postgres-integration-")); - prepareMockAgentDataHome(dataHome); - - handle = await spawnSandboxAgent({ - enabled: true, - log: "silent", - timeoutMs: 30000, - env: { - XDG_DATA_HOME: dataHome, - HOME: dataHome, - USERPROFILE: dataHome, - APPDATA: join(dataHome, "AppData", "Roaming"), - LOCALAPPDATA: join(dataHome, "AppData", "Local"), - }, - }); - baseUrl = handle.baseUrl; - token = handle.token; - }); - - beforeEach(async () => { - postgres = await startPostgresContainer(); - }); - - afterEach(() => { - if (postgres) { - stopPostgresContainer(postgres.containerId); - postgres = null; - } - }); - - afterAll(async () => { - await handle.dispose(); - rmSync(dataHome, { recursive: true, force: true }); - }); - - it("persists session/event history across SDK instances and supports replay restore", async () => { - const connectionString = requirePostgres(postgres).connectionString; - - const persist1 = new PostgresSessionPersistDriver({ - connectionString, - }); - - const sdk1 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist1, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const created = await sdk1.createSession({ agent: "mock" }); - await created.prompt([{ type: "text", text: "postgres-first" }]); - const firstConnectionId = created.lastConnectionId; - - await sdk1.dispose(); - await persist1.close(); - - const persist2 = new PostgresSessionPersistDriver({ - connectionString, - }); - const sdk2 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist2, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const restored = await sdk2.resumeSession(created.id); - expect(restored.lastConnectionId).not.toBe(firstConnectionId); - - await restored.prompt([{ type: "text", text: "postgres-second" }]); - - const sessions = await sdk2.listSessions({ limit: 20 }); - expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true); - - const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 }); - expect(events.items.length).toBeGreaterThan(0); - expect(events.items.every((event) => typeof event.id === "string")).toBe(true); - expect(events.items.every((event) => Number.isInteger(event.eventIndex))).toBe(true); - - for (let i = 1; i < events.items.length; i += 1) { - expect(events.items[i]!.eventIndex).toBeGreaterThanOrEqual(events.items[i - 1]!.eventIndex); - } - - const replayInjected = events.items.find((event) => { - if (event.sender !== "client") { - return false; - } - const payload = event.payload as Record; - const method = payload.method; - const params = payload.params as Record | undefined; - const prompt = Array.isArray(params?.prompt) ? params?.prompt : []; - const firstBlock = prompt[0] as Record | undefined; - return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below"); - }); - expect(replayInjected).toBeTruthy(); - - await sdk2.dispose(); - await persist2.close(); - }); -}); - -async function startPostgresContainer(): Promise { - const name = `sandbox-agent-postgres-${randomUUID()}`; - const containerId = runDockerCommand([ - "run", - "-d", - "--rm", - "--name", - name, - "-e", - "POSTGRES_USER=postgres", - "-e", - "POSTGRES_PASSWORD=postgres", - "-e", - "POSTGRES_DB=sandboxagent", - "-p", - "127.0.0.1::5432", - "postgres:16-alpine", - ]); - - const portOutput = runDockerCommand(["port", containerId, "5432/tcp"]); - const port = parsePort(portOutput); - const connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/sandboxagent`; - await waitForPostgres(connectionString); - - return { - containerId, - connectionString, - }; -} - -function stopPostgresContainer(containerId: string): void { - try { - runDockerCommand(["rm", "-f", containerId]); - } catch { - // Container may already be gone when test teardown runs. - } -} - -function runDockerCommand(args: string[]): string { - return execFileSync("docker", args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); -} - -function parsePort(output: string): string { - const firstLine = output.split("\n")[0]?.trim() ?? ""; - const match = firstLine.match(/:(\d+)$/); - if (!match) { - throw new Error(`Failed to parse docker port output: '${output}'`); - } - return match[1]; -} - -async function waitForPostgres(connectionString: string): Promise { - const timeoutMs = 30000; - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - - while (Date.now() < deadline) { - const client = new Client({ connectionString }); - try { - await client.connect(); - await client.query("SELECT 1"); - await client.end(); - return; - } catch (error) { - lastError = error; - try { - await client.end(); - } catch { - // Ignore cleanup failures while retrying. - } - await delay(250); - } - } - - throw new Error(`Postgres container did not become ready: ${String(lastError)}`); -} - -function delay(ms: number): Promise { - return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); -} - -function requirePostgres(container: PostgresContainer | null): PostgresContainer { - if (!container) { - throw new Error("Postgres container was not initialized for this test."); - } - return container; -} diff --git a/sdks/persist-rivet/README.md b/sdks/persist-rivet/README.md new file mode 100644 index 0000000..49e4a7a --- /dev/null +++ b/sdks/persist-rivet/README.md @@ -0,0 +1,5 @@ +# @sandbox-agent/persist-rivet + +> **Deprecated:** This package has been deprecated and removed. + +Copy the driver source directly into your project. See the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance. diff --git a/sdks/persist-rivet/package.json b/sdks/persist-rivet/package.json index 723e3b4..b297670 100644 --- a/sdks/persist-rivet/package.json +++ b/sdks/persist-rivet/package.json @@ -16,30 +16,16 @@ "import": "./dist/index.js" } }, - "dependencies": { - "sandbox-agent": "workspace:*" - }, - "peerDependencies": { - "rivetkit": ">=0.5.0" - }, - "peerDependenciesMeta": { - "rivetkit": { - "optional": true - } - }, "files": [ "dist" ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/node": "^22.0.0", "tsup": "^8.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "typescript": "^5.7.0" } } diff --git a/sdks/persist-rivet/src/index.ts b/sdks/persist-rivet/src/index.ts index b89b7df..8e77d33 100644 --- a/sdks/persist-rivet/src/index.ts +++ b/sdks/persist-rivet/src/index.ts @@ -1,168 +1,5 @@ -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; - -/** Structural type compatible with rivetkit's ActorContext without importing it. */ -export interface ActorContextLike { - state: Record; -} - -export interface RivetPersistData { - sessions: Record; - events: Record; -} - -export type RivetPersistState = { - _sandboxAgentPersist: RivetPersistData; -}; - -export interface RivetSessionPersistDriverOptions { - /** Maximum number of sessions to retain. Oldest are evicted first. Default: 1024. */ - maxSessions?: number; - /** Maximum events per session. Oldest are trimmed first. Default: 500. */ - maxEventsPerSession?: number; - /** Key on `c.state` where persist data is stored. Default: `"_sandboxAgentPersist"`. */ - stateKey?: string; -} - -const DEFAULT_MAX_SESSIONS = 1024; -const DEFAULT_MAX_EVENTS_PER_SESSION = 500; -const DEFAULT_LIST_LIMIT = 100; -const DEFAULT_STATE_KEY = "_sandboxAgentPersist"; - -export class RivetSessionPersistDriver implements SessionPersistDriver { - private readonly maxSessions: number; - private readonly maxEventsPerSession: number; - private readonly stateKey: string; - private readonly ctx: ActorContextLike; - - constructor(ctx: ActorContextLike, options: RivetSessionPersistDriverOptions = {}) { - this.ctx = ctx; - this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS); - this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION); - this.stateKey = options.stateKey ?? DEFAULT_STATE_KEY; - - // Auto-initialize if absent; preserve existing data on actor wake. - if (!this.ctx.state[this.stateKey]) { - this.ctx.state[this.stateKey] = { sessions: {}, events: {} } satisfies RivetPersistData; - } - } - - private get data(): RivetPersistData { - return this.ctx.state[this.stateKey] as RivetPersistData; - } - - async getSession(id: string): Promise { - const session = this.data.sessions[id]; - return session ? cloneSessionRecord(session) : undefined; - } - - async listSessions(request: ListPageRequest = {}): Promise> { - const sorted = Object.values(this.data.sessions).sort((a, b) => { - if (a.createdAt !== b.createdAt) { - return a.createdAt - b.createdAt; - } - return a.id.localeCompare(b.id); - }); - const page = paginate(sorted, request); - return { - items: page.items.map(cloneSessionRecord), - nextCursor: page.nextCursor, - }; - } - - async updateSession(session: SessionRecord): Promise { - this.data.sessions[session.id] = { ...session }; - - if (!this.data.events[session.id]) { - this.data.events[session.id] = []; - } - - const ids = Object.keys(this.data.sessions); - if (ids.length <= this.maxSessions) { - return; - } - - const overflow = ids.length - this.maxSessions; - const removable = Object.values(this.data.sessions) - .sort((a, b) => { - if (a.createdAt !== b.createdAt) { - return a.createdAt - b.createdAt; - } - return a.id.localeCompare(b.id); - }) - .slice(0, overflow) - .map((s) => s.id); - - for (const sessionId of removable) { - delete this.data.sessions[sessionId]; - delete this.data.events[sessionId]; - } - } - - async listEvents(request: ListEventsRequest): Promise> { - const all = [...(this.data.events[request.sessionId] ?? [])].sort((a, b) => { - if (a.eventIndex !== b.eventIndex) { - return a.eventIndex - b.eventIndex; - } - return a.id.localeCompare(b.id); - }); - const page = paginate(all, request); - return { - items: page.items.map(cloneSessionEvent), - nextCursor: page.nextCursor, - }; - } - - async insertEvent(sessionId: string, event: SessionEvent): Promise { - const events = this.data.events[sessionId] ?? []; - events.push(cloneSessionEvent(event)); - - if (events.length > this.maxEventsPerSession) { - events.splice(0, events.length - this.maxEventsPerSession); - } - - this.data.events[sessionId] = events; - } -} - -function cloneSessionRecord(session: SessionRecord): SessionRecord { - return { - ...session, - sessionInit: session.sessionInit ? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"]) : undefined, - }; -} - -function cloneSessionEvent(event: SessionEvent): SessionEvent { - return { - ...event, - payload: JSON.parse(JSON.stringify(event.payload)) as SessionEvent["payload"], - }; -} - -function normalizeCap(value: number | undefined, fallback: number): number { - if (!Number.isFinite(value) || (value ?? 0) < 1) { - return fallback; - } - return Math.floor(value as number); -} - -function paginate(items: T[], request: ListPageRequest): ListPage { - const offset = parseCursor(request.cursor); - const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT); - const slice = items.slice(offset, offset + limit); - const nextOffset = offset + slice.length; - return { - items: slice, - nextCursor: nextOffset < items.length ? String(nextOffset) : undefined, - }; -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} +throw new Error( + "@sandbox-agent/persist-rivet has been deprecated and removed. " + + "Copy the reference implementation into your project instead. " + + "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-rivet", +); diff --git a/sdks/persist-rivet/tests/driver.test.ts b/sdks/persist-rivet/tests/driver.test.ts deleted file mode 100644 index 839cffe..0000000 --- a/sdks/persist-rivet/tests/driver.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { RivetSessionPersistDriver } from "../src/index.ts"; -import type { RivetPersistData } from "../src/index.ts"; - -function makeCtx() { - return { state: {} as Record }; -} - -describe("RivetSessionPersistDriver", () => { - it("auto-initializes state on construction", () => { - const ctx = makeCtx(); - new RivetSessionPersistDriver(ctx); - const data = ctx.state._sandboxAgentPersist as RivetPersistData; - expect(data).toBeDefined(); - expect(data.sessions).toEqual({}); - expect(data.events).toEqual({}); - }); - - it("preserves existing state on construction (actor wake)", async () => { - const ctx = makeCtx(); - const driver1 = new RivetSessionPersistDriver(ctx); - - await driver1.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - // Simulate actor wake: new driver instance, same state object - const driver2 = new RivetSessionPersistDriver(ctx); - const session = await driver2.getSession("s-1"); - expect(session?.id).toBe("s-1"); - expect(session?.createdAt).toBe(100); - }); - - it("stores and retrieves sessions", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - await driver.updateSession({ - id: "s-2", - agent: "mock", - agentSessionId: "a-2", - lastConnectionId: "c-2", - createdAt: 200, - destroyedAt: 300, - }); - - const loaded = await driver.getSession("s-2"); - expect(loaded?.destroyedAt).toBe(300); - - const missing = await driver.getSession("s-nonexistent"); - expect(missing).toBeUndefined(); - }); - - it("pages sessions sorted by createdAt", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - await driver.updateSession({ - id: "s-2", - agent: "mock", - agentSessionId: "a-2", - lastConnectionId: "c-2", - createdAt: 200, - }); - - const page1 = await driver.listSessions({ limit: 1 }); - expect(page1.items).toHaveLength(1); - expect(page1.items[0]?.id).toBe("s-1"); - expect(page1.nextCursor).toBeTruthy(); - - const page2 = await driver.listSessions({ cursor: page1.nextCursor, limit: 1 }); - expect(page2.items).toHaveLength(1); - expect(page2.items[0]?.id).toBe("s-2"); - expect(page2.nextCursor).toBeUndefined(); - }); - - it("stores and pages events", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - - await driver.insertEvent("s-1", { - id: "evt-1", - eventIndex: 1, - sessionId: "s-1", - createdAt: 1, - connectionId: "c-1", - sender: "client", - payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } }, - }); - - await driver.insertEvent("s-1", { - id: "evt-2", - eventIndex: 2, - sessionId: "s-1", - createdAt: 2, - connectionId: "c-1", - sender: "agent", - payload: { jsonrpc: "2.0", method: "session/update", params: { sessionId: "a-1" } }, - }); - - const eventsPage = await driver.listEvents({ sessionId: "s-1", limit: 10 }); - expect(eventsPage.items).toHaveLength(2); - expect(eventsPage.items[0]?.id).toBe("evt-1"); - expect(eventsPage.items[0]?.eventIndex).toBe(1); - expect(eventsPage.items[1]?.id).toBe("evt-2"); - expect(eventsPage.items[1]?.eventIndex).toBe(2); - }); - - it("evicts oldest sessions when maxSessions exceeded", async () => { - const driver = new RivetSessionPersistDriver(makeCtx(), { maxSessions: 2 }); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - await driver.updateSession({ - id: "s-2", - agent: "mock", - agentSessionId: "a-2", - lastConnectionId: "c-2", - createdAt: 200, - }); - - // Adding a third session should evict the oldest (s-1) - await driver.updateSession({ - id: "s-3", - agent: "mock", - agentSessionId: "a-3", - lastConnectionId: "c-3", - createdAt: 300, - }); - - expect(await driver.getSession("s-1")).toBeUndefined(); - expect(await driver.getSession("s-2")).toBeDefined(); - expect(await driver.getSession("s-3")).toBeDefined(); - }); - - it("trims oldest events when maxEventsPerSession exceeded", async () => { - const driver = new RivetSessionPersistDriver(makeCtx(), { maxEventsPerSession: 2 }); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - - for (let i = 1; i <= 3; i++) { - await driver.insertEvent("s-1", { - id: `evt-${i}`, - eventIndex: i, - sessionId: "s-1", - createdAt: i, - connectionId: "c-1", - sender: "client", - payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } }, - }); - } - - const page = await driver.listEvents({ sessionId: "s-1" }); - expect(page.items).toHaveLength(2); - // Oldest event (evt-1) should be trimmed - expect(page.items[0]?.id).toBe("evt-2"); - expect(page.items[1]?.id).toBe("evt-3"); - }); - - it("clones data to prevent external mutation", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - - const s1 = await driver.getSession("s-1"); - const s2 = await driver.getSession("s-1"); - expect(s1).toEqual(s2); - expect(s1).not.toBe(s2); // Different object references - }); - - it("supports custom stateKey", async () => { - const ctx = makeCtx(); - const driver = new RivetSessionPersistDriver(ctx, { stateKey: "myPersist" }); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - - expect((ctx.state.myPersist as RivetPersistData).sessions["s-1"]).toBeDefined(); - expect(ctx.state._sandboxAgentPersist).toBeUndefined(); - }); - - it("returns empty results for unknown session events", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - const page = await driver.listEvents({ sessionId: "nonexistent" }); - expect(page.items).toHaveLength(0); - expect(page.nextCursor).toBeUndefined(); - }); -}); diff --git a/sdks/persist-sqlite/README.md b/sdks/persist-sqlite/README.md new file mode 100644 index 0000000..c1fe980 --- /dev/null +++ b/sdks/persist-sqlite/README.md @@ -0,0 +1,5 @@ +# @sandbox-agent/persist-sqlite + +> **Deprecated:** This package has been deprecated and removed. The implementation now lives as a copy-paste reference in [`examples/persist-sqlite`](../../examples/persist-sqlite). + +Install `better-sqlite3` directly and copy the driver source into your project. See the [full example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite). diff --git a/sdks/persist-sqlite/package.json b/sdks/persist-sqlite/package.json index 852e384..3deef42 100644 --- a/sdks/persist-sqlite/package.json +++ b/sdks/persist-sqlite/package.json @@ -16,24 +16,17 @@ "import": "./dist/index.js" } }, - "dependencies": { - "better-sqlite3": "^11.0.0", - "sandbox-agent": "workspace:*" - }, + "dependencies": {}, "files": [ "dist" ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" + "typecheck": "tsc --noEmit" }, "devDependencies": { - "@types/better-sqlite3": "^7.0.0", "@types/node": "^22.0.0", "tsup": "^8.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "typescript": "^5.7.0" } } diff --git a/sdks/persist-sqlite/src/index.ts b/sdks/persist-sqlite/src/index.ts index b04b0fc..75386b2 100644 --- a/sdks/persist-sqlite/src/index.ts +++ b/sdks/persist-sqlite/src/index.ts @@ -1,294 +1,5 @@ -import Database from "better-sqlite3"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; - -const DEFAULT_LIST_LIMIT = 100; - -export interface SQLiteSessionPersistDriverOptions { - filename?: string; -} - -export class SQLiteSessionPersistDriver implements SessionPersistDriver { - private readonly db: Database.Database; - - constructor(options: SQLiteSessionPersistDriverOptions = {}) { - this.db = new Database(options.filename ?? ":memory:"); - this.initialize(); - } - - async getSession(id: string): Promise { - const row = this.db - .prepare( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json - FROM sessions WHERE id = ?`, - ) - .get(id) as SessionRow | undefined; - - if (!row) { - return undefined; - } - - return decodeSessionRow(row); - } - - async listSessions(request: ListPageRequest = {}): Promise> { - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - - const rows = this.db - .prepare( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json - FROM sessions - ORDER BY created_at ASC, id ASC - LIMIT ? OFFSET ?`, - ) - .all(limit, offset) as SessionRow[]; - - const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM sessions`).get() as { count: number }; - const nextOffset = offset + rows.length; - - return { - items: rows.map(decodeSessionRow), - nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined, - }; - } - - async updateSession(session: SessionRecord): Promise { - this.db - .prepare( - `INSERT INTO sessions ( - id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - agent = excluded.agent, - agent_session_id = excluded.agent_session_id, - last_connection_id = excluded.last_connection_id, - created_at = excluded.created_at, - destroyed_at = excluded.destroyed_at, - sandbox_id = excluded.sandbox_id, - session_init_json = excluded.session_init_json`, - ) - .run( - session.id, - session.agent, - session.agentSessionId, - session.lastConnectionId, - session.createdAt, - session.destroyedAt ?? null, - session.sandboxId ?? null, - session.sessionInit ? JSON.stringify(session.sessionInit) : null, - ); - } - - async listEvents(request: ListEventsRequest): Promise> { - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - - const rows = this.db - .prepare( - `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json - FROM events - WHERE session_id = ? - ORDER BY event_index ASC, id ASC - LIMIT ? OFFSET ?`, - ) - .all(request.sessionId, limit, offset) as EventRow[]; - - const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM events WHERE session_id = ?`).get(request.sessionId) as { count: number }; - - const nextOffset = offset + rows.length; - - return { - items: rows.map(decodeEventRow), - nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined, - }; - } - - async insertEvent(_sessionId: string, event: SessionEvent): Promise { - this.db - .prepare( - `INSERT INTO events ( - id, event_index, session_id, created_at, connection_id, sender, payload_json - ) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - event_index = excluded.event_index, - session_id = excluded.session_id, - created_at = excluded.created_at, - connection_id = excluded.connection_id, - sender = excluded.sender, - payload_json = excluded.payload_json`, - ) - .run(event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, JSON.stringify(event.payload)); - } - - close(): void { - this.db.close(); - } - - private initialize(): void { - this.db.exec(` - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - agent TEXT NOT NULL, - agent_session_id TEXT NOT NULL, - last_connection_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - destroyed_at INTEGER, - sandbox_id TEXT, - session_init_json TEXT - ) - `); - - const sessionColumns = this.db.prepare(`PRAGMA table_info(sessions)`).all() as TableInfoRow[]; - if (!sessionColumns.some((column) => column.name === "sandbox_id")) { - this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`); - } - - this.ensureEventsTable(); - } - - private ensureEventsTable(): void { - const tableInfo = this.db.prepare(`PRAGMA table_info(events)`).all() as TableInfoRow[]; - if (tableInfo.length === 0) { - this.createEventsTable(); - return; - } - - const idColumn = tableInfo.find((column) => column.name === "id"); - const hasEventIndex = tableInfo.some((column) => column.name === "event_index"); - const idType = (idColumn?.type ?? "").trim().toUpperCase(); - const idIsText = idType === "TEXT"; - - if (!idIsText || !hasEventIndex) { - this.rebuildEventsTable(hasEventIndex); - } - - this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_events_session_order - ON events(session_id, event_index, id) - `); - } - - private createEventsTable(): void { - this.db.exec(` - CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, - event_index INTEGER NOT NULL, - session_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - connection_id TEXT NOT NULL, - sender TEXT NOT NULL, - payload_json TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_events_session_order - ON events(session_id, event_index, id) - `); - } - - private rebuildEventsTable(hasEventIndex: boolean): void { - this.db.exec(` - ALTER TABLE events RENAME TO events_legacy; - `); - - this.createEventsTable(); - - if (hasEventIndex) { - this.db.exec(` - INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json) - SELECT - CAST(id AS TEXT), - COALESCE(event_index, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC)), - session_id, - created_at, - connection_id, - sender, - payload_json - FROM events_legacy - `); - } else { - this.db.exec(` - INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json) - SELECT - CAST(id AS TEXT), - ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC), - session_id, - created_at, - connection_id, - sender, - payload_json - FROM events_legacy - `); - } - - this.db.exec(`DROP TABLE events_legacy`); - } -} - -type SessionRow = { - id: string; - agent: string; - agent_session_id: string; - last_connection_id: string; - created_at: number; - destroyed_at: number | null; - sandbox_id: string | null; - session_init_json: string | null; -}; - -type EventRow = { - id: string; - event_index: number; - session_id: string; - created_at: number; - connection_id: string; - sender: "client" | "agent"; - payload_json: string; -}; - -type TableInfoRow = { - name: string; - type: string; -}; - -function decodeSessionRow(row: SessionRow): SessionRecord { - return { - id: row.id, - agent: row.agent, - agentSessionId: row.agent_session_id, - lastConnectionId: row.last_connection_id, - createdAt: row.created_at, - destroyedAt: row.destroyed_at ?? undefined, - sandboxId: row.sandbox_id ?? undefined, - sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined, - }; -} - -function decodeEventRow(row: EventRow): SessionEvent { - return { - id: row.id, - eventIndex: row.event_index, - sessionId: row.session_id, - createdAt: row.created_at, - connectionId: row.connection_id, - sender: row.sender, - payload: JSON.parse(row.payload_json), - }; -} - -function normalizeLimit(limit: number | undefined): number { - if (!Number.isFinite(limit) || (limit ?? 0) < 1) { - return DEFAULT_LIST_LIMIT; - } - return Math.floor(limit as number); -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} +throw new Error( + "@sandbox-agent/persist-sqlite has been deprecated and removed. " + + "Copy the reference implementation from examples/persist-sqlite into your project instead. " + + "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite", +); diff --git a/sdks/persist-sqlite/tests/integration.test.ts b/sdks/persist-sqlite/tests/integration.test.ts deleted file mode 100644 index 376406c..0000000 --- a/sdks/persist-sqlite/tests/integration.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { tmpdir } from "node:os"; -import { SandboxAgent } from "sandbox-agent"; -import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts"; -import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts"; -import { SQLiteSessionPersistDriver } from "../src/index.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findBinary(): string | null { - if (process.env.SANDBOX_AGENT_BIN) { - return process.env.SANDBOX_AGENT_BIN; - } - - const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")]; - - for (const p of cargoPaths) { - if (existsSync(p)) { - return p; - } - } - - return null; -} - -const BINARY_PATH = findBinary(); -if (!BINARY_PATH) { - throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN."); -} -if (!process.env.SANDBOX_AGENT_BIN) { - process.env.SANDBOX_AGENT_BIN = BINARY_PATH; -} - -describe("SQLite persistence driver", () => { - let handle: SandboxAgentSpawnHandle; - let baseUrl: string; - let token: string; - let dataHome: string; - - beforeAll(async () => { - dataHome = mkdtempSync(join(tmpdir(), "sqlite-integration-")); - prepareMockAgentDataHome(dataHome); - - handle = await spawnSandboxAgent({ - enabled: true, - log: "silent", - timeoutMs: 30000, - env: { - XDG_DATA_HOME: dataHome, - HOME: dataHome, - USERPROFILE: dataHome, - APPDATA: join(dataHome, "AppData", "Roaming"), - LOCALAPPDATA: join(dataHome, "AppData", "Local"), - }, - }); - baseUrl = handle.baseUrl; - token = handle.token; - }); - - afterAll(async () => { - await handle.dispose(); - rmSync(dataHome, { recursive: true, force: true }); - }); - - it("persists session/event history across SDK instances and supports replay restore", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "sqlite-persist-")); - const dbPath = join(tempDir, "session-store.db"); - - const persist1 = new SQLiteSessionPersistDriver({ filename: dbPath }); - const sdk1 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist1, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const created = await sdk1.createSession({ agent: "mock" }); - await created.prompt([{ type: "text", text: "sqlite-first" }]); - const firstConnectionId = created.lastConnectionId; - - await sdk1.dispose(); - persist1.close(); - - const persist2 = new SQLiteSessionPersistDriver({ filename: dbPath }); - const sdk2 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist2, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const restored = await sdk2.resumeSession(created.id); - expect(restored.lastConnectionId).not.toBe(firstConnectionId); - - await restored.prompt([{ type: "text", text: "sqlite-second" }]); - - const sessions = await sdk2.listSessions({ limit: 20 }); - expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true); - - const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 }); - expect(events.items.length).toBeGreaterThan(0); - expect(events.items.every((event) => typeof event.id === "string")).toBe(true); - expect(events.items.every((event) => Number.isInteger(event.eventIndex))).toBe(true); - - for (let i = 1; i < events.items.length; i += 1) { - expect(events.items[i]!.eventIndex).toBeGreaterThanOrEqual(events.items[i - 1]!.eventIndex); - } - - const replayInjected = events.items.find((event) => { - if (event.sender !== "client") { - return false; - } - const payload = event.payload as Record; - const method = payload.method; - const params = payload.params as Record | undefined; - const prompt = Array.isArray(params?.prompt) ? params?.prompt : []; - const firstBlock = prompt[0] as Record | undefined; - return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below"); - }); - expect(replayInjected).toBeTruthy(); - - await sdk2.dispose(); - persist2.close(); - rmSync(tempDir, { recursive: true, force: true }); - }); -}); diff --git a/sdks/persist-sqlite/vitest.config.ts b/sdks/persist-sqlite/vitest.config.ts deleted file mode 100644 index 8a85a83..0000000 --- a/sdks/persist-sqlite/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/**/*.test.ts"], - testTimeout: 60000, - }, -}); diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 94fbc3e..e94b521 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1770,7 +1770,7 @@ export class SandboxAgent { }; try { - await this.persist.insertEvent(event); + await this.persist.insertEvent(localSessionId, event); break; } catch (error) { if (!isSessionEventIndexConflict(error) || attempt === MAX_EVENT_INDEX_INSERT_RETRIES - 1) { diff --git a/sdks/typescript/src/providers/daytona.ts b/sdks/typescript/src/providers/daytona.ts index eb34b88..fcae45e 100644 --- a/sdks/typescript/src/providers/daytona.ts +++ b/sdks/typescript/src/providers/daytona.ts @@ -7,15 +7,17 @@ const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60; type DaytonaCreateParams = NonNullable[0]>; +type DaytonaCreateOverrides = Partial; + export interface DaytonaProviderOptions { - create?: DaytonaCreateParams | (() => DaytonaCreateParams | Promise); + create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise); image?: string; agentPort?: number; previewTtlSeconds?: number; deleteTimeoutSeconds?: number; } -async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise { +async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise { if (!value) return undefined; if (typeof value === "function") return await value(); return value; diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 003b0dd..295e688 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -70,19 +70,19 @@ class StrictUniqueSessionPersistDriver implements SessionPersistDriver { return this.events.listEvents(request); } - async insertEvent(event: SessionEvent): Promise { + async insertEvent(sessionId: string, event: SessionEvent): Promise { await sleep(5); - const indexes = this.eventIndexesBySession.get(event.sessionId) ?? new Set(); + const indexes = this.eventIndexesBySession.get(sessionId) ?? new Set(); if (indexes.has(event.eventIndex)) { throw new Error("UNIQUE constraint failed: sandbox_agent_events.session_id, sandbox_agent_events.event_index"); } indexes.add(event.eventIndex); - this.eventIndexesBySession.set(event.sessionId, indexes); + this.eventIndexesBySession.set(sessionId, indexes); await sleep(5); - await this.events.insertEvent(event); + await this.events.insertEvent(sessionId, event); } }