-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry. -- Applied on startup by apps/coder/src/db.ts:applySchema(). -- Lives in the same 'boochat' database as BooChat's tables. CREATE TABLE IF NOT EXISTS pending_changes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL, task_id UUID, file_path TEXT NOT NULL, operation TEXT NOT NULL, diff TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')), CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted')) ); CREATE TABLE IF NOT EXISTS tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL, parent_task_id UUID REFERENCES tasks(id), state TEXT NOT NULL DEFAULT 'pending', input TEXT NOT NULL, output_summary TEXT, agent TEXT, model TEXT, execution_path TEXT, worktree_path TEXT, cost_tokens INTEGER, started_at TIMESTAMPTZ, ended_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')), CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen')) ); CREATE TABLE IF NOT EXISTS available_agents ( name TEXT PRIMARY KEY, install_path TEXT, version TEXT, supports_acp BOOLEAN NOT NULL DEFAULT false, supports_mcp_client BOOLEAN NOT NULL DEFAULT false, last_probed_at TIMESTAMPTZ ); -- v2.0.0 Phase 4: link tasks to their inference sessions. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id); -- v2.0.5: add 'qwen' to execution_path CHECK + arena_id column. ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_execution_path_chk; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tasks_execution_path_chk') THEN ALTER TABLE tasks ADD CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen')); END IF; END $$; -- v2.0.5: arena support — group tasks into competitive arenas. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID; -- Human inbox: tasks needing attention CREATE OR REPLACE VIEW human_inbox AS SELECT * FROM tasks WHERE state IN ('blocked', 'failed'); -- v2.1.0: provider picker — extend available_agents with model discovery. ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty'; -- v2.5.10: persisted ACP available_commands (captured during the cold probe), so -- an agent's live command set survives the tier-2 probe skip and shows without a -- dispatch. ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb; -- v2.2.0: Paseo-style session config on tasks. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB; -- v2.6: one shared worktree per session (all agents/panes in the session operate in it). CREATE TABLE IF NOT EXISTS session_worktrees ( session_id UUID PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, worktree_path TEXT NOT NULL, base_commit TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); -- P1.5-b: DEFANG the CASCADE — a session delete must no longer wipe its worktree -- row. This table is SUPERSEDED by `worktrees` below; all readers are repointed -- this phase, so the row just persists (dead) on session delete until a later -- cleanup drops the table. session_id is this table's PRIMARY KEY, so it cannot be -- nullable → SET NULL is invalid and NO ACTION/RESTRICT would block deletes; the -- only valid defang is to drop the FK with no replacement. Idempotent: only fires -- while the FK is still ON DELETE CASCADE ('c'). DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'session_worktrees_session_id_fkey' AND confdeltype = 'c' ) THEN ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey; END IF; END $$; -- v2.6: one backend session per (session, agent); resumed on switch-back. CREATE TABLE IF NOT EXISTS agent_sessions ( session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, agent TEXT NOT NULL, backend TEXT NOT NULL, agent_session_id TEXT, server_port INTEGER, status TEXT NOT NULL DEFAULT 'idle', last_active_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), PRIMARY KEY (session_id, agent), CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server', 'acp_warm')), CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle', 'active', 'crashed', 'closed')) ); -- Migrate existing agent_sessions FK to CASCADE. DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_session_id_fkey' AND confdeltype <> 'c' ) THEN ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey; ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE; END IF; END $$; -- v2.6: config fingerprint for stale-session detection (auto-recover on model change). ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT; -- v2.6 Phase 1-UX (U.6): opencode token/cost usage, ACCUMULATED per (chat_id, agent). -- opencode's warm server emits `session.next.step.ended` once per LLM step (several -- per multi-tool turn) carrying {tokens{input,output,reasoning,cache},cost}. We sum -- each step's normalized {input,output,cost} onto the session row — running totals -- for the whole conversation context, not last-step. Backend-only; no route/UI yet. -- input_tokens folds in cache read+write; output_tokens folds in reasoning (see -- backends/opencode-usage.ts). Defaults 0 so accumulation (col + delta) is well-defined. ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS input_tokens BIGINT NOT NULL DEFAULT 0; ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS output_tokens BIGINT NOT NULL DEFAULT 0; ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS cost DOUBLE PRECISION NOT NULL DEFAULT 0; -- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ─── -- The TAB (a chat) is the context unit: two opencode tabs in one session = two -- independent contexts sharing one worktree. So agent_sessions keys on -- (chat_id, agent), NOT (worktree_id, agent) or (session_id, agent). The -- `worktrees` table is one-per-session (selectable later) and only referenced -- informationally by agent_sessions.worktree_id (SET NULL); chat_id is the key. -- -- PREREQUISITE: the unmigratable test session (35 chats, 1 agent_sessions row that -- maps to no single chat) is DELETED before this runs, so agent_sessions is empty -- and the chat_id backfill is N/A. If a row with NULL chat_id remains, the verify -- gate below RAISEs and aborts — delete the offending session first. -- worktree as a first-class entity; survives session delete (session_id SET NULL). CREATE TABLE IF NOT EXISTS worktrees ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID REFERENCES sessions(id) ON DELETE SET NULL, project_id UUID, path TEXT NOT NULL, branch TEXT, base_commit TEXT, slug TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')), created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active'; -- Migrate any surviving session_worktrees rows → worktrees (idempotent; 0 rows -- after the test-session delete, kept for generality / fresh-DB safety). INSERT INTO worktrees (session_id, path, branch, base_commit, status) SELECT sw.session_id, sw.worktree_path, 'session-' || sw.session_id, sw.base_commit, 'active' FROM session_worktrees sw WHERE NOT EXISTS (SELECT 1 FROM worktrees w WHERE w.session_id = sw.session_id AND w.status='active'); -- Dispatch hint: which chat (tab) a task belongs to. The coder message route and -- skills route set it from the frontend tab; session-less creators (arena, MCP, -- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL; -- Re-key columns on agent_sessions. ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS chat_id UUID; ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS worktree_id UUID; -- BACKFILL-VERIFY GATE: the new PK is (chat_id, agent), so chat_id must be -- non-null on every row before the swap. With the test session deleted this is a -- 0-row assertion; if any row has NULL chat_id (an unmigratable pre-existing row), -- abort loudly rather than create a degenerate (NULL, agent) key. DO $$ DECLARE n int; BEGIN SELECT count(*) INTO n FROM agent_sessions WHERE chat_id IS NULL; IF n > 0 THEN RAISE EXCEPTION 'P1.5-b: % agent_sessions row(s) have NULL chat_id — delete the unmigratable session(s) before applying', n; END IF; END $$; -- Swap PK (session_id,agent) → (chat_id,agent) + FKs (run-once, guarded on the new -- FK's absence). chat_id CASCADEs from chats (closing a tab ends its context); -- worktree_id is informational SET NULL; session_id defanged to nullable SET NULL. DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_chat_id_fkey') THEN ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_pkey; ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_session_id_fkey; ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL; ALTER TABLE agent_sessions ALTER COLUMN chat_id SET NOT NULL; ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_pkey PRIMARY KEY (chat_id, agent); ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL; ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_worktree_id_fkey FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE SET NULL; END IF; END $$; -- P1.5-b follow-up: converge agent_sessions.session_id FK CASCADE → SET NULL. -- The re-key block above re-adds session_id_fkey as SET NULL, but it is guarded on -- chat_id_fkey's ABSENCE — so a DB already re-keyed to (chat_id, agent) while -- session_id_fkey was still ON DELETE CASCADE never re-enters that block and stays -- 'c'. This standalone guard flips it to SET NULL ('n'), matching worktree_id. -- Idempotent (mirrors the session_worktrees defang's confdeltype check): only fires -- while the FK is still CASCADE — a no-op on a fresh deploy (already 'n' from the -- re-key block) and on every re-run thereafter. DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_session_id_fkey' AND confdeltype = 'c' ) THEN ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL; ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey; ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL; END IF; END $$; -- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this). ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT; -- write-edit-robustness #4: worktree checkpoints. A pre-turn shadow-commit of the -- session worktree (tracked + untracked, captured without disturbing the real -- index/working tree) stored in a private GC-safe ref refs/boocode/checkpoints/. -- Created best-effort before each external-agent turn (opencode / warm-ACP / one-shot -- ACP+PTY); restore resets the worktree to commit_sha, trims the transcript from -- message_id forward, and resets the backend session. chat_id CASCADEs from chats -- (like agent_sessions); worktree_id SET NULL so a checkpoint outlives a reaped -- worktree row. session_id / message_id are informational (no FK — message rows are -- trimmed by a checkpoint restore and we must not block that on a dangling ref). CREATE TABLE IF NOT EXISTS checkpoints ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, session_id UUID, worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL, message_id UUID, -- anchor: the assistant turn row this checkpoint precedes commit_sha TEXT NOT NULL, -- shadow-commit capturing the pre-turn worktree tree label TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); CREATE INDEX IF NOT EXISTS checkpoints_chat_created_idx ON checkpoints(chat_id, created_at); -- claude-sdk-sessionstore #9 (Part 1): append-only mirror of Claude Agent SDK -- session transcripts. The SDK's SessionStore adapter writes one JSONL line per -- entry; PostgresSessionStore (services/backends/claude-session-store.ts) inserts -- one row per entry and replays them ORDER BY id on resume. The store is generic -- per the SDK's SessionKey (project_key, session_id, subpath) — chat↔session -- ownership lives in agent_sessions, not here. subpath '' is the main transcript -- (the SDK's undefined subpath maps to '' in the column). CREATE TABLE IF NOT EXISTS claude_session_entries ( id BIGSERIAL PRIMARY KEY, project_key TEXT NOT NULL, session_id TEXT NOT NULL, subpath TEXT NOT NULL DEFAULT '', -- '' = main transcript (SDK's undefined subpath maps here) entry JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); CREATE INDEX IF NOT EXISTS claude_session_entries_key_idx ON claude_session_entries (project_key, session_id, subpath, id); -- claude-sdk-sessionstore #9 (Part 2): the warm Claude-SDK backend persists its -- agent_sessions rows with backend='claude_sdk'. Widen the named CHECK to accept -- it. Idempotent: DROP the named constraint (the inline CREATE TABLE check above -- carries this explicit name, so DROP IF EXISTS targets it) + re-ADD the widened -- list. Re-runs/fresh deploys land on the same final constraint (the table-level -- CREATE already includes only the old two values on a fresh DB; this block then -- replaces it with the three-value list). ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_backend_chk; ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk')); -- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes, -- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same -- transaction, so the dispatcher reacts immediately instead of waiting for the -- fallback poll. Postgres holds the notification until COMMIT, so the listener -- always sees the committed row. A trigger covers all insert paths with no -- app-code drift. Idempotent: re-applied on every startup. CREATE OR REPLACE FUNCTION notify_tasks_new() RETURNS trigger AS $$ BEGIN PERFORM pg_notify('tasks_new', ''); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS tasks_notify_new ON tasks; CREATE TRIGGER tasks_notify_new AFTER INSERT ON tasks FOR EACH ROW EXECUTE FUNCTION notify_tasks_new();