-- A business task owns exactly one external Agent session. The external -- session identifier and every conversational turn stay server-side and are -- encrypted by the application before persistence. CREATE TABLE IF NOT EXISTS agent_sessions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, task_id uuid NOT NULL UNIQUE REFERENCES tasks(id) ON DELETE CASCADE, conversation_id text, provider text NOT NULL DEFAULT 'superagent', external_session_id_ciphertext text, status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'recovering', 'expired', 'failed', 'closed')), current_turn integer NOT NULL DEFAULT 0 CHECK (current_turn >= 0), recovery_count integer NOT NULL DEFAULT 0 CHECK (recovery_count >= 0), last_error text, last_message_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS agent_sessions_conversation_idx ON agent_sessions (organization_id, conversation_id, updated_at DESC) WHERE conversation_id IS NOT NULL; CREATE INDEX IF NOT EXISTS agent_sessions_pending_idx ON agent_sessions (organization_id, conversation_id, status, updated_at DESC) WHERE conversation_id IS NOT NULL AND status NOT IN ('closed', 'failed'); CREATE TABLE IF NOT EXISTS agent_session_messages ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, agent_session_id uuid NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, turn_no integer NOT NULL CHECK (turn_no > 0), role text NOT NULL CHECK (role IN ('user', 'assistant', 'system')), content_ciphertext text NOT NULL, status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sending', 'sent', 'replayed', 'failed')), idempotency_key text, error_code text, created_at timestamptz NOT NULL DEFAULT now(), sent_at timestamptz, UNIQUE (agent_session_id, turn_no, role) ); CREATE UNIQUE INDEX IF NOT EXISTS agent_session_messages_idempotency_idx ON agent_session_messages (organization_id, idempotency_key) WHERE idempotency_key IS NOT NULL; CREATE INDEX IF NOT EXISTS agent_session_messages_queue_idx ON agent_session_messages (agent_session_id, status, role, turn_no); CREATE INDEX IF NOT EXISTS agent_session_messages_task_idx ON agent_session_messages (organization_id, task_id, turn_no); -- Existing tasks were created before task-scoped sessions existed. Backfill a -- pending session and preserve the already encrypted original instruction as -- the first user turn. The application will attach conversation metadata when -- a later message arrives. INSERT INTO agent_sessions (organization_id, task_id, status, current_turn, created_at, updated_at) SELECT t.organization_id, t.id, CASE WHEN t.status IN ( 'completed', 'cancelled', 'parse_failed', 'parse_blocked', 'blocked', 'failed', 'dry_run', 'reconciliation_pending', 'execution_uncertain', 'saved_unverified', 'operation_blocked' ) THEN 'closed' ELSE 'pending' END, 1, t.created_at, t.updated_at FROM tasks t WHERE NOT EXISTS ( SELECT 1 FROM agent_sessions s WHERE s.task_id = t.id ); INSERT INTO agent_session_messages (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, status, created_at, sent_at) SELECT t.organization_id, s.id, t.id, 1, 'user', t.original_text_ciphertext, CASE WHEN t.status = 'parse_queued' THEN 'queued' WHEN t.status = 'parse_running' THEN 'sending' ELSE 'sent' END, t.created_at, CASE WHEN t.status IN ('parse_queued', 'parse_running') THEN NULL ELSE t.created_at END FROM tasks t JOIN agent_sessions s ON s.task_id = t.id WHERE t.original_text_ciphertext IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM agent_session_messages m WHERE m.agent_session_id = s.id AND m.turn_no = 1 AND m.role = 'user' );