-- 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; -- ─── 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; -- 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();