/** * SQLite table-creation SQL. * * SQLite stores only indexes and aggregates: users / login sessions / Project authorization / * Agent & Session indexes / usage summaries / error records / UI preferences. Agent State, * Trace, and Workspace still follow the local directory-based storage rules. * Product not yet released: no migration branches — everything is CREATE IF NOT EXISTS, formed * once. The one exception: columns added to an existing table after release of a web.db are * ALTERed in by the idempotent per-column guard in database.ts (ensureColumn), since CREATE * TABLE IF NOT EXISTS never touches an existing table. */ export const SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS users ( user_id TEXT PRIMARY KEY, -- semantic id doubles as login name: ^[a-z][a-z0-9_-]{1,31}$ password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64) is_admin INTEGER NOT NULL DEFAULT 0, -- 1 for the built-in admin (seeded at startup) password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS auth_sessions ( token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain) via TEXT -- 'password' | 'desktop'; NULL = legacy row (password) ); CREATE TABLE IF NOT EXISTS projects ( project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml owner_user_id TEXT NOT NULL REFERENCES users(user_id), created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS project_members ( -- member grants only; owners are not stored here project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY (project_id, user_id) ); CREATE TABLE IF NOT EXISTS agents ( -- index only; name/description live in system_config.yaml project_id TEXT NOT NULL, agent_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (project_id, agent_id) ); CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, agent_id TEXT NOT NULL, provider TEXT NOT NULL, -- provider group of the session model (paired with model_id as a model reference) model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub as-is; never concatenate /) workspace TEXT NOT NULL, approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat") archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived") client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration) created_at TEXT NOT NULL ); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row) CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC); CREATE TABLE IF NOT EXISTS usage_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, date TEXT NOT NULL, -- local date yyyy-mm-dd (aggregation key; same convention as Trace date directories) project_id TEXT NOT NULL, agent_id TEXT NOT NULL, session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session) origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session provider TEXT NOT NULL, -- provider group: paired with model_id as the attribution key; always GROUP BY provider, model_id model_id TEXT NOT NULL, -- upstream model id (paired with provider; same model_id across providers aggregates separately) cache_read INTEGER NOT NULL, cache_write INTEGER NOT NULL, output INTEGER NOT NULL, total INTEGER NOT NULL, -- from token_usage.request (one row per Request) status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate) ); -- cost is not stored: computed at query time from current pricing CREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date); CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_records(session_id); CREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the Costs page's "Errors" tab) id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records) project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context agent_id TEXT, session_id TEXT, source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | compaction | schedule kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime) code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ... status INTEGER, -- HTTP status code; NULL for non-HTTP sources message TEXT NOT NULL -- truncated to 500 chars (no stack stored: stacks go to logs only) ); CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date); CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files are declarative intent; the system never writes back) project_id TEXT NOT NULL, agent_id TEXT NOT NULL, name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier creator_user_id TEXT, -- creator (recorded on API create; hand-edited file reconciliation falls back to the Project owner) start_at_ms INTEGER NOT NULL, -- defines identity: a start_at change counts as a new task instance, resetting fire state def_hash TEXT NOT NULL, -- file content fingerprint: any change clears the invalid flag (the file takes effect again after edits) last_slot_ms INTEGER, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts) last_fired_at TEXT, -- last actual fire time (for display) fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled) invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy PRIMARY KEY (project_id, agent_id, name) ); CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads) id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display session_id TEXT NOT NULL, project_id TEXT NOT NULL, agent_id TEXT NOT NULL, objective TEXT NOT NULL, status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active) budget INTEGER NOT NULL, -- token budget; -1 = unlimited used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting) rounds INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id); CREATE TABLE IF NOT EXISTS ui_prefs ( user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON ); CREATE TABLE IF NOT EXISTS server_settings ( -- admin-level server-global settings (GET/PUT /api/admin/settings) key TEXT PRIMARY KEY, -- setting name, e.g. 'proxy_for_app' value TEXT NOT NULL -- JSON-encoded value; an absent row means the setting's built-in default ); CREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk Trace tree (services/trace-index.ts): the directories stay the single source of truth, every row is rebuildable from disk, and a row is never authority for absence — consumers reconcile + retry on a miss, so a stale index costs one extra scan, never a false 404 project_id TEXT NOT NULL, agent_id TEXT NOT NULL, session_id TEXT NOT NULL, file_index INTEGER NOT NULL, -- shard index NNN of _NNN.jsonl (name/path are reconstructed from the row, never stored: the data root may move) date TEXT NOT NULL, -- date directory name (local yyyy-mm-dd) size_bytes INTEGER NOT NULL, -- last observed size (listings stat-refresh the returned page and write back; an actively-appended shard may lag in between) page_stats TEXT, -- cached message-window scan state at this shard's END (services/message-window.ts ShardPrefixRecord JSON); immutable shards only, NULL = not yet computed, nulled whenever size_bytes changes PRIMARY KEY (project_id, agent_id, session_id, file_index) ); CREATE INDEX IF NOT EXISTS idx_trace_files_agent_date ON trace_files(project_id, agent_id, date); CREATE INDEX IF NOT EXISTS idx_trace_files_session ON trace_files(session_id); CREATE TABLE IF NOT EXISTS trace_sessions ( -- per-session facts read ONCE at registration from the earliest shard's head (same derived-cache rules as trace_files; listing needs zero head-reads) session_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT, -- session_meta origin: 'subagent' | 'schedule' | NULL = user-created (or head not yet readable) workspace TEXT NOT NULL DEFAULT '', title TEXT, -- first-prompt fallback title (sessions.title always wins when present) provider TEXT, -- model reference from session_meta (CLI adoption reads it from here; NULL = meta unreadable / legacy without provider) model_id TEXT, first_ts TEXT, -- first record's timestamp (adoption's createdAt fallback when the id embeds no time) meta_read INTEGER NOT NULL DEFAULT 0 -- 1 once the head parsed; 0 = facts unknown, retried by the next reconcile that touches the session ); CREATE INDEX IF NOT EXISTS idx_trace_sessions_agent ON trace_sessions(project_id, agent_id); `;