Files
coder/coderd/database/queries/chats.sql
T
Michael Suchacz 1458d27d78 fix: allow manual chat compaction from the error state (#28022)
A chat that fails generation with a context overflow (for example `Input
length 262625 exceeds the maximum allowed input length of 262112
tokens`) is stuck in a catch-22: `POST /chats/{id}/compact` returns 409
because the `RequestCompaction` transition is only allowed from the
waiting state, and the only other way out of the error state is sending
or editing a message, which re-runs generation with the same oversized
prompt and fails again. Compaction is exactly the recovery a
context-overflowed chat needs, and it is unreachable exactly when it is
needed.

Three semantic changes:

- Allow `RequestCompaction` from the error states: `E0 -> R0` and `E1 ->
R1` (queued messages are preserved and processed after the compaction
turn).
- Clear `last_error` in `Tx.RequestCompaction`, matching the
architecture rule that transitions leaving `E0`/`E1` clear the stored
error. Without this a successful compaction would land in waiting with a
stale persisted error.
- Grant the compaction turn a fresh history epoch: a
`grant_history_epoch` flag on `UpdateChatExecutionState` sets
`history_version = snapshot_version`, resets `generation_attempt`, and
clears `retry_state` in the same atomic update that clears `last_error`
(mirroring the `chat_messages` trigger postcondition). The transition
inserts no history, so without this the turn inherits the failed turn's
spent retry budget, and resetting the counter alone could collide with
message part episode keys still retained on the erroring replica.

No frontend change is required: the chat input is already enabled in the
error state and `/compact` submission already handles both the success
and 409 paths. Also updates ARCHITECTURE.md (transition matrix,
endpoint, and manual compaction sections), the endpoint's swagger
description, and SDK comments.

> Mux created this PR on Mike's behalf.

<!-- mux-attribution: model=claude-sonnet-4-6 thinking=high -->
2026-08-12 20:38:57 +02:00

2803 lines
92 KiB
SQL

-- name: ArchiveChatByID :many
WITH updated_chats AS (
UPDATE chats
SET archived = true, pin_order = 0, updated_at = NOW()
WHERE id = @id::uuid OR root_chat_id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chats.id,
updated_chats.owner_id,
updated_chats.workspace_id,
updated_chats.title,
updated_chats.status,
updated_chats.worker_id,
updated_chats.started_at,
updated_chats.heartbeat_at,
updated_chats.created_at,
updated_chats.updated_at,
updated_chats.parent_chat_id,
updated_chats.root_chat_id,
updated_chats.last_model_config_id,
updated_chats.last_reasoning_effort,
updated_chats.archived,
updated_chats.last_error,
updated_chats.mode,
updated_chats.mcp_server_ids,
updated_chats.labels,
updated_chats.build_id,
updated_chats.agent_id,
updated_chats.pin_order,
updated_chats.last_read_message_id,
updated_chats.dynamic_tools,
updated_chats.organization_id,
updated_chats.plan_mode,
updated_chats.client_type,
updated_chats.last_turn_summary,
updated_chats.summary,
updated_chats.summary_generated_at,
updated_chats.snapshot_version,
updated_chats.history_version,
updated_chats.queue_version,
updated_chats.generation_attempt,
updated_chats.retry_state,
updated_chats.retry_state_version,
updated_chats.runner_id,
updated_chats.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chats.context_aggregate_hash,
updated_chats.context_dirty_since,
updated_chats.context_dirty_resources,
updated_chats.context_error,
updated_chats.compaction_requested_at
FROM
updated_chats
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT *
FROM chats_expanded
ORDER BY (chats_expanded.id = @id::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC;
-- name: UnarchiveChatByID :many
WITH updated_chats AS (
UPDATE chats SET
archived = false,
updated_at = NOW()
WHERE id = @id::uuid OR root_chat_id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chats.id,
updated_chats.owner_id,
updated_chats.workspace_id,
updated_chats.title,
updated_chats.status,
updated_chats.worker_id,
updated_chats.started_at,
updated_chats.heartbeat_at,
updated_chats.created_at,
updated_chats.updated_at,
updated_chats.parent_chat_id,
updated_chats.root_chat_id,
updated_chats.last_model_config_id,
updated_chats.last_reasoning_effort,
updated_chats.archived,
updated_chats.last_error,
updated_chats.mode,
updated_chats.mcp_server_ids,
updated_chats.labels,
updated_chats.build_id,
updated_chats.agent_id,
updated_chats.pin_order,
updated_chats.last_read_message_id,
updated_chats.dynamic_tools,
updated_chats.organization_id,
updated_chats.plan_mode,
updated_chats.client_type,
updated_chats.last_turn_summary,
updated_chats.summary,
updated_chats.summary_generated_at,
updated_chats.snapshot_version,
updated_chats.history_version,
updated_chats.queue_version,
updated_chats.generation_attempt,
updated_chats.retry_state,
updated_chats.retry_state_version,
updated_chats.runner_id,
updated_chats.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chats.context_aggregate_hash,
updated_chats.context_dirty_since,
updated_chats.context_dirty_resources,
updated_chats.context_error,
updated_chats.compaction_requested_at
FROM
updated_chats
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT *
FROM chats_expanded
ORDER BY (chats_expanded.id = @id::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC;
-- name: PinChatByID :exec
WITH target_chat AS (
SELECT
id,
owner_id
FROM
chats
WHERE
id = @id::uuid
),
-- Under READ COMMITTED, concurrent pin operations for the same
-- owner may momentarily produce duplicate pin_order values because
-- each CTE snapshot does not see the other's writes. The next
-- pin/unpin/reorder operation's ROW_NUMBER() self-heals the
-- sequence, so this is acceptable.
ranked AS (
SELECT
c.id,
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS next_pin_order
FROM
chats c
JOIN
target_chat ON c.owner_id = target_chat.owner_id
WHERE
c.pin_order > 0
AND c.archived = FALSE
AND c.id <> target_chat.id
),
updates AS (
SELECT
ranked.id,
ranked.next_pin_order AS pin_order
FROM
ranked
UNION ALL
SELECT
target_chat.id,
COALESCE((
SELECT
MAX(ranked.next_pin_order)
FROM
ranked
), 0) + 1 AS pin_order
FROM
target_chat
)
UPDATE
chats c
SET
pin_order = updates.pin_order
FROM
updates
WHERE
c.id = updates.id;
-- name: UnpinChatByID :exec
WITH target_chat AS (
SELECT
id,
owner_id
FROM
chats
WHERE
id = @id::uuid
),
ranked AS (
SELECT
c.id,
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position
FROM
chats c
JOIN
target_chat ON c.owner_id = target_chat.owner_id
WHERE
c.pin_order > 0
AND c.archived = FALSE
),
target AS (
SELECT
ranked.id,
ranked.current_position
FROM
ranked
WHERE
ranked.id = @id::uuid
),
updates AS (
SELECT
ranked.id,
CASE
WHEN ranked.id = target.id THEN 0
WHEN ranked.current_position > target.current_position THEN ranked.current_position - 1
ELSE ranked.current_position
END AS pin_order
FROM
ranked
CROSS JOIN
target
)
UPDATE
chats c
SET
pin_order = updates.pin_order
FROM
updates
WHERE
c.id = updates.id;
-- name: UpdateChatPinOrder :exec
WITH target_chat AS (
SELECT
id,
owner_id
FROM
chats
WHERE
id = @id::uuid
),
ranked AS (
SELECT
c.id,
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position,
COUNT(*) OVER () :: integer AS pinned_count
FROM
chats c
JOIN
target_chat ON c.owner_id = target_chat.owner_id
WHERE
c.pin_order > 0
AND c.archived = FALSE
),
target AS (
SELECT
ranked.id,
ranked.current_position,
LEAST(GREATEST(@pin_order::integer, 1), ranked.pinned_count) AS desired_position
FROM
ranked
WHERE
ranked.id = @id::uuid
),
updates AS (
SELECT
ranked.id,
CASE
WHEN ranked.id = target.id THEN target.desired_position
WHEN target.desired_position < target.current_position
AND ranked.current_position >= target.desired_position
AND ranked.current_position < target.current_position THEN ranked.current_position + 1
WHEN target.desired_position > target.current_position
AND ranked.current_position > target.current_position
AND ranked.current_position <= target.desired_position THEN ranked.current_position - 1
ELSE ranked.current_position
END AS pin_order
FROM
ranked
CROSS JOIN
target
)
UPDATE
chats c
SET
pin_order = updates.pin_order
FROM
updates
WHERE
c.id = updates.id;
-- name: SoftDeleteChatMessagesAfterID :exec
UPDATE
chat_messages
SET
deleted = true
WHERE
chat_id = @chat_id::uuid
AND id > @after_id::bigint;
-- name: SoftDeleteChatMessageByID :exec
UPDATE
chat_messages
SET
deleted = true
WHERE
id = @id::bigint;
-- name: BackfillChatMessagesSearchTsv :execrows
-- Backfills chat_messages.search_tsv for pending rows, newest first.
-- The WHERE clause must match the predicate of
-- idx_chat_messages_search_tsv_pending exactly so the partial index
-- serves this query.
WITH batch AS (
SELECT id FROM chat_messages
WHERE search_tsv IS NULL
AND deleted = false
AND visibility IN ('user', 'both')
AND role IN ('user', 'assistant')
ORDER BY id DESC
LIMIT @batch_size::int
)
UPDATE chat_messages cm
-- NULL means "pending", empty tsvector means "backfilled, no text".
SET search_tsv = COALESCE(
to_tsvector('simple', chat_message_search_text(cm.content)),
''::tsvector)
FROM batch WHERE cm.id = batch.id;
-- name: GetChatByID :one
SELECT *
FROM chats_expanded
WHERE id = @id::uuid;
-- name: GetChatFamilyIDsByRootID :many
-- Returns the chat IDs of every chat in a family (root + all children)
-- in deterministic order. The id parameter must be the root id; the
-- query does not walk up from a child.
SELECT id
FROM chats
WHERE id = @id::uuid OR root_chat_id = @id::uuid
ORDER BY (id = @id::uuid) DESC, created_at ASC, id ASC;
-- name: GetChatACLByID :one
SELECT
user_acl AS users,
group_acl AS groups
FROM
chats
WHERE
id = @id::uuid;
-- name: UpdateChatACLByID :exec
UPDATE
chats
SET
user_acl = @user_acl,
group_acl = @group_acl
WHERE
id = @id::uuid;
-- name: GetChatMessageByID :one
SELECT
*
FROM
chat_messages
WHERE
id = @id::bigint
AND deleted = false;
-- name: GetChatMessagesByChatID :many
-- Ordered by id to match the @after_id cursor. created_at is the transaction
-- start time, so it can disagree with append order when a transaction takes the
-- chat row lock later than one that started after it.
SELECT
*
FROM
chat_messages
WHERE
chat_id = @chat_id::uuid
AND id > @after_id::bigint
AND visibility IN ('user', 'both')
AND deleted = false
ORDER BY
id ASC;
-- name: GetChatMessagesByRevisionForStream :many
-- Stream deltas and reset snapshots must use the same message order.
SELECT
*
FROM
chat_messages
WHERE
chat_id = @chat_id::uuid
AND revision > @after_revision::bigint
AND visibility IN ('user', 'both')
ORDER BY
id ASC;
-- name: GetChatMessagesByChatIDAscPaginated :many
SELECT
*
FROM
chat_messages
WHERE
chat_id = @chat_id::uuid
AND id > @after_id::bigint
AND visibility IN ('user', 'both')
AND deleted = false
ORDER BY
id ASC
LIMIT
COALESCE(NULLIF(@limit_val::int, 0), 50);
-- name: GetChatMessagesByChatIDDescPaginated :many
SELECT
*
FROM
chat_messages
WHERE
chat_id = @chat_id::uuid
AND CASE
WHEN @before_id::bigint > 0 THEN id < @before_id::bigint
ELSE true
END
AND CASE
WHEN @after_id::bigint > 0 THEN id > @after_id::bigint
ELSE true
END
AND visibility IN ('user', 'both')
AND deleted = false
ORDER BY
id DESC
LIMIT
COALESCE(NULLIF(@limit_val::int, 0), 50);
-- name: GetChatUserPromptsByChatID :many
-- Returns the concatenated text of each user-visible user prompt in a
-- chat, newest first. Used by the composer to populate the up/down
-- arrow prompt-history cycle. Non-text parts (tool calls, files,
-- attachments, ...) are excluded; messages whose text payload is
-- entirely whitespace are dropped so cycling never lands on a blank
-- entry. The jsonb_typeof guard skips legacy V0 rows whose content is
-- a scalar JSON string (predates migration 000434) so the lateral
-- jsonb_array_elements never raises "cannot extract elements from a
-- scalar". Backed by idx_chat_messages_user_prompts.
SELECT
cm.id,
string_agg(part->>'text', '' ORDER BY ordinality)::text AS text
FROM
chat_messages cm,
jsonb_array_elements(cm.content) WITH ORDINALITY AS t(part, ordinality)
WHERE
cm.chat_id = @chat_id::uuid
AND cm.role = 'user'
AND cm.deleted = false
AND cm.visibility IN ('user', 'both')
AND jsonb_typeof(cm.content) = 'array'
AND part->>'type' = 'text'
GROUP BY
cm.id
HAVING
string_agg(part->>'text', '') ~ '\S'
ORDER BY
cm.id DESC
LIMIT
COALESCE(NULLIF(@limit_val::int, 0), 500);
-- name: GetChatMessagesForPromptByChatID :many
-- The compaction boundary and final ordering must use the same key so tool
-- results remain after their assistant calls.
WITH latest_compressed_summary AS (
SELECT
id
FROM
chat_messages
WHERE
chat_id = @chat_id::uuid
AND compressed = TRUE
AND deleted = false
AND visibility = 'model'
ORDER BY
id DESC
LIMIT
1
)
SELECT
*
FROM
chat_messages
WHERE
chat_id = @chat_id::uuid
AND visibility IN ('model', 'both')
AND deleted = false
AND (
(
role = 'system'
AND compressed = FALSE
)
OR (
compressed = FALSE
AND (
NOT EXISTS (
SELECT
1
FROM
latest_compressed_summary
)
OR id > (
SELECT
id
FROM
latest_compressed_summary
)
)
)
OR id = (
SELECT
id
FROM
latest_compressed_summary
)
)
ORDER BY
id ASC;
-- name: GetChats :many
WITH cursor_chat AS (
SELECT
pin_order,
updated_at,
id
FROM chats
WHERE id = @after_id
)
SELECT
sqlc.embed(chats_expanded),
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
AND cm.role = 'assistant'
AND cm.deleted = false
AND cm.id > COALESCE(chats_expanded.last_read_message_id, 0)
) AS has_unread
FROM
chats_expanded
WHERE
(
(NOT @owned_only::boolean AND NOT @shared_only::boolean)
OR (@owned_only::boolean AND chats_expanded.owner_id = @viewer_id::uuid)
OR (
@shared_only::boolean
AND chats_expanded.owner_id != @viewer_id::uuid
AND (
chats_expanded.user_acl ? (@shared_with_user_id::uuid)::text
OR chats_expanded.group_acl ?| @shared_with_group_ids::text[]
)
)
)
AND CASE
WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
ELSE chats_expanded.archived = sqlc.narg('archived') :: boolean
END
AND CASE
-- Cursor pagination: the last element on a page acts as the cursor.
-- The 4-tuple matches the ORDER BY below. All columns sort DESC
-- (pin_order is negated so lower values sort first in DESC order),
-- which lets us use a single tuple < comparison.
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
(CASE WHEN chats_expanded.pin_order > 0 THEN 1 ELSE 0 END, -chats_expanded.pin_order, chats_expanded.updated_at, chats_expanded.id) < (
SELECT
CASE WHEN cursor_chat.pin_order > 0 THEN 1 ELSE 0 END,
-cursor_chat.pin_order,
cursor_chat.updated_at,
cursor_chat.id
FROM
cursor_chat
)
)
ELSE true
END
AND CASE
WHEN sqlc.narg('label_filter')::jsonb IS NOT NULL THEN chats_expanded.labels @> sqlc.narg('label_filter')::jsonb
ELSE true
END
-- Match chats whose linked diff URL (e.g. a pull request URL)
-- equals the given value, case-insensitively. The URL may live on
-- a delegated sub-agent's diff status, so we surface the root chat
-- when any descendant matches.
AND CASE
WHEN sqlc.narg('diff_url')::text IS NOT NULL THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
JOIN chats c2 ON c2.id = cds.chat_id
WHERE cds.url IS NOT NULL
AND cds.url <> ''
AND LOWER(cds.url) = LOWER(sqlc.narg('diff_url')::text)
AND (c2.id = chats_expanded.id OR c2.root_chat_id = chats_expanded.id)
)
ELSE true
END
-- Filter by title substring (case-insensitive). Applied when the
-- caller provides a non-empty title_query.
AND CASE
WHEN @title_query :: text != '' THEN chats_expanded.title ILIKE '%' || @title_query || '%'
ELSE true
END
AND CASE
WHEN sqlc.narg('has_unread')::boolean IS NOT NULL THEN (
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
AND cm.role = 'assistant'
AND cm.deleted = false
AND cm.id > COALESCE(chats_expanded.last_read_message_id, 0)
)
) = sqlc.narg('has_unread')::boolean
ELSE true
END
-- Filter by pull request status. Unlike the diff_url filter above,
-- this intentionally checks only the root chat's own diff status.
-- Child chats share the same workspace and git branch as their
-- parent, so gitsync populates identical PR state on both; traversing
-- descendants would be redundant.
AND CASE
WHEN COALESCE(array_length(@pull_request_statuses::text[], 1), 0) > 0 THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND (
CASE
WHEN cds.pull_request_state = 'open' AND cds.pull_request_draft THEN 'draft'
WHEN cds.pull_request_state = 'open' THEN 'open'
ELSE cds.pull_request_state
END
) = ANY(@pull_request_statuses::text[])
)
ELSE true
END
-- Filter by PR number (exact match on chat's diff status).
AND CASE
WHEN @pr_number::int != 0 THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND cds.pr_number = @pr_number
)
ELSE true
END
-- Filter by repository (substring match on remote origin or PR URL).
AND CASE
WHEN @repo_query::text != '' THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND (
cds.git_remote_origin ILIKE '%' || @repo_query || '%'
OR cds.url ILIKE '%' || @repo_query || '%'
)
)
ELSE true
END
-- Filter by pull request title (case-insensitive substring).
AND CASE
WHEN @pr_title_query::text != '' THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND cds.pull_request_title ILIKE '%' || @pr_title_query || '%'
)
ELSE true
END
-- websearch_to_tsquery accepts quoted phrases, OR, and -negation;
-- the 'simple' config folds case and skips stemming.
AND CASE
WHEN @search::text != '' THEN (
-- Served by idx_chats_title_fts.
to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search)
-- Served by idx_chat_diff_statuses_pr_title_fts.
OR EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', @search)
)
-- The WHERE clause must repeat the predicate of the partial index
-- idx_chat_messages_search_tsv so the planner can use it. Additional
-- filters should still be fine.
OR EXISTS (
SELECT 1
FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
AND cm.search_tsv IS NOT NULL
AND cm.deleted = false
AND cm.visibility IN ('user', 'both')
AND cm.role IN ('user', 'assistant')
AND cm.search_tsv @@ websearch_to_tsquery('simple', @search)
)
-- Skip an explicit pr_number lookup unless the search is a valid bigint.
OR CASE
WHEN @search ~ '^[0-9]{1,18}$' THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND cds.pr_number IS NOT NULL
AND cds.pr_number = @search::bigint
)
ELSE false
END
)
ELSE true
END
-- Paginate over root chats only. Children are fetched
-- separately via GetChildChatsByParentIDs and embedded under
-- each parent. Other callers that need the full set should
-- use a narrower query (e.g. GetChatsByWorkspaceIDs).
AND chats_expanded.parent_chat_id IS NULL
-- Authorize Filter clause will be injected below in GetAuthorizedChats
-- @authorize_filter
ORDER BY
-- Pinned chats (pin_order > 0) sort before unpinned ones. Within
-- pinned chats, lower pin_order values come first. The negation
-- trick (-pin_order) keeps all sort columns DESC so the cursor
-- tuple < comparison works with uniform direction.
CASE WHEN chats_expanded.pin_order > 0 THEN 1 ELSE 0 END DESC,
-chats_expanded.pin_order DESC,
chats_expanded.updated_at DESC,
chats_expanded.id DESC
OFFSET @offset_opt
LIMIT
-- The chat list is unbounded and expected to grow large.
-- Default to 50 to prevent accidental excessively large queries.
COALESCE(NULLIF(@limit_opt :: int, 0), 50);
-- name: GetChildChatsByParentIDs :many
-- Fetches child chats of the given parents, optionally filtered by
-- archive state (NULL = all, true/false = match). The archive
-- invariant (parent archived implies child archived) is enforced
-- at write time, not here.
SELECT
sqlc.embed(chats_expanded),
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
AND cm.role = 'assistant'
AND cm.deleted = false
AND cm.id > COALESCE(chats_expanded.last_read_message_id, 0)
) AS has_unread
FROM
chats_expanded
WHERE
chats_expanded.parent_chat_id = ANY(@parent_ids :: uuid[])
AND CASE
WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
ELSE chats_expanded.archived = sqlc.narg('archived') :: boolean
END
ORDER BY
chats_expanded.created_at DESC,
chats_expanded.id DESC;
-- name: InsertChat :one
WITH inserted_chat AS (
INSERT INTO chats (
id,
organization_id,
owner_id,
workspace_id,
build_id,
agent_id,
parent_chat_id,
root_chat_id,
last_model_config_id,
title,
mode,
plan_mode,
status,
mcp_server_ids,
labels,
dynamic_tools,
client_type
) VALUES (
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid()),
@organization_id::uuid,
@owner_id::uuid,
sqlc.narg('workspace_id')::uuid,
sqlc.narg('build_id')::uuid,
sqlc.narg('agent_id')::uuid,
sqlc.narg('parent_chat_id')::uuid,
sqlc.narg('root_chat_id')::uuid,
@last_model_config_id::uuid,
@title::text,
sqlc.narg('mode')::chat_mode,
sqlc.narg('plan_mode')::chat_plan_mode,
@status::chat_status,
COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
COALESCE(sqlc.narg('labels')::jsonb, '{}'::jsonb),
sqlc.narg('dynamic_tools')::jsonb,
@client_type::chat_client_type
)
RETURNING *
),
chats_expanded AS (
SELECT
inserted_chat.id,
inserted_chat.owner_id,
inserted_chat.workspace_id,
inserted_chat.title,
inserted_chat.status,
inserted_chat.worker_id,
inserted_chat.started_at,
inserted_chat.heartbeat_at,
inserted_chat.created_at,
inserted_chat.updated_at,
inserted_chat.parent_chat_id,
inserted_chat.root_chat_id,
inserted_chat.last_model_config_id,
inserted_chat.last_reasoning_effort,
inserted_chat.archived,
inserted_chat.last_error,
inserted_chat.mode,
inserted_chat.mcp_server_ids,
inserted_chat.labels,
inserted_chat.build_id,
inserted_chat.agent_id,
inserted_chat.pin_order,
inserted_chat.last_read_message_id,
inserted_chat.dynamic_tools,
inserted_chat.organization_id,
inserted_chat.plan_mode,
inserted_chat.client_type,
inserted_chat.last_turn_summary,
inserted_chat.summary,
inserted_chat.summary_generated_at,
inserted_chat.snapshot_version,
inserted_chat.history_version,
inserted_chat.queue_version,
inserted_chat.generation_attempt,
inserted_chat.retry_state,
inserted_chat.retry_state_version,
inserted_chat.runner_id,
inserted_chat.requires_action_deadline_at,
COALESCE(root.user_acl, inserted_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, inserted_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
inserted_chat.context_aggregate_hash,
inserted_chat.context_dirty_since,
inserted_chat.context_dirty_resources,
inserted_chat.context_error,
inserted_chat.compaction_requested_at
FROM
inserted_chat
LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = inserted_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: InsertChatMessages :many
-- Returns the inserted rows in input array order. Ids are allocated before the
-- insert and the k-th smallest is assigned to input index k, so callers may
-- index the result positionally.
WITH batch AS (
SELECT
(
SELECT val
FROM UNNEST(@model_config_id::uuid[])
WITH ORDINALITY AS t(val, ord)
WHERE val != '00000000-0000-0000-0000-000000000000'::uuid
ORDER BY ord DESC
LIMIT 1
) AS last_model_config_id,
(
SELECT NULLIF(val, '')::chat_reasoning_effort
FROM UNNEST(@reasoning_effort::text[])
WITH ORDINALITY AS t(val, ord)
WHERE val != ''
ORDER BY ord DESC
LIMIT 1
) AS last_reasoning_effort
),
updated_chat AS (
UPDATE
chats
SET
last_model_config_id = COALESCE(batch.last_model_config_id, chats.last_model_config_id),
last_reasoning_effort = COALESCE(batch.last_reasoning_effort, chats.last_reasoning_effort)
FROM batch
WHERE
chats.id = @chat_id::uuid
AND (
chats.last_model_config_id IS DISTINCT FROM COALESCE(batch.last_model_config_id, chats.last_model_config_id)
OR chats.last_reasoning_effort IS DISTINCT FROM COALESCE(batch.last_reasoning_effort, chats.last_reasoning_effort)
)
),
allocated AS MATERIALIZED (
-- Numbering the ids by value, rather than by the order nextval produced
-- them, is what makes ordinal k always the k-th smallest id. MATERIALIZED
-- is redundant while nextval is volatile, and pins that if it changes.
SELECT
id,
(ROW_NUMBER() OVER (ORDER BY id))::int AS ord
FROM (
SELECT nextval('chat_messages_id_seq') AS id
FROM generate_series(1, cardinality(@role::chat_message_role[]))
) s
),
inserted AS (
INSERT INTO chat_messages (
id,
chat_id,
created_by,
model_config_id,
reasoning_effort,
role,
content,
content_version,
visibility,
input_tokens,
output_tokens,
total_tokens,
reasoning_tokens,
cache_creation_tokens,
cache_read_tokens,
context_limit,
compressed,
runtime_ms
)
SELECT
allocated.id,
@chat_id::uuid,
NULLIF((@created_by::uuid[])[allocated.ord], '00000000-0000-0000-0000-000000000000'::uuid),
NULLIF((@model_config_id::uuid[])[allocated.ord], '00000000-0000-0000-0000-000000000000'::uuid),
NULLIF((@reasoning_effort::text[])[allocated.ord], '')::chat_reasoning_effort,
(@role::chat_message_role[])[allocated.ord],
(@content::text[])[allocated.ord]::jsonb,
(@content_version::smallint[])[allocated.ord],
(@visibility::chat_message_visibility[])[allocated.ord],
NULLIF((@input_tokens::bigint[])[allocated.ord], 0),
NULLIF((@output_tokens::bigint[])[allocated.ord], 0),
NULLIF((@total_tokens::bigint[])[allocated.ord], 0),
NULLIF((@reasoning_tokens::bigint[])[allocated.ord], 0),
NULLIF((@cache_creation_tokens::bigint[])[allocated.ord], 0),
NULLIF((@cache_read_tokens::bigint[])[allocated.ord], 0),
NULLIF((@context_limit::bigint[])[allocated.ord], 0),
(@compressed::boolean[])[allocated.ord],
NULLIF((@runtime_ms::bigint[])[allocated.ord], 0)
FROM allocated
RETURNING *
)
SELECT *
FROM inserted
ORDER BY id;
-- name: UpdateChatByID :one
WITH updated_chat AS (
UPDATE
chats
SET
title = @title::text,
updated_at = NOW()
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatTitleByID :one
WITH updated_chat AS (
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid
-- changing list ordering when a user renames an older chat
-- out-of-band.
title = @title::text
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatPlanModeByID :one
WITH updated_chat AS (
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
plan_mode = sqlc.narg('plan_mode')::chat_plan_mode
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatLastModelConfigByID :one
WITH updated_chat AS (
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
last_model_config_id = @last_model_config_id::uuid
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatLabelsByID :one
WITH updated_chat AS (
UPDATE
chats
SET
labels = @labels::jsonb,
updated_at = NOW()
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatWorkspaceBinding :one
WITH current_chat AS (
SELECT *
FROM chats
WHERE id = @id::uuid
),
binding_changed AS (
SELECT
workspace_id IS DISTINCT FROM sqlc.narg('workspace_id')::uuid
OR build_id IS DISTINCT FROM sqlc.narg('build_id')::uuid
OR agent_id IS DISTINCT FROM sqlc.narg('agent_id')::uuid AS changed
FROM current_chat
),
changed_chat AS (
UPDATE chats SET
workspace_id = sqlc.narg('workspace_id')::uuid,
build_id = sqlc.narg('build_id')::uuid,
agent_id = sqlc.narg('agent_id')::uuid,
updated_at = NOW()
WHERE id = @id::uuid
AND (SELECT changed FROM binding_changed)
RETURNING *
),
result_chat AS (
SELECT *
FROM changed_chat
UNION ALL
SELECT *
FROM current_chat
WHERE NOT (SELECT changed FROM binding_changed)
),
chats_expanded AS (
SELECT
result_chat.id,
result_chat.owner_id,
result_chat.workspace_id,
result_chat.title,
result_chat.status,
result_chat.worker_id,
result_chat.started_at,
result_chat.heartbeat_at,
result_chat.created_at,
result_chat.updated_at,
result_chat.parent_chat_id,
result_chat.root_chat_id,
result_chat.last_model_config_id,
result_chat.last_reasoning_effort,
result_chat.archived,
result_chat.last_error,
result_chat.mode,
result_chat.mcp_server_ids,
result_chat.labels,
result_chat.build_id,
result_chat.agent_id,
result_chat.pin_order,
result_chat.last_read_message_id,
result_chat.dynamic_tools,
result_chat.organization_id,
result_chat.plan_mode,
result_chat.client_type,
result_chat.last_turn_summary,
result_chat.summary,
result_chat.summary_generated_at,
result_chat.snapshot_version,
result_chat.history_version,
result_chat.queue_version,
result_chat.generation_attempt,
result_chat.retry_state,
result_chat.retry_state_version,
result_chat.runner_id,
result_chat.requires_action_deadline_at,
COALESCE(root.user_acl, result_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, result_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
result_chat.context_aggregate_hash,
result_chat.context_dirty_since,
result_chat.context_dirty_resources,
result_chat.context_error,
result_chat.compaction_requested_at
FROM
result_chat
LEFT JOIN chats root ON root.id = COALESCE(result_chat.root_chat_id, result_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = result_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatBuildAgentBinding :one
WITH updated_chat AS (
UPDATE chats SET
build_id = sqlc.narg('build_id')::uuid,
agent_id = sqlc.narg('agent_id')::uuid,
updated_at = NOW()
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatLastTurnSummary :execrows
-- Updates the cached last completed turn summary for sidebar display.
-- Empty or whitespace-only summaries are stored as NULL here so direct
-- query callers cannot accidentally persist blank sidebar text.
-- This intentionally preserves updated_at. The staleness guard uses
-- history_version so worker lifecycle transitions that do not change the
-- active message history cannot reject final turn summary writes.
-- Two summary workers using the same freshness marker are last-write-wins.
UPDATE chats
SET
last_turn_summary = NULLIF(REGEXP_REPLACE(
sqlc.narg('last_turn_summary')::text, '^[[:space:]]+|[[:space:]]+$', '', 'g'
), '')
WHERE
id = @id::uuid
AND history_version = @expected_history_version::bigint;
-- name: UpdateChatSummary :execrows
-- The history_version fence lets background summary writes ignore worker-only
-- updates while losing to newer message history.
UPDATE chats
SET
summary = sqlc.narg('summary')::text,
summary_generated_at = NOW()
WHERE
id = @id::uuid
AND history_version = @expected_history_version::bigint;
-- name: UpdateChatMCPServerIDs :one
WITH updated_chat AS (
UPDATE
chats
SET
mcp_server_ids = @mcp_server_ids::uuid[],
updated_at = NOW()
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: SetChatContextSnapshot :exec
-- Pins a single chat to the supplied context snapshot hash and error
-- and clears any dirty marker. Used by chat-create hydration and the
-- refresh endpoint. Does not bump updated_at: context pinning is
-- background state and must not reorder chat lists.
UPDATE chats
SET
context_aggregate_hash = @aggregate_hash,
context_error = @context_error,
context_dirty_since = NULL
WHERE id = @id::uuid;
-- name: HydrateAgentChatsContext :many
-- Stamps the pinned hash and error on every not-yet-hydrated chat for
-- an agent (context_aggregate_hash IS NULL) and copies the agent's
-- current context resources onto those chats in the same statement, so
-- a chat's pinned hash and pinned bodies are always written together.
-- Runs as a side effect of an agent push and of chat-create hydration,
-- so chats created before the agent was ready pick up the snapshot
-- without a dirty marker. The ON CONFLICT upsert is defensive: a
-- not-yet-hydrated chat has no pinned rows, so it normally inserts.
-- Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch
-- sets chat_context_resources.updated_at on the rows it rewrites.
-- Returns the hydrated chat IDs so callers can notify watchers of every
-- chat the statement pinned.
WITH hydrated AS (
UPDATE chats
SET
context_aggregate_hash = @aggregate_hash,
context_error = @context_error
WHERE agent_id = @agent_id::uuid
AND archived = false
AND context_aggregate_hash IS NULL
RETURNING id
),
copied AS (
INSERT INTO chat_context_resources (
chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path
)
SELECT
hydrated.id, r.source, r.body_kind, r.body, r.content_hash,
r.size_bytes, r.status, r.error, r.source_path
FROM hydrated
CROSS JOIN workspace_agent_context_resources r
WHERE r.workspace_agent_id = @agent_id::uuid
ON CONFLICT (chat_id, source) DO UPDATE SET
body_kind = EXCLUDED.body_kind,
body = EXCLUDED.body,
content_hash = EXCLUDED.content_hash,
size_bytes = EXCLUDED.size_bytes,
status = EXCLUDED.status,
error = EXCLUDED.error,
source_path = EXCLUDED.source_path,
updated_at = now()
)
SELECT id FROM hydrated;
-- name: MarkChatsContextDirtyByAgent :many
-- Flips active, already-hydrated chats for an agent to dirty when the
-- agent's latest snapshot hash differs from the chat's pinned hash. The
-- pinned hash is intentionally left untouched; the refresh endpoint
-- re-pins it. Returns the chats that transitioned so the caller can
-- emit watch events after the transaction commits.
UPDATE chats
SET context_dirty_since = @dirty_since
WHERE agent_id = @agent_id::uuid
AND archived = false
AND status IN ('waiting', 'running', 'requires_action')
AND context_aggregate_hash IS NOT NULL
AND context_aggregate_hash IS DISTINCT FROM @aggregate_hash
AND context_dirty_since IS NULL
RETURNING id, owner_id;
-- name: InsertAgentContextResourcesIntoChat :exec
-- Copies an agent's current context resources onto a single chat. Pair
-- with DeleteChatContextResourcesByChatID (clear-then-copy, in a
-- transaction) to re-pin a chat to its agent's latest snapshot from the
-- refresh endpoint and on agent rebinding.
INSERT INTO chat_context_resources (
chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path
)
SELECT
@chat_id::uuid, r.source, r.body_kind, r.body, r.content_hash,
r.size_bytes, r.status, r.error, r.source_path
FROM workspace_agent_context_resources r
WHERE r.workspace_agent_id = @agent_id::uuid;
-- name: DeleteChatContextResourcesByChatID :exec
-- Clears a chat's pinned context resources. Used as the first half of a
-- clear-then-copy re-pin, and on its own when the chat's current agent
-- has no snapshot.
DELETE FROM chat_context_resources
WHERE chat_id = @chat_id::uuid;
-- name: ListChatContextResourcesByChatID :many
-- Lists a chat's pinned context resources, ordered deterministically by
-- source.
SELECT * FROM chat_context_resources
WHERE chat_id = @chat_id::uuid
ORDER BY source ASC;
-- name: LinkChatFilesAfterLock :one
-- LinkChatFilesAfterLock requires the chat row lock.
-- The lock serializes cap checks. The result counts rejected new links.
WITH current AS (
SELECT COUNT(*) AS cnt
FROM chat_file_links
WHERE chat_id = @chat_id::uuid
),
new_links AS (
SELECT DISTINCT @chat_id::uuid AS chat_id, unnest(@file_ids::uuid[]) AS file_id
),
genuinely_new AS (
SELECT nl.chat_id, nl.file_id
FROM new_links nl
WHERE NOT EXISTS (
SELECT 1 FROM chat_file_links cfl
WHERE cfl.chat_id = nl.chat_id AND cfl.file_id = nl.file_id
)
),
inserted AS (
INSERT INTO chat_file_links (chat_id, file_id)
SELECT gn.chat_id, gn.file_id
FROM genuinely_new gn, current c
WHERE c.cnt + (SELECT COUNT(*) FROM genuinely_new) <= @max_file_links::int
ON CONFLICT (chat_id, file_id) DO NOTHING
RETURNING file_id
)
SELECT
(SELECT COUNT(*)::int FROM genuinely_new) -
(SELECT COUNT(*)::int FROM inserted) AS rejected_new_files;
-- name: UpdateChatStatus :one
WITH updated_chat AS (
UPDATE
chats
SET
status = @status::chat_status,
worker_id = sqlc.narg('worker_id')::uuid,
started_at = sqlc.narg('started_at')::timestamptz,
heartbeat_at = sqlc.narg('heartbeat_at')::timestamptz,
last_error = sqlc.narg('last_error')::jsonb,
updated_at = NOW()
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: GetStaleChats :many
-- Find chats that appear stuck and need recovery:
-- 1. Running chats whose heartbeat has expired (worker crash).
-- 2. requires_action chats past the timeout threshold (client
-- disappeared).
-- 3. Waiting chats with a non-empty queue and stale updated_at
-- (deferred-promote stranding when the worker dies before its
-- post-cancel cleanup runs).
SELECT
*
FROM
chats_expanded
WHERE
(status = 'running'::chat_status
AND heartbeat_at < @stale_threshold::timestamptz)
OR (status = 'requires_action'::chat_status
AND updated_at < @stale_threshold::timestamptz)
OR (status = 'waiting'::chat_status
AND updated_at < @stale_threshold::timestamptz
AND EXISTS (
SELECT 1 FROM chat_queued_messages cqm
WHERE cqm.chat_id = chats_expanded.id
));
-- name: UpdateChatHeartbeats :many
-- Bumps the heartbeat timestamp for the given set of chat IDs,
-- provided they are still running and owned by the specified
-- worker. Returns the IDs that were actually updated so the
-- caller can detect stolen or completed chats via set-difference.
UPDATE
chats
SET
heartbeat_at = @now::timestamptz
WHERE
id = ANY(@ids::uuid[])
AND worker_id = @worker_id::uuid
AND status = 'running'::chat_status
RETURNING id;
-- name: GetChatDiffStatusByChatID :one
SELECT
*
FROM
chat_diff_statuses
WHERE
chat_id = @chat_id::uuid;
-- name: GetChatDiffStatusesByChatIDs :many
SELECT
*
FROM
chat_diff_statuses
WHERE
chat_id = ANY(@chat_ids::uuid[]);
-- name: UpsertChatDiffStatusReference :one
INSERT INTO chat_diff_statuses (
chat_id,
url,
git_branch,
git_remote_origin,
stale_at
) VALUES (
@chat_id::uuid,
sqlc.narg('url')::text,
@git_branch::text,
@git_remote_origin::text,
@stale_at::timestamptz
)
ON CONFLICT (chat_id) DO UPDATE
SET
url = CASE
WHEN EXCLUDED.url IS NOT NULL THEN EXCLUDED.url
ELSE chat_diff_statuses.url
END,
git_branch = CASE
WHEN EXCLUDED.git_branch != '' THEN EXCLUDED.git_branch
ELSE chat_diff_statuses.git_branch
END,
git_remote_origin = CASE
WHEN EXCLUDED.git_remote_origin != '' THEN EXCLUDED.git_remote_origin
ELSE chat_diff_statuses.git_remote_origin
END,
stale_at = EXCLUDED.stale_at,
updated_at = NOW()
RETURNING
*;
-- name: UpsertChatDiffStatus :one
INSERT INTO chat_diff_statuses (
chat_id,
url,
pull_request_state,
pull_request_title,
pull_request_draft,
changes_requested,
additions,
deletions,
changed_files,
author_login,
author_avatar_url,
base_branch,
head_branch,
pr_number,
commits,
approved,
reviewer_count,
refreshed_at,
stale_at
) VALUES (
@chat_id::uuid,
sqlc.narg('url')::text,
sqlc.narg('pull_request_state')::text,
@pull_request_title::text,
@pull_request_draft::boolean,
@changes_requested::boolean,
@additions::integer,
@deletions::integer,
@changed_files::integer,
sqlc.narg('author_login')::text,
sqlc.narg('author_avatar_url')::text,
sqlc.narg('base_branch')::text,
sqlc.narg('head_branch')::text,
sqlc.narg('pr_number')::integer,
sqlc.narg('commits')::integer,
sqlc.narg('approved')::boolean,
sqlc.narg('reviewer_count')::integer,
@refreshed_at::timestamptz,
@stale_at::timestamptz
)
ON CONFLICT (chat_id) DO UPDATE
SET
url = EXCLUDED.url,
pull_request_state = EXCLUDED.pull_request_state,
pull_request_title = EXCLUDED.pull_request_title,
pull_request_draft = EXCLUDED.pull_request_draft,
changes_requested = EXCLUDED.changes_requested,
additions = EXCLUDED.additions,
deletions = EXCLUDED.deletions,
changed_files = EXCLUDED.changed_files,
author_login = EXCLUDED.author_login,
author_avatar_url = EXCLUDED.author_avatar_url,
base_branch = EXCLUDED.base_branch,
head_branch = EXCLUDED.head_branch,
pr_number = EXCLUDED.pr_number,
commits = EXCLUDED.commits,
approved = EXCLUDED.approved,
reviewer_count = EXCLUDED.reviewer_count,
refreshed_at = EXCLUDED.refreshed_at,
stale_at = EXCLUDED.stale_at,
updated_at = NOW()
RETURNING
*;
-- name: InsertChatQueuedMessage :one
-- Legacy queue insertion path. When no caller-supplied creator exists,
-- preserve the created_by invariant by attributing the queued row to the
-- chat owner.
INSERT INTO chat_queued_messages (chat_id, content, model_config_id, reasoning_effort, created_by)
SELECT
@chat_id::uuid,
@content::jsonb,
sqlc.narg('model_config_id')::uuid,
sqlc.narg('reasoning_effort')::chat_reasoning_effort,
chats.owner_id
FROM chats
WHERE chats.id = @chat_id::uuid
RETURNING *;
-- name: GetChatQueuedMessages :many
SELECT * FROM chat_queued_messages
WHERE chat_id = @chat_id
ORDER BY created_at ASC, id ASC;
-- name: DeleteChatQueuedMessage :exec
DELETE FROM chat_queued_messages WHERE id = @id AND chat_id = @chat_id;
-- name: DeleteAllChatQueuedMessages :exec
DELETE FROM chat_queued_messages WHERE chat_id = @chat_id;
-- name: PopNextQueuedMessage :one
DELETE FROM chat_queued_messages
WHERE id = (
SELECT cqm.id FROM chat_queued_messages cqm
WHERE cqm.chat_id = @chat_id
ORDER BY cqm.created_at ASC, cqm.id ASC
LIMIT 1
)
RETURNING *;
-- name: ReorderChatQueuedMessageToFront :execrows
-- Mutates only created_at on the target row; ids are unchanged so
-- consumers can keep tracking queued messages by id.
UPDATE chat_queued_messages AS target
SET created_at = (
SELECT MIN(inner_cqm.created_at) - INTERVAL '1 microsecond'
FROM chat_queued_messages AS inner_cqm
WHERE inner_cqm.chat_id = @chat_id
)
WHERE target.id = @target_id AND target.chat_id = @chat_id;
-- name: GetLastChatMessageByRole :one
-- The returned id becomes both an AfterID cursor and last_read_message_id, so
-- "last" must use id order.
SELECT
*
FROM
chat_messages
WHERE
chat_id = @chat_id::uuid
AND role = @role::chat_message_role
AND deleted = false
ORDER BY
id DESC
LIMIT
1;
-- name: LockChatByID :one
SELECT id
FROM chats
WHERE id = @id::uuid
FOR UPDATE;
-- name: GetChatByIDForUpdate :one
WITH locked_chat AS (
SELECT *
FROM chats
WHERE id = @id::uuid
FOR UPDATE
),
chats_expanded AS (
SELECT
locked_chat.id,
locked_chat.owner_id,
locked_chat.workspace_id,
locked_chat.title,
locked_chat.status,
locked_chat.worker_id,
locked_chat.started_at,
locked_chat.heartbeat_at,
locked_chat.created_at,
locked_chat.updated_at,
locked_chat.parent_chat_id,
locked_chat.root_chat_id,
locked_chat.last_model_config_id,
locked_chat.last_reasoning_effort,
locked_chat.archived,
locked_chat.last_error,
locked_chat.mode,
locked_chat.mcp_server_ids,
locked_chat.labels,
locked_chat.build_id,
locked_chat.agent_id,
locked_chat.pin_order,
locked_chat.last_read_message_id,
locked_chat.dynamic_tools,
locked_chat.organization_id,
locked_chat.plan_mode,
locked_chat.client_type,
locked_chat.last_turn_summary,
locked_chat.summary,
locked_chat.summary_generated_at,
locked_chat.snapshot_version,
locked_chat.history_version,
locked_chat.queue_version,
locked_chat.generation_attempt,
locked_chat.retry_state,
locked_chat.retry_state_version,
locked_chat.runner_id,
locked_chat.requires_action_deadline_at,
COALESCE(root.user_acl, locked_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, locked_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
locked_chat.context_aggregate_hash,
locked_chat.context_dirty_since,
locked_chat.context_dirty_resources,
locked_chat.context_error,
locked_chat.compaction_requested_at
FROM
locked_chat
LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = locked_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: GetChatByIDForShare :one
WITH shared_chat AS (
SELECT *
FROM chats
WHERE id = @id::uuid
FOR SHARE
),
chats_expanded AS (
SELECT
shared_chat.id,
shared_chat.owner_id,
shared_chat.workspace_id,
shared_chat.title,
shared_chat.status,
shared_chat.worker_id,
shared_chat.started_at,
shared_chat.heartbeat_at,
shared_chat.created_at,
shared_chat.updated_at,
shared_chat.parent_chat_id,
shared_chat.root_chat_id,
shared_chat.last_model_config_id,
shared_chat.last_reasoning_effort,
shared_chat.archived,
shared_chat.last_error,
shared_chat.mode,
shared_chat.mcp_server_ids,
shared_chat.labels,
shared_chat.build_id,
shared_chat.agent_id,
shared_chat.pin_order,
shared_chat.last_read_message_id,
shared_chat.dynamic_tools,
shared_chat.organization_id,
shared_chat.plan_mode,
shared_chat.client_type,
shared_chat.last_turn_summary,
shared_chat.summary,
shared_chat.summary_generated_at,
shared_chat.snapshot_version,
shared_chat.history_version,
shared_chat.queue_version,
shared_chat.generation_attempt,
shared_chat.retry_state,
shared_chat.retry_state_version,
shared_chat.runner_id,
shared_chat.requires_action_deadline_at,
COALESCE(root.user_acl, shared_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, shared_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
shared_chat.context_aggregate_hash,
shared_chat.context_dirty_since,
shared_chat.context_dirty_resources,
shared_chat.context_error,
shared_chat.compaction_requested_at
FROM
shared_chat
LEFT JOIN chats root ON root.id = COALESCE(shared_chat.root_chat_id, shared_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = shared_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: GetChatsByChatFileID :many
SELECT
*
FROM
chats_expanded
WHERE
id IN (
SELECT chat_id
FROM chat_file_links
WHERE file_id = @file_id::uuid
)
-- Authorize Filter clause will be injected below in GetAuthorizedChatsByChatFileID.
-- @authorize_filter
;
-- name: AcquireStaleChatDiffStatuses :many
WITH acquired AS (
UPDATE
chat_diff_statuses
SET
-- Claim for 5 minutes. The worker sets the real stale_at
-- after refresh. If the worker crashes, rows become eligible
-- again after this interval.
-- NOTE: updated_at is intentionally NOT touched here so
-- the worker can read it as "when was this row last
-- externally changed" (by MarkStale or a successful
-- refresh).
stale_at = NOW() + INTERVAL '5 minutes'
WHERE
chat_id IN (
SELECT
cds.chat_id
FROM
chat_diff_statuses cds
INNER JOIN
chats c ON c.id = cds.chat_id
WHERE
cds.stale_at <= NOW()
AND cds.git_remote_origin != ''
AND cds.git_branch != ''
AND c.archived = FALSE
ORDER BY
cds.stale_at ASC
FOR UPDATE OF cds
SKIP LOCKED
LIMIT
@limit_val::int
)
RETURNING *
)
SELECT
acquired.*,
c.owner_id
FROM
acquired
INNER JOIN
chats c ON c.id = acquired.chat_id;
-- name: BackoffChatDiffStatus :exec
UPDATE
chat_diff_statuses
SET
-- NOTE: updated_at is intentionally NOT touched here so
-- the worker can read it as "when was this row last
-- externally changed" (by MarkStale or a successful
-- refresh).
stale_at = @stale_at::timestamptz
WHERE
chat_id = @chat_id::uuid;
-- name: GetChatDiffStatusSummary :one
-- Returns aggregate PR counts across all agent chats for telemetry.
-- Deduplicates by PR URL so forked chats referencing the same pull
-- request are counted once (using the most recently refreshed state).
-- Total is derived from the three recognized state buckets and
-- always equals open + merged + closed; other non-NULL states are
-- intentionally excluded from these aggregates.
WITH deduped AS (
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
cds.pull_request_state
FROM chat_diff_statuses cds
JOIN chats c ON c.id = cds.chat_id
WHERE cds.pull_request_state IN ('open', 'merged', 'closed')
ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), cds.updated_at DESC, c.id DESC
)
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE pull_request_state = 'open')::bigint AS open,
COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS merged,
COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS closed
FROM deduped;
-- name: GetTotalChatMessageRuntimeMsInRange :one
-- Computes hb_agent_runtime_v1 usage event payloads. Deliberately includes
-- soft-deleted messages and messages from all chats.
SELECT COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
FROM chat_messages cm
WHERE cm.created_at >= @start_time::timestamptz
AND cm.created_at < @end_time::timestamptz
AND cm.runtime_ms IS NOT NULL;
-- name: GetChatsByWorkspaceIDs :many
SELECT *
FROM chats_expanded
WHERE archived = false
AND workspace_id = ANY(@ids::uuid[])
ORDER BY workspace_id, updated_at DESC;
-- name: UpdateChatLastReadMessageID :exec
-- Updates the last read message ID for a chat. This is used to track
-- which messages the owner has seen, enabling unread indicators.
UPDATE chats
SET last_read_message_id = @last_read_message_id::bigint
WHERE id = @id::uuid;
-- name: DeleteOldChats :execrows
-- Deletes chats that have been archived for longer than the given
-- threshold. Active (non-archived) chats are never deleted.
-- All chat-scoped child tables are removed via ON DELETE CASCADE.
-- Parent/root references on child chats are SET NULL.
WITH deletable AS (
SELECT id
FROM chats
WHERE archived = true
AND updated_at < @before_time::timestamptz
ORDER BY updated_at ASC
LIMIT @limit_count
)
DELETE FROM chats
USING deletable
WHERE chats.id = deletable.id
AND chats.archived = true;
-- name: GetChatsUpdatedAfter :many
-- Retrieves chats updated after the given timestamp for telemetry
-- snapshot collection. Uses updated_at so that long-running chats
-- still appear in each snapshot window while they are active.
SELECT
c.id, c.owner_id, c.created_at, c.updated_at, c.status,
(c.parent_chat_id IS NOT NULL)::bool AS has_parent,
c.root_chat_id, c.workspace_id,
c.mode, c.archived, c.last_model_config_id, c.client_type,
cds.pull_request_state
FROM chats c
LEFT JOIN chat_diff_statuses cds ON cds.chat_id = c.id
WHERE c.updated_at > @updated_after;
-- name: GetChatMessageSummariesPerChat :many
-- Aggregates message-level metrics per chat for messages created
-- after the given timestamp. Uses message created_at so that
-- ongoing activity in long-running chats is captured each window.
SELECT
cm.chat_id,
COUNT(*)::bigint AS message_count,
COUNT(*) FILTER (WHERE cm.role = 'user')::bigint AS user_message_count,
COUNT(*) FILTER (WHERE cm.role = 'assistant')::bigint AS assistant_message_count,
COUNT(*) FILTER (WHERE cm.role = 'tool')::bigint AS tool_message_count,
COUNT(*) FILTER (WHERE cm.role = 'system')::bigint AS system_message_count,
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
COALESCE(SUM(cm.reasoning_tokens), 0)::bigint AS total_reasoning_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms,
COUNT(DISTINCT cm.model_config_id)::bigint AS distinct_model_count,
COUNT(*) FILTER (WHERE cm.compressed)::bigint AS compressed_message_count
FROM chat_messages cm
WHERE cm.created_at > @created_after
AND cm.deleted = false
GROUP BY cm.chat_id;
-- name: GetChatModelConfigsForTelemetry :many
-- Returns all model configurations for telemetry snapshot collection.
-- deleted = false guarantees ai_provider_id is non-null, so INNER JOIN is safe.
SELECT cmc.id, ap.type::text AS provider, cmc.model, cmc.context_limit, cmc.enabled, cmc.is_default
FROM chat_model_configs cmc
JOIN ai_providers ap ON ap.id = cmc.ai_provider_id
WHERE cmc.deleted = false;
-- name: GetActiveChatsByAgentID :many
SELECT *
FROM chats_expanded
WHERE agent_id = @agent_id::uuid
AND archived = false
-- Active statuses only: waiting, running, requires_action.
-- Excludes error (terminal state) and interrupting.
AND status IN ('waiting', 'running', 'requires_action')
ORDER BY updated_at DESC;
-- name: SoftDeleteContextFileMessages :exec
UPDATE chat_messages SET deleted = true
WHERE chat_id = @chat_id::uuid
AND deleted = false
AND content::jsonb @> '[{"type": "context-file"}]';
-- name: GetChatWorkerAcquisitionCandidates :many
-- Returns chats that workers may try to acquire. Candidates must be:
-- - in a worker-runnable execution status;
-- - unarchived; and
-- - missing ownership, carrying inconsistent ownership, or lacking a
-- fresh heartbeat for the assigned runner.
--
-- Missing ownership is worker_id IS NULL. Inconsistent ownership is
-- runner_id IS NULL while worker_id is set. Stale ownership is no
-- heartbeat row for (chat_id, runner_id), or one older than
-- @stale_seconds by database time. Candidates are ordered by oldest
-- updated_at first so workers drain stale runnable chats predictably.
SELECT
chats_expanded.*,
chat_heartbeats.heartbeat_at AS current_heartbeat_at,
NOT EXISTS (
SELECT 1
FROM chat_heartbeats current_lease
WHERE current_lease.chat_id = chats_expanded.id
AND current_lease.runner_id = chats_expanded.runner_id
AND current_lease.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
) AS heartbeat_stale
FROM chats_expanded
LEFT JOIN chat_heartbeats
ON chat_heartbeats.chat_id = chats_expanded.id
AND chat_heartbeats.runner_id = chats_expanded.runner_id
WHERE
chats_expanded.status IN ('running'::chat_status, 'interrupting'::chat_status, 'requires_action'::chat_status)
AND chats_expanded.archived = false
AND (
chats_expanded.worker_id IS NULL
OR chats_expanded.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats current_lease
WHERE current_lease.chat_id = chats_expanded.id
AND current_lease.runner_id = chats_expanded.runner_id
AND current_lease.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
)
)
ORDER BY chats_expanded.updated_at ASC, chats_expanded.id ASC
LIMIT @limit_count::int;
-- name: GetChatsByIDsForRunnerSync :many
SELECT *
FROM chats_expanded
WHERE id = ANY(@ids::uuid[])
ORDER BY id ASC;
-- name: BatchUpsertChatHeartbeats :exec
INSERT INTO chat_heartbeats (chat_id, runner_id, heartbeat_at)
SELECT chat_ids.chat_id, runner_ids.runner_id, NOW()
FROM unnest(@chat_ids::uuid[]) WITH ORDINALITY AS chat_ids(chat_id, ord)
JOIN unnest(@runner_ids::uuid[]) WITH ORDINALITY AS runner_ids(runner_id, ord) USING (ord)
ON CONFLICT (chat_id, runner_id) DO UPDATE
SET heartbeat_at = EXCLUDED.heartbeat_at;
-- name: DeleteStaleChatHeartbeats :execrows
DELETE FROM chat_heartbeats
WHERE heartbeat_at < NOW() - (INTERVAL '1 second' * @stale_seconds::int);
-- name: GetAutoArchiveInactiveChatCandidates :many
-- Returns read-only root chat candidates for state-machine-backed
-- auto-archive. Activity is computed across the root family. The query
-- limits roots, not total family members.
SELECT
chats_expanded.*,
COALESCE(activity.last_activity_at, chats_expanded.created_at)::timestamptz AS last_activity_at
FROM chats_expanded
LEFT JOIN LATERAL (
SELECT MAX(chat_messages.created_at) AS last_activity_at
FROM chat_messages
JOIN chats family_chat ON family_chat.id = chat_messages.chat_id
WHERE (family_chat.id = chats_expanded.id OR family_chat.root_chat_id = chats_expanded.id)
AND chat_messages.deleted = false
) activity ON TRUE
WHERE
chats_expanded.archived = false
AND chats_expanded.pin_order = 0
AND chats_expanded.parent_chat_id IS NULL
AND chats_expanded.created_at < @archive_cutoff::timestamptz
AND chats_expanded.status NOT IN (
'running'::chat_status,
'interrupting'::chat_status,
'requires_action'::chat_status
)
AND COALESCE(activity.last_activity_at, chats_expanded.created_at) < @archive_cutoff::timestamptz
ORDER BY chats_expanded.created_at ASC
LIMIT @limit_count::int;
-- name: LockChatAndBumpSnapshotVersion :one
-- Locks the chat row with FOR UPDATE and atomically increments its
-- snapshot_version, returning the post-bump chat. This is the single
-- entry point ChatMachine.Update uses to acquire the row lock and
-- allocate a new snapshot version in one round trip.
WITH bumped_chat AS (
UPDATE chats
SET snapshot_version = snapshot_version + 1
WHERE id = (
SELECT id FROM chats
WHERE id = @id::uuid
FOR UPDATE
)
RETURNING *
),
chats_expanded AS (
SELECT
bumped_chat.id,
bumped_chat.owner_id,
bumped_chat.workspace_id,
bumped_chat.title,
bumped_chat.status,
bumped_chat.worker_id,
bumped_chat.started_at,
bumped_chat.heartbeat_at,
bumped_chat.created_at,
bumped_chat.updated_at,
bumped_chat.parent_chat_id,
bumped_chat.root_chat_id,
bumped_chat.last_model_config_id,
bumped_chat.last_reasoning_effort,
bumped_chat.archived,
bumped_chat.last_error,
bumped_chat.mode,
bumped_chat.mcp_server_ids,
bumped_chat.labels,
bumped_chat.build_id,
bumped_chat.agent_id,
bumped_chat.pin_order,
bumped_chat.last_read_message_id,
bumped_chat.dynamic_tools,
bumped_chat.organization_id,
bumped_chat.plan_mode,
bumped_chat.client_type,
bumped_chat.last_turn_summary,
bumped_chat.summary,
bumped_chat.summary_generated_at,
bumped_chat.snapshot_version,
bumped_chat.history_version,
bumped_chat.queue_version,
bumped_chat.generation_attempt,
bumped_chat.retry_state,
bumped_chat.retry_state_version,
bumped_chat.runner_id,
bumped_chat.requires_action_deadline_at,
COALESCE(root.user_acl, bumped_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, bumped_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
bumped_chat.context_aggregate_hash,
bumped_chat.context_dirty_since,
bumped_chat.context_dirty_resources,
bumped_chat.context_error,
bumped_chat.compaction_requested_at
FROM bumped_chat
LEFT JOIN chats root ON root.id = COALESCE(bumped_chat.root_chat_id, bumped_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = bumped_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatExecutionState :one
-- Atomically updates the execution-state-managed fields on a chat:
-- status, archived, last_error, ownership identifiers, the
-- requires-action deadline, and the manual compaction request marker.
-- Callers compose this with transition mutations inside a single
-- ChatMachine.Update transaction.
--
-- grant_history_epoch gives a turn that inserts no history the same
-- fresh retry budget and message part episode keys a history change
-- would grant, mirroring the chat_messages trigger postcondition.
WITH updated_chat AS (
UPDATE chats
SET
status = @status::chat_status,
archived = @archived::boolean,
worker_id = sqlc.narg('worker_id')::uuid,
runner_id = sqlc.narg('runner_id')::uuid,
last_error = sqlc.narg('last_error')::jsonb,
requires_action_deadline_at = sqlc.narg('requires_action_deadline_at')::timestamptz,
compaction_requested_at = sqlc.narg('compaction_requested_at')::timestamptz,
history_version = CASE WHEN @grant_history_epoch::boolean THEN snapshot_version ELSE history_version END,
generation_attempt = CASE WHEN @grant_history_epoch::boolean THEN 0 ELSE generation_attempt END,
retry_state = CASE WHEN @grant_history_epoch::boolean THEN NULL ELSE retry_state END,
pin_order = CASE WHEN @archived::boolean THEN 0 ELSE pin_order END,
updated_at = NOW()
WHERE id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatRetryState :one
-- Stores the client-visible retry payload. retry_state_version is
-- assigned by trigger from the current snapshot_version.
WITH updated_chat AS (
UPDATE chats
SET
retry_state = @retry_state::jsonb,
updated_at = NOW()
WHERE id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.last_reasoning_effort,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.summary,
updated_chat.summary_generated_at,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error,
updated_chat.compaction_requested_at
FROM updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: IncrementChatGenerationAttempt :one
-- Increments generation_attempt and returns the resulting value.
UPDATE chats
SET generation_attempt = generation_attempt + 1, updated_at = NOW()
WHERE id = @id::uuid
RETURNING generation_attempt;
-- name: GetDatabaseNow :one
-- Returns the current database timestamp. Used so transitions that
-- record deadlines or heartbeats rely on a clock that is consistent
-- with the database rather than the caller's local clock.
SELECT NOW()::timestamptz AS now;
-- name: InsertChatQueuedMessageWithCreator :one
-- Inserts a queued message that carries a position (from the default
-- sequence) and an explicit created_by reference. Use this when the
-- queued-message creator differs from the chat owner.
INSERT INTO chat_queued_messages (chat_id, content, model_config_id, reasoning_effort, created_by)
VALUES (
@chat_id::uuid,
@content::jsonb,
sqlc.narg('model_config_id')::uuid,
sqlc.narg('reasoning_effort')::chat_reasoning_effort,
@created_by::uuid
)
RETURNING *;
-- name: GetChatQueuedMessagesByPosition :many
-- Returns queued messages in state-machine order (position ASC, id ASC).
SELECT * FROM chat_queued_messages
WHERE chat_id = @chat_id::uuid
ORDER BY position ASC, id ASC;
-- name: CountChatQueuedMessages :one
-- Cheap queue-length check used by ChatMachine.Update when deciding
-- whether the chat is in a "1" sub-state.
SELECT COUNT(*)::bigint AS count
FROM chat_queued_messages
WHERE chat_id = @chat_id::uuid;
-- name: GetChatQueuedMessageHead :one
-- Returns the queue head (lowest position, then lowest id).
SELECT * FROM chat_queued_messages
WHERE chat_id = @chat_id::uuid
ORDER BY position ASC, id ASC
LIMIT 1;
-- name: GetChatQueuedMessageByID :one
SELECT * FROM chat_queued_messages
WHERE id = @id::bigint AND chat_id = @chat_id::uuid;
-- name: DeleteChatQueuedMessageReturningCount :execrows
-- Deletes a queued message, scoped to the parent chat. Returns the
-- number of affected rows so callers can detect missing rows without
-- a follow-up read.
DELETE FROM chat_queued_messages
WHERE id = @id::bigint AND chat_id = @chat_id::uuid;
-- name: DeleteAllChatQueuedMessagesReturningCount :execrows
DELETE FROM chat_queued_messages
WHERE chat_id = @chat_id::uuid;
-- name: ReorderChatQueuedMessageToHead :execrows
-- Sets the target queued message's position to one less than the
-- current minimum position for that chat, moving it to the head.
UPDATE chat_queued_messages AS target
SET position = COALESCE(
(SELECT MIN(position) FROM chat_queued_messages WHERE chat_id = @chat_id::uuid),
0
) - 1
WHERE target.id = @id::bigint
AND target.chat_id = @chat_id::uuid
AND target.position > COALESCE(
(SELECT MIN(position) FROM chat_queued_messages WHERE chat_id = @chat_id::uuid),
target.position
);
-- name: UpsertChatHeartbeat :exec
-- Upserts a heartbeat row for the (chat_id, runner_id) lease. Uses
-- database time so callers do not depend on a local clock.
INSERT INTO chat_heartbeats (chat_id, runner_id, heartbeat_at)
VALUES (@chat_id::uuid, @runner_id::uuid, NOW())
ON CONFLICT (chat_id, runner_id) DO UPDATE
SET heartbeat_at = EXCLUDED.heartbeat_at;
-- name: GetChatHeartbeat :one
SELECT * FROM chat_heartbeats
WHERE chat_id = @chat_id::uuid AND runner_id = @runner_id::uuid;
-- name: IsChatHeartbeatStale :one
-- Returns true when there is no heartbeat row for (chat_id, runner_id)
-- or the existing row is older than @stale_seconds seconds by database
-- time. chatstate calls this in a single query so the staleness check
-- is atomic and does not depend on the caller's local clock.
SELECT NOT EXISTS (
SELECT 1 FROM chat_heartbeats
WHERE chat_id = @chat_id::uuid
AND runner_id = @runner_id::uuid
AND heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
) AS stale;
-- name: BatchDeleteChatHeartbeats :execrows
-- Deletes heartbeat rows for the supplied (chat_id, runner_id) pairs.
DELETE FROM chat_heartbeats
USING unnest(@chat_ids::uuid[]) WITH ORDINALITY AS chat_ids(chat_id, ord)
JOIN unnest(@runner_ids::uuid[]) WITH ORDINALITY AS runner_ids(runner_id, ord) USING (ord)
WHERE chat_heartbeats.chat_id = chat_ids.chat_id
AND chat_heartbeats.runner_id = runner_ids.runner_id;
-- name: DeleteAllChatHeartbeats :exec
-- Deletes all heartbeat rows for the chat. Used during ownership
-- transitions that abandon a lease.
DELETE FROM chat_heartbeats WHERE chat_id = @chat_id::uuid;
-- name: GetChatStreamSyncRows :many
SELECT
id,
snapshot_version,
history_version,
queue_version,
retry_state_version,
generation_attempt,
status,
worker_id
FROM chats
WHERE id = ANY(@ids::uuid[])
ORDER BY id ASC;
-- name: AutoArchiveInactiveChats :many
-- Archives inactive root chats (pinned and already-archived chats skipped),
-- cascading to children via root_chat_id. Limits apply to roots, not total
-- rows. The Go caller passes @archive_cutoff as UTC midnight so that all
-- chats sharing the same last-activity date are archived together.
-- Used by dbpurge.
WITH to_archive AS (
SELECT
c.id,
-- Activity = MAX(cm.created_at) across the family, or c.created_at
-- when the family has no non-deleted messages.
COALESCE(activity.last_activity_at, c.created_at) AS last_activity_at
FROM chats c
LEFT JOIN LATERAL (
SELECT MAX(cm.created_at) AS last_activity_at
FROM chat_messages cm
JOIN chats fc ON fc.id = cm.chat_id
WHERE (fc.id = c.id OR fc.root_chat_id = c.id)
AND cm.deleted = false
) activity ON TRUE
WHERE c.archived = false
AND c.pin_order = 0
AND c.parent_chat_id IS NULL -- roots only
-- Redundant filter helps the planner use the partial index on created_at.
AND c.created_at < @archive_cutoff::timestamptz
-- New active statuses must be added here to prevent archiving.
AND c.status NOT IN ('running', 'requires_action')
AND COALESCE(activity.last_activity_at, c.created_at) < @archive_cutoff::timestamptz
-- Sorting by created_at lets Postgres drive the scan from the
-- partial index instead of evaluating every LATERAL subquery
-- before sorting. All candidates are past the cutoff, so the
-- archive order is immaterial once the backlog drains.
ORDER BY c.created_at ASC
LIMIT @limit_count
),
archived AS (
UPDATE chats c
SET archived = true, pin_order = 0, updated_at = NOW()
FROM to_archive t
WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children
AND c.archived = false
RETURNING c.*
)
SELECT
a.*,
-- Children inherit their root's activity so last_activity_at is never null.
COALESCE(
t.last_activity_at,
(SELECT tr.last_activity_at FROM to_archive tr WHERE tr.id = a.root_chat_id),
a.created_at
)::timestamptz AS last_activity_at
FROM archived a
LEFT JOIN to_archive t ON t.id = a.id
-- created_at ASC flows through to dbpurge's digest truncation; see
-- buildDigestData in dbpurge.go for the tradeoff rationale.
ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC;