pending_changes.agent stamped at every queue site (native -> 'boocode', dispatched external -> task.agent, manual RightRail -> NULL) + flows through listPending. New GET /api/sessions/:id/agent-sessions -> [{agent,status,has_session,last_active_at}] per (chat,agent). opencode warm server consumes session.next.step.ended, accumulating input_tokens/output_tokens/cost onto agent_sessions (new idempotent columns) via a pure opencode-usage.ts mapper. Tests: agent-sessions.routes (3) + opencode-usage (6); tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
261 lines
12 KiB
PL/PgSQL
261 lines
12 KiB
PL/PgSQL
-- 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;
|
|
|
|
-- 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();
|