Aller au contenu principal

Agent storage layout

Status: V1.x shipped (sessions). Other files (auth.json, recent.json, workspaces.json) shipped earlier.

This page is the desktop-specific binding of the agent RFC's persistency contract. The abstract schema (chat_sessions / chat_messages / chat_parts), the ID strategy, the save-on-chunk policy, the mutable-mid-stream rule, and the JSON-column discipline are all locked in the RFC — read that first.

What follows is delta:

  • Agent-home layout and its separation from Electron's browser profile.
  • The node:sqlite + drizzle sqlite-proxy implementation choice.
  • Multi-process safety.
  • Why we don't ship resumable-stream.

Agent-home layout

The agent host owns four files. All sit side-by-side under the canonical Grida agent home (~/.grida/agent).

Electron main separately owns preferences.json under Electron's platform-specific userData directory. It contains small, non-secret Desktop lifecycle preferences such as versioned onboarding completion. It is not agent data, never contains account or provider credentials, and is intentionally outside the AgentSidecar table below.

FileHoldsModeAtomic write
auth.jsonOAuth tokens, BYOK keys, PKCE state0o600tmp + rename
recent.jsonRecent documents list (the agent host's canon)defaulttmp + rename
workspaces.jsonWorkspace registry ({id, root, name, openedAt, pinned})0o600tmp + rename
sessions.dbThe three-table chat store (persistency)defaultSQLite atomic transactions
sessions.db-walWrite-ahead log companion (WAL journal mode)defaultSQLite
sessions.db-shmShared-memory companion (WAL journal mode)defaultSQLite

The WAL companions exist whenever the DB has been opened in WAL mode since the last clean shutdown. They are not user-data files; backups should use the SQLite backup API rather than copying the trio.

sessions.db deliberately uses the OS default mode, not 0o600 — the DB holds user data (chat content), not at-rest secrets. The auth.json permission guard protects the secret surface separately. This matches agent security / secrets discipline.

SQLite driver — node:sqlite + drizzle proxy

The agent host uses Node 24 LTS's built-in node:sqlite driver, with drizzle-orm on top via the drizzle-orm/sqlite-proxy adapter.

Why this combo:

  • Zero native rebuild. better-sqlite3 would pull in a native module per platform; the asar workflow doesn't pay for that cost when the schema is this small.
  • No extra binary in the Electron asar. node:sqlite is in the runtime; nothing to ship.
  • Drizzle's query builder for free. The sqlite-proxy adapter takes an async callback the agent host implements over DatabaseSync.prepare(...).run(). The adapter expects rows as positional arrays (drizzle indexes by column position, not name) — see db.ts's execProxy.

Migration tooling. The schema is applied inline via CREATE TABLE IF NOT EXISTS on every open. drizzle-kit migrations land the first time we ship a tagged agent host release with a frozen schema (today the agent host is v0.0.0 private; no DBs in the wild to evolve).

This is one valid implementation of the RFC's three-table schema; another host might pick better-sqlite3, Postgres, or Turso. The column shapes, indexes, and pragmas don't change — the driver does.

Pragmas

Applied on every openSessionsDb() call:

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;

The trade-offs (NORMAL vs FULL, why FK on) are documented in agent/persistency.md / pragmas. Desktop picks the RFC defaults.

Multi-process safety

WAL handles concurrent readers + one writer. Two daemon processes on the same agent home is user error today (typically two Electron instances spawned by a packaging bug or a developer with a release + dev build open). The plan: take a process lock on sessions.db itself on agent host start; refuse to boot if held.

Lean toward shipping the lock after the first time someone hits the gap. Until then, the supervisor's single-instance Electron guard is the de-facto interlock.

Why we don't ship resumable-stream

vercel/resumable-stream is the canonical published primitive for cross-replica SSE resume, but ships only Redis / generic Publisher-Subscriber adapters — no in-memory runtime. It exists for multi-replica serverless deployments where a resume GET may land on a different replica than the producer.

Running Redis inside the Electron agent host just to talk to ourselves is a strict regression. Our in-process StreamRegistry (packages/grida-ai-agent/src/runtime.ts) IS the in-memory variant the package deliberately doesn't ship. The RFC's session lifecycle / multi-replica deployments note explicitly carves out the single-process case.

As of the AI SDK v6 upgrade, vercel/ai-chatbot itself ships the resume endpoint as Response(null, { status: 204 }) — the canonical template currently has no working resume implementation either. Our local registry is the working answer for desktop.

Crash semantics

The stream registry is in-memory. Agent host restart drops every entry; the next reconnect gets 404 and the renderer falls back to DB hydration. The DB still has whatever the recorder persisted up to the crash. The RFC carves cross-restart resume out of scope: the upstream provider has no notion of "your previous request."

The recorder upserts parts as chunks arrive, so a crashed run leaves a truthful partial state — text-so-far + any tool call frozen in its last observed state (persistency / mutable-mid-stream parts).

Backups

Backups are the user's job. If we ever ship a backup helper, it MUST use sqlite3_backup_* (the engine's backup API), not a raw cp — a file copy during a WAL checkpoint risks tearing.

See also