mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
c7cac9debe
Previously, `chats.last_model_config_id` was not updated when a user sent a mid-chat message with a different model, and queued messages did not store their own per-turn model, so promotion ran against whatever the chat row said at promote time. Chat watch events also did not merge `last_model_config_id` into the site's root, child, and per-chat caches, so sidebar labels stayed stale after direct sends and queued promotions. - Add nullable `chat_queued_messages.model_config_id`, backfilled from `chats.last_model_config_id`. Queued inserts round-trip the effective model id at enqueue time. - In `coderd/x/chatd`, direct sends update `chats.last_model_config_id` inside the same transaction that inserts the admitted user message. Manual promotion and auto-promotion use the queued row's stored `model_config_id`, with a fallback to `chats.last_model_config_id` for legacy NULL rows during rollout. `PromoteQueuedOptions.ModelConfigID` is now ignored. - On the site, extract `mergeWatchedChatSummary` and `mergeWatchedChatIntoCaches` in `site/src/api/queries/chats.ts` so status-change watch events merge `last_model_config_id` into the root infinite chat list, the parent-embedded child entry, and the per-chat `chatKey(chatId)` cache. `updated_at` guards against stale watch payloads clobbering newer cached state, while diff status events still merge their PR metadata because they are timestamped outside the chat row. Watch timestamps are compared as instants so variable fractional precision does not make fresh events look stale. - Queued promotion validates stored model config IDs before admission. Invalid legacy queued IDs fall back to the chat's current model config instead of dropping the queued message during auto-promotion. - Backend and frontend regression coverage added for admission, queue promotion (including FIFO across mixed models, legacy NULL fallback, and invalid queued model IDs), and chat watch cache merging. > Mux is acting on Mike's behalf.
1486 lines
44 KiB
SQL
1486 lines
44 KiB
SQL
-- name: ArchiveChatByID :many
|
|
WITH chats AS (
|
|
UPDATE chats
|
|
SET archived = true, pin_order = 0, updated_at = NOW()
|
|
WHERE id = @id::uuid OR root_chat_id = @id::uuid
|
|
RETURNING *
|
|
)
|
|
SELECT *
|
|
FROM chats
|
|
ORDER BY (id = @id::uuid) DESC, created_at ASC, id ASC;
|
|
|
|
-- name: UnarchiveChatByID :many
|
|
-- Unarchives a chat (and its children). Stale file references are
|
|
-- handled automatically by FK cascades on chat_file_links: when
|
|
-- dbpurge deletes a chat_files row, the corresponding
|
|
-- chat_file_links rows are cascade-deleted by PostgreSQL.
|
|
WITH chats AS (
|
|
UPDATE chats SET
|
|
archived = false,
|
|
updated_at = NOW()
|
|
WHERE id = @id::uuid OR root_chat_id = @id::uuid
|
|
RETURNING *
|
|
)
|
|
SELECT *
|
|
FROM chats
|
|
ORDER BY (id = @id::uuid) DESC, created_at ASC, 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: GetChatByID :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
chats
|
|
WHERE
|
|
id = @id::uuid;
|
|
|
|
-- name: GetChatMessageByID :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
id = @id::bigint
|
|
AND deleted = false;
|
|
|
|
-- name: GetChatMessagesByChatID :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
|
|
created_at 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 visibility IN ('user', 'both')
|
|
AND deleted = false
|
|
ORDER BY
|
|
id DESC
|
|
LIMIT
|
|
COALESCE(NULLIF(@limit_val::int, 0), 50);
|
|
|
|
-- name: GetChatMessagesForPromptByChatID :many
|
|
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
|
|
created_at DESC,
|
|
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
|
|
created_at ASC,
|
|
id ASC;
|
|
|
|
-- name: GetChats :many
|
|
SELECT
|
|
sqlc.embed(chats),
|
|
EXISTS (
|
|
SELECT 1 FROM chat_messages cm
|
|
WHERE cm.chat_id = chats.id
|
|
AND cm.role = 'assistant'
|
|
AND cm.deleted = false
|
|
AND cm.id > COALESCE(chats.last_read_message_id, 0)
|
|
) AS has_unread
|
|
FROM
|
|
chats
|
|
WHERE
|
|
CASE
|
|
WHEN @owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN chats.owner_id = @owner_id
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
|
|
ELSE chats.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 pin_order > 0 THEN 1 ELSE 0 END, -pin_order, updated_at, id) < (
|
|
SELECT
|
|
CASE WHEN c2.pin_order > 0 THEN 1 ELSE 0 END, -c2.pin_order, c2.updated_at, c2.id
|
|
FROM
|
|
chats c2
|
|
WHERE
|
|
c2.id = @after_id
|
|
)
|
|
)
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN sqlc.narg('label_filter')::jsonb IS NOT NULL THEN chats.labels @> sqlc.narg('label_filter')::jsonb
|
|
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.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 pin_order > 0 THEN 1 ELSE 0 END DESC,
|
|
-pin_order DESC,
|
|
updated_at DESC,
|
|
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),
|
|
EXISTS (
|
|
SELECT 1 FROM chat_messages cm
|
|
WHERE cm.chat_id = chats.id
|
|
AND cm.role = 'assistant'
|
|
AND cm.deleted = false
|
|
AND cm.id > COALESCE(chats.last_read_message_id, 0)
|
|
) AS has_unread
|
|
FROM
|
|
chats
|
|
WHERE
|
|
chats.parent_chat_id = ANY(@parent_ids :: uuid[])
|
|
AND CASE
|
|
WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
|
|
ELSE chats.archived = sqlc.narg('archived') :: boolean
|
|
END
|
|
ORDER BY
|
|
chats.created_at DESC,
|
|
chats.id DESC;
|
|
|
|
-- name: InsertChat :one
|
|
INSERT INTO chats (
|
|
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 (
|
|
@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
|
|
*;
|
|
|
|
-- name: InsertChatMessages :many
|
|
WITH updated_chat AS (
|
|
UPDATE
|
|
chats
|
|
SET
|
|
last_model_config_id = (
|
|
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
|
|
)
|
|
WHERE
|
|
id = @chat_id::uuid
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM UNNEST(@model_config_id::uuid[])
|
|
WHERE unnest != '00000000-0000-0000-0000-000000000000'::uuid
|
|
)
|
|
AND chats.last_model_config_id IS DISTINCT FROM (
|
|
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
|
|
)
|
|
)
|
|
INSERT INTO chat_messages (
|
|
chat_id,
|
|
created_by,
|
|
model_config_id,
|
|
role,
|
|
content,
|
|
content_version,
|
|
visibility,
|
|
input_tokens,
|
|
output_tokens,
|
|
total_tokens,
|
|
reasoning_tokens,
|
|
cache_creation_tokens,
|
|
cache_read_tokens,
|
|
context_limit,
|
|
compressed,
|
|
total_cost_micros,
|
|
runtime_ms,
|
|
provider_response_id
|
|
)
|
|
SELECT
|
|
@chat_id::uuid,
|
|
NULLIF(UNNEST(@created_by::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid),
|
|
NULLIF(UNNEST(@model_config_id::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid),
|
|
UNNEST(@role::chat_message_role[]),
|
|
UNNEST(@content::text[])::jsonb,
|
|
UNNEST(@content_version::smallint[]),
|
|
UNNEST(@visibility::chat_message_visibility[]),
|
|
NULLIF(UNNEST(@input_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@output_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@total_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@reasoning_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@cache_creation_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@cache_read_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@context_limit::bigint[]), 0),
|
|
UNNEST(@compressed::boolean[]),
|
|
NULLIF(UNNEST(@total_cost_micros::bigint[]), 0),
|
|
NULLIF(UNNEST(@runtime_ms::bigint[]), 0),
|
|
NULLIF(UNNEST(@provider_response_id::text[]), '')
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatMessageByID :one
|
|
UPDATE
|
|
chat_messages
|
|
SET
|
|
model_config_id = COALESCE(sqlc.narg('model_config_id')::uuid, model_config_id),
|
|
content = sqlc.narg('content')::jsonb
|
|
WHERE
|
|
id = @id::bigint
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatByID :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
title = @title::text,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatTitleByID :one
|
|
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
|
|
*;
|
|
|
|
-- name: UpdateChatPlanModeByID :one
|
|
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
|
|
*;
|
|
|
|
-- name: UpdateChatLastModelConfigByID :one
|
|
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
|
|
*;
|
|
|
|
-- name: UpdateChatLabelsByID :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
labels = @labels::jsonb,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatWorkspaceBinding :one
|
|
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
|
|
RETURNING *;
|
|
|
|
-- name: UpdateChatBuildAgentBinding :one
|
|
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 *;
|
|
|
|
-- name: UpdateChatLastInjectedContext :one
|
|
-- Updates the cached injected context parts (AGENTS.md +
|
|
-- skills) on the chat row. Called only when context changes
|
|
-- (first workspace attach or agent change). updated_at is
|
|
-- intentionally not touched to avoid reordering the chat list.
|
|
UPDATE chats SET
|
|
last_injected_context = sqlc.narg('last_injected_context')::jsonb
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING *;
|
|
|
|
-- name: UpdateChatMCPServerIDs :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
mcp_server_ids = @mcp_server_ids::uuid[],
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: LinkChatFiles :one
|
|
-- LinkChatFiles inserts file associations into the chat_file_links
|
|
-- join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
|
|
-- is conditional: it only proceeds when the total number of links
|
|
-- (existing + genuinely new) does not exceed max_file_links. Returns
|
|
-- the number of genuinely new file IDs that were NOT inserted due to
|
|
-- the cap. A return value of 0 means all files were linked (or were
|
|
-- already linked). A positive value means the cap blocked that many
|
|
-- new links.
|
|
WITH current AS (
|
|
SELECT COUNT(*) AS cnt
|
|
FROM chat_file_links
|
|
WHERE chat_id = @chat_id::uuid
|
|
),
|
|
new_links AS (
|
|
SELECT @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: AcquireChats :many
|
|
-- Acquires up to @num_chats pending chats for processing. Uses SKIP LOCKED
|
|
-- to prevent multiple replicas from acquiring the same chat.
|
|
UPDATE
|
|
chats
|
|
SET
|
|
status = 'running'::chat_status,
|
|
started_at = @started_at::timestamptz,
|
|
heartbeat_at = @started_at::timestamptz,
|
|
updated_at = @started_at::timestamptz,
|
|
worker_id = @worker_id::uuid
|
|
WHERE
|
|
id = ANY(
|
|
SELECT
|
|
id
|
|
FROM
|
|
chats
|
|
WHERE
|
|
status = 'pending'::chat_status
|
|
AND archived = false
|
|
ORDER BY
|
|
updated_at ASC
|
|
FOR UPDATE
|
|
SKIP LOCKED
|
|
LIMIT
|
|
@num_chats::int
|
|
)
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatStatus :one
|
|
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')::text,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatStatusPreserveUpdatedAt :one
|
|
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')::text,
|
|
updated_at = @updated_at::timestamptz
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: GetStaleChats :many
|
|
-- Find chats that appear stuck and need recovery. This covers:
|
|
-- 1. Running chats whose heartbeat has expired (worker crash).
|
|
-- 2. Chats awaiting client action (requires_action) past the
|
|
-- timeout threshold (client disappeared).
|
|
SELECT
|
|
*
|
|
FROM
|
|
chats
|
|
WHERE
|
|
(status = 'running'::chat_status
|
|
AND heartbeat_at < @stale_threshold::timestamptz)
|
|
OR (status = 'requires_action'::chat_status
|
|
AND updated_at < @stale_threshold::timestamptz);
|
|
|
|
-- 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
|
|
INSERT INTO chat_queued_messages (chat_id, content, model_config_id)
|
|
VALUES (
|
|
@chat_id,
|
|
@content,
|
|
sqlc.narg('model_config_id')::uuid
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: GetChatQueuedMessages :many
|
|
SELECT * FROM chat_queued_messages
|
|
WHERE chat_id = @chat_id
|
|
ORDER BY 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.id ASC
|
|
LIMIT 1
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: GetLastChatMessageByRole :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
chat_id = @chat_id::uuid
|
|
AND role = @role::chat_message_role
|
|
AND deleted = false
|
|
ORDER BY
|
|
created_at DESC, id DESC
|
|
LIMIT
|
|
1;
|
|
|
|
-- name: GetChatByIDForUpdate :one
|
|
SELECT * FROM chats WHERE id = @id::uuid FOR UPDATE;
|
|
|
|
-- 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: GetChatCostSummary :one
|
|
-- Aggregate cost summary for a single user within a date range.
|
|
-- Only counts assistant-role messages.
|
|
SELECT
|
|
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.total_cost_micros IS NOT NULL
|
|
)::bigint AS priced_message_count,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.total_cost_micros IS NULL
|
|
AND (
|
|
cm.input_tokens IS NOT NULL
|
|
OR cm.output_tokens IS NOT NULL
|
|
OR cm.reasoning_tokens IS NOT NULL
|
|
OR cm.cache_creation_tokens IS NOT NULL
|
|
OR cm.cache_read_tokens IS NOT NULL
|
|
)
|
|
)::bigint AS unpriced_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.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
|
|
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
|
|
FROM
|
|
chat_messages cm
|
|
JOIN
|
|
chats c ON c.id = cm.chat_id
|
|
WHERE
|
|
c.owner_id = @owner_id::uuid
|
|
AND cm.role = 'assistant'
|
|
AND cm.created_at >= @start_date::timestamptz
|
|
AND cm.created_at < @end_date::timestamptz;
|
|
|
|
-- name: GetChatCostPerModel :many
|
|
-- Per-model cost breakdown for a single user within a date range.
|
|
-- Only counts assistant-role messages that have a model_config_id.
|
|
SELECT
|
|
cmc.id AS model_config_id,
|
|
cmc.display_name,
|
|
cmc.provider,
|
|
cmc.model,
|
|
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.input_tokens IS NOT NULL
|
|
OR cm.output_tokens IS NOT NULL
|
|
OR cm.reasoning_tokens IS NOT NULL
|
|
OR cm.cache_creation_tokens IS NOT NULL
|
|
OR cm.cache_read_tokens IS NOT NULL
|
|
)::bigint AS 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.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
|
|
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
|
|
FROM
|
|
chat_messages cm
|
|
JOIN
|
|
chats c ON c.id = cm.chat_id
|
|
JOIN
|
|
chat_model_configs cmc ON cmc.id = cm.model_config_id
|
|
WHERE
|
|
c.owner_id = @owner_id::uuid
|
|
AND cm.role = 'assistant'
|
|
AND cm.created_at >= @start_date::timestamptz
|
|
AND cm.created_at < @end_date::timestamptz
|
|
GROUP BY
|
|
cmc.id, cmc.display_name, cmc.provider, cmc.model
|
|
ORDER BY
|
|
total_cost_micros DESC;
|
|
|
|
-- name: GetChatCostPerChat :many
|
|
-- Per-root-chat cost breakdown for a single user within a date range.
|
|
-- Groups by root_chat_id so forked chats roll up under their root.
|
|
-- Only counts assistant-role messages.
|
|
WITH chat_costs AS (
|
|
SELECT
|
|
COALESCE(c.root_chat_id, c.id) AS root_chat_id,
|
|
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.input_tokens IS NOT NULL
|
|
OR cm.output_tokens IS NOT NULL
|
|
OR cm.reasoning_tokens IS NOT NULL
|
|
OR cm.cache_creation_tokens IS NOT NULL
|
|
OR cm.cache_read_tokens IS NOT NULL
|
|
)::bigint AS 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.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
|
|
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
|
|
FROM chat_messages cm
|
|
JOIN chats c ON c.id = cm.chat_id
|
|
WHERE c.owner_id = @owner_id::uuid
|
|
AND cm.role = 'assistant'
|
|
AND cm.created_at >= @start_date::timestamptz
|
|
AND cm.created_at < @end_date::timestamptz
|
|
GROUP BY COALESCE(c.root_chat_id, c.id)
|
|
)
|
|
SELECT
|
|
cc.root_chat_id,
|
|
COALESCE(rc.title, '') AS chat_title,
|
|
cc.total_cost_micros,
|
|
cc.message_count,
|
|
cc.total_input_tokens,
|
|
cc.total_output_tokens,
|
|
cc.total_cache_read_tokens,
|
|
cc.total_cache_creation_tokens,
|
|
cc.total_runtime_ms
|
|
FROM chat_costs cc
|
|
LEFT JOIN chats rc ON rc.id = cc.root_chat_id
|
|
ORDER BY cc.total_cost_micros DESC;
|
|
|
|
-- name: GetChatCostPerUser :many
|
|
-- Deployment-wide per-user cost rollup within a date range.
|
|
-- Only counts assistant-role messages.
|
|
WITH chat_cost_users AS (
|
|
SELECT
|
|
c.owner_id AS user_id,
|
|
u.username,
|
|
u.name,
|
|
u.avatar_url,
|
|
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.input_tokens IS NOT NULL
|
|
OR cm.output_tokens IS NOT NULL
|
|
OR cm.reasoning_tokens IS NOT NULL
|
|
OR cm.cache_creation_tokens IS NOT NULL
|
|
OR cm.cache_read_tokens IS NOT NULL
|
|
)::bigint AS message_count,
|
|
COUNT(DISTINCT COALESCE(c.root_chat_id, c.id))::bigint AS chat_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.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
|
|
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
|
|
FROM
|
|
chat_messages cm
|
|
JOIN
|
|
chats c ON c.id = cm.chat_id
|
|
JOIN
|
|
users u ON u.id = c.owner_id
|
|
WHERE
|
|
cm.role = 'assistant'
|
|
AND cm.created_at >= @start_date::timestamptz
|
|
AND cm.created_at < @end_date::timestamptz
|
|
AND (
|
|
@username::text = ''
|
|
OR u.username ILIKE '%' || @username::text || '%'
|
|
OR u.name ILIKE '%' || @username::text || '%'
|
|
)
|
|
GROUP BY
|
|
c.owner_id,
|
|
u.username,
|
|
u.name,
|
|
u.avatar_url
|
|
)
|
|
SELECT
|
|
user_id,
|
|
username,
|
|
name,
|
|
avatar_url,
|
|
total_cost_micros,
|
|
message_count,
|
|
chat_count,
|
|
total_input_tokens,
|
|
total_output_tokens,
|
|
total_cache_read_tokens,
|
|
total_cache_creation_tokens,
|
|
total_runtime_ms,
|
|
COUNT(*) OVER()::bigint AS total_count
|
|
FROM
|
|
chat_cost_users
|
|
ORDER BY
|
|
total_cost_micros DESC,
|
|
username ASC
|
|
LIMIT
|
|
sqlc.arg('page_limit')::int
|
|
OFFSET
|
|
sqlc.arg('page_offset')::int;
|
|
|
|
-- name: GetChatUsageLimitConfig :one
|
|
SELECT * FROM chat_usage_limit_config WHERE singleton = TRUE LIMIT 1;
|
|
|
|
-- name: UpsertChatUsageLimitConfig :one
|
|
INSERT INTO chat_usage_limit_config (singleton, enabled, default_limit_micros, period, updated_at)
|
|
VALUES (TRUE, @enabled::boolean, @default_limit_micros::bigint, @period::text, NOW())
|
|
ON CONFLICT (singleton) DO UPDATE SET
|
|
enabled = EXCLUDED.enabled,
|
|
default_limit_micros = EXCLUDED.default_limit_micros,
|
|
period = EXCLUDED.period,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: ListChatUsageLimitOverrides :many
|
|
SELECT u.id AS user_id, u.username, u.name, u.avatar_url,
|
|
u.chat_spend_limit_micros AS spend_limit_micros
|
|
FROM users u
|
|
WHERE u.chat_spend_limit_micros IS NOT NULL
|
|
ORDER BY u.username ASC;
|
|
|
|
-- name: UpsertChatUsageLimitUserOverride :one
|
|
UPDATE users
|
|
SET chat_spend_limit_micros = @spend_limit_micros::bigint
|
|
WHERE id = @user_id::uuid
|
|
RETURNING id AS user_id, username, name, avatar_url, chat_spend_limit_micros AS spend_limit_micros;
|
|
|
|
-- name: DeleteChatUsageLimitUserOverride :exec
|
|
UPDATE users SET chat_spend_limit_micros = NULL WHERE id = @user_id::uuid;
|
|
|
|
-- name: GetChatUsageLimitUserOverride :one
|
|
SELECT id AS user_id, chat_spend_limit_micros AS spend_limit_micros
|
|
FROM users
|
|
WHERE id = @user_id::uuid AND chat_spend_limit_micros IS NOT NULL;
|
|
|
|
-- name: GetUserChatSpendInPeriod :one
|
|
-- Returns the total spend for a user in the given period.
|
|
-- When organization_id is NULL, spend across all organizations is
|
|
-- returned (global behavior). Otherwise only spend within the
|
|
-- specified organization is included.
|
|
SELECT COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_spend_micros
|
|
FROM chat_messages cm
|
|
JOIN chats c ON c.id = cm.chat_id
|
|
WHERE c.owner_id = @user_id::uuid
|
|
AND (sqlc.narg('organization_id')::uuid IS NULL
|
|
OR c.organization_id = sqlc.narg('organization_id')::uuid)
|
|
AND cm.created_at >= @start_time::timestamptz
|
|
AND cm.created_at < @end_time::timestamptz
|
|
AND cm.total_cost_micros IS NOT NULL;
|
|
|
|
-- name: CountEnabledModelsWithoutPricing :one
|
|
-- Counts enabled, non-deleted model configs that lack both input and
|
|
-- output pricing in their JSONB options.cost configuration.
|
|
SELECT COUNT(*)::bigint AS count
|
|
FROM chat_model_configs
|
|
WHERE enabled = TRUE
|
|
AND deleted = FALSE
|
|
AND (
|
|
options->'cost' IS NULL
|
|
OR options->'cost' = 'null'::jsonb
|
|
OR (
|
|
(options->'cost'->>'input_price_per_million_tokens' IS NULL)
|
|
AND (options->'cost'->>'output_price_per_million_tokens' IS NULL)
|
|
)
|
|
);
|
|
|
|
-- name: ListChatUsageLimitGroupOverrides :many
|
|
SELECT
|
|
g.id AS group_id,
|
|
g.name AS group_name,
|
|
g.display_name AS group_display_name,
|
|
g.avatar_url AS group_avatar_url,
|
|
g.chat_spend_limit_micros AS spend_limit_micros,
|
|
(SELECT COUNT(*)
|
|
FROM group_members_expanded gme
|
|
WHERE gme.group_id = g.id
|
|
AND gme.user_is_system = FALSE) AS member_count
|
|
FROM groups g
|
|
WHERE g.chat_spend_limit_micros IS NOT NULL
|
|
ORDER BY g.name ASC;
|
|
|
|
-- name: UpsertChatUsageLimitGroupOverride :one
|
|
UPDATE groups
|
|
SET chat_spend_limit_micros = @spend_limit_micros::bigint
|
|
WHERE id = @group_id::uuid
|
|
RETURNING id AS group_id, name, display_name, avatar_url, chat_spend_limit_micros AS spend_limit_micros;
|
|
|
|
-- name: DeleteChatUsageLimitGroupOverride :exec
|
|
UPDATE groups SET chat_spend_limit_micros = NULL WHERE id = @group_id::uuid;
|
|
|
|
-- name: GetChatUsageLimitGroupOverride :one
|
|
SELECT id AS group_id, chat_spend_limit_micros AS spend_limit_micros
|
|
FROM groups
|
|
WHERE id = @group_id::uuid AND chat_spend_limit_micros IS NOT NULL;
|
|
|
|
-- name: GetUserGroupSpendLimit :one
|
|
-- Returns the minimum (most restrictive) group limit for a user.
|
|
-- Returns -1 if no group limits match the specified scope.
|
|
-- When organization_id is NULL, groups across all organizations are
|
|
-- considered (global behavior). Otherwise only groups within the
|
|
-- specified organization are considered.
|
|
SELECT COALESCE(MIN(g.chat_spend_limit_micros), -1)::bigint AS limit_micros
|
|
FROM groups g
|
|
JOIN group_members_expanded gme ON gme.group_id = g.id
|
|
WHERE gme.user_id = @user_id::uuid
|
|
AND (sqlc.narg('organization_id')::uuid IS NULL
|
|
OR g.organization_id = sqlc.narg('organization_id')::uuid)
|
|
AND g.chat_spend_limit_micros IS NOT NULL;
|
|
|
|
-- name: GetChatsByWorkspaceIDs :many
|
|
SELECT *
|
|
FROM chats
|
|
WHERE archived = false
|
|
AND workspace_id = ANY(@ids::uuid[])
|
|
ORDER BY workspace_id, updated_at DESC;
|
|
|
|
-- name: ResolveUserChatSpendLimit :one
|
|
-- Resolves the effective spend limit for a user using the hierarchy:
|
|
-- 1. Individual user override (highest priority, applies globally across
|
|
-- all organizations since it lives on the users table)
|
|
-- 2. Minimum group limit across the user's groups
|
|
-- 3. Global default from config
|
|
-- Returns -1 if limits are not enabled.
|
|
-- When organization_id is NULL, groups across all organizations are
|
|
-- considered (global behavior). Otherwise only groups within the
|
|
-- specified organization are considered.
|
|
-- limit_source indicates which tier won: 'user', 'group', 'default',
|
|
-- or 'disabled'.
|
|
SELECT CASE
|
|
WHEN NOT cfg.enabled THEN -1
|
|
WHEN u.chat_spend_limit_micros IS NOT NULL THEN u.chat_spend_limit_micros
|
|
WHEN gl.limit_micros IS NOT NULL THEN gl.limit_micros
|
|
ELSE cfg.default_limit_micros
|
|
END::bigint AS effective_limit_micros,
|
|
CASE
|
|
WHEN NOT cfg.enabled THEN 'disabled'
|
|
WHEN u.chat_spend_limit_micros IS NOT NULL THEN 'user'
|
|
WHEN gl.limit_micros IS NOT NULL THEN 'group'
|
|
ELSE 'default'
|
|
END AS limit_source
|
|
FROM chat_usage_limit_config cfg
|
|
CROSS JOIN users u
|
|
LEFT JOIN LATERAL (
|
|
SELECT MIN(g.chat_spend_limit_micros) AS limit_micros
|
|
FROM groups g
|
|
JOIN group_members_expanded gme ON gme.group_id = g.id
|
|
WHERE gme.user_id = @user_id::uuid
|
|
AND (sqlc.narg('organization_id')::uuid IS NULL
|
|
OR g.organization_id = sqlc.narg('organization_id')::uuid)
|
|
AND g.chat_spend_limit_micros IS NOT NULL
|
|
) gl ON TRUE
|
|
WHERE u.id = @user_id::uuid
|
|
LIMIT 1;
|
|
|
|
-- 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.
|
|
-- Related chat_messages, chat_diff_statuses, and
|
|
-- chat_queued_messages 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.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
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.
|
|
SELECT id, provider, model, context_limit, enabled, is_default
|
|
FROM chat_model_configs
|
|
WHERE deleted = false;
|
|
-- name: GetActiveChatsByAgentID :many
|
|
SELECT *
|
|
FROM chats
|
|
WHERE agent_id = @agent_id::uuid
|
|
AND archived = false
|
|
-- Active statuses only: waiting, pending, running, paused,
|
|
-- requires_action.
|
|
-- Excludes completed and error (terminal states).
|
|
AND status IN ('waiting', 'running', 'paused', 'pending', 'requires_action')
|
|
ORDER BY updated_at DESC;
|
|
|
|
-- name: ClearChatMessageProviderResponseIDsByChatID :exec
|
|
UPDATE chat_messages
|
|
SET provider_response_id = NULL
|
|
WHERE chat_id = @chat_id::uuid
|
|
AND deleted = false
|
|
AND provider_response_id IS NOT NULL;
|
|
|
|
-- 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: 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. 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
|
|
AND c.created_at < @archive_cutoff::timestamptz
|
|
-- New active statuses must be added here to prevent archiving.
|
|
AND c.status NOT IN ('running', 'pending', 'paused', '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
|
|
ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC;
|