mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
8b1705eb65
## Summary Routes chatd model calls backed by concrete AI Provider rows through the in-process aibridge transport by default, with deployment options to use direct provider routing when AI Gateway is disabled or chat AI Gateway routing is disabled. - Splits model routing into common, direct provider, and AI Gateway paths behind a single deployment-mode entry point. - Builds chatd models through explicit request, route, and options data. Active API key attribution is passed explicitly instead of being hidden inside generic model construction. - For AI Gateway BYOK routes, resolves the user's provider key in chatd, forwards it through provider-specific auth headers, and sets `X-Coder-AI-Governance-Token` to the `delegated` marker so aibridge preserves those headers while still stripping Coder-specific metadata. - Keeps central provider credentials and deployment fallback credentials out of forwarded provider auth headers, so AI Gateway central policy remains authoritative. - Redacts delegated provider auth from default string formatting to avoid accidental plaintext logging of user BYOK credentials. - Covers selected chat models, advisor overrides, title and quickgen paths, subagent overrides, computer use model selection, and an integration-style chat turn through the aibridge transport path. - Persists initiating API key IDs on chat and queued user messages, including subagent child messages, and fails closed for AI Gateway-routed model builds without an active key. - Removes unused `api_key_id` indexes while keeping the persistence columns and foreign keys. - Keeps the deployment option available through config and env parsing, but hides it from CLI help and generated docs. - Stabilizes the subagent poll fallback test so background CreateChat processing cannot win the state transition under slower CI environments. ## Tests - `go test ./coderd/x/chatd -run 'TestAIGatewayProviderAuthForUser|TestAIGatewayProviderAuthRedactsFormatting|TestResolveModelRouteForConfigAIGatewayProviderAuth|TestAIGatewayModelForwardsProviderAuth|TestProcessChat_AIGatewayRoutingUsesDelegatedAPIKey|TestAwaitSubagentCompletion' -count=1` - `go test ./coderd/aibridged -run 'TestServeHTTP_DelegatedAPIKey|TestServeHTTP_StripCoderToken' -count=1` - `git diff --check HEAD~1..HEAD` - `make lint` > Mux working on behalf of Mike.
2372 lines
74 KiB
SQL
2372 lines
74 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.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.last_injected_context,
|
|
updated_chats.dynamic_tools,
|
|
updated_chats.organization_id,
|
|
updated_chats.plan_mode,
|
|
updated_chats.client_type,
|
|
updated_chats.last_turn_summary,
|
|
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
|
|
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
|
|
-- 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 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.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.last_injected_context,
|
|
updated_chats.dynamic_tools,
|
|
updated_chats.organization_id,
|
|
updated_chats.plan_mode,
|
|
updated_chats.client_type,
|
|
updated_chats.last_turn_summary,
|
|
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
|
|
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: GetChatByID :one
|
|
SELECT *
|
|
FROM chats_expanded
|
|
WHERE id = @id::uuid;
|
|
|
|
-- 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
|
|
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 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
|
|
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
|
|
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
|
|
CASE
|
|
WHEN @owned_only::boolean THEN chats_expanded.owner_id = @viewer_id::uuid
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN @shared_only::boolean THEN chats_expanded.owner_id != @viewer_id::uuid
|
|
ELSE true
|
|
END
|
|
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
|
|
-- 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 (
|
|
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 *
|
|
),
|
|
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.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.last_injected_context,
|
|
inserted_chat.dynamic_tools,
|
|
inserted_chat.organization_id,
|
|
inserted_chat.plan_mode,
|
|
inserted_chat.client_type,
|
|
inserted_chat.last_turn_summary,
|
|
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
|
|
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
|
|
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,
|
|
api_key_id,
|
|
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(@api_key_id::text[]), ''),
|
|
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
|
|
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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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 updated_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
|
|
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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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: 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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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: UpdateChatLastInjectedContext :one
|
|
WITH updated_chat AS (
|
|
-- 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 *
|
|
),
|
|
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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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 relies on
|
|
-- every new-turn query, such as UpdateChatStatus and AcquireChats, bumping
|
|
-- updated_at. Future chat-field updates that do not bump updated_at can let
|
|
-- stale summaries persist. If this query ever bumps updated_at, later
|
|
-- goroutine summary writes will be rejected as stale.
|
|
-- 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 updated_at = @expected_updated_at::timestamptz;
|
|
|
|
-- 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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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: 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.
|
|
WITH acquired_chats AS (
|
|
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 *
|
|
),
|
|
chats_expanded AS (
|
|
SELECT
|
|
acquired_chats.id,
|
|
acquired_chats.owner_id,
|
|
acquired_chats.workspace_id,
|
|
acquired_chats.title,
|
|
acquired_chats.status,
|
|
acquired_chats.worker_id,
|
|
acquired_chats.started_at,
|
|
acquired_chats.heartbeat_at,
|
|
acquired_chats.created_at,
|
|
acquired_chats.updated_at,
|
|
acquired_chats.parent_chat_id,
|
|
acquired_chats.root_chat_id,
|
|
acquired_chats.last_model_config_id,
|
|
acquired_chats.archived,
|
|
acquired_chats.last_error,
|
|
acquired_chats.mode,
|
|
acquired_chats.mcp_server_ids,
|
|
acquired_chats.labels,
|
|
acquired_chats.build_id,
|
|
acquired_chats.agent_id,
|
|
acquired_chats.pin_order,
|
|
acquired_chats.last_read_message_id,
|
|
acquired_chats.last_injected_context,
|
|
acquired_chats.dynamic_tools,
|
|
acquired_chats.organization_id,
|
|
acquired_chats.plan_mode,
|
|
acquired_chats.client_type,
|
|
acquired_chats.last_turn_summary,
|
|
COALESCE(root.user_acl, acquired_chats.user_acl) AS user_acl,
|
|
COALESCE(root.group_acl, acquired_chats.group_acl) AS group_acl,
|
|
owner.username AS owner_username,
|
|
owner.name AS owner_name
|
|
FROM
|
|
acquired_chats
|
|
LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id)
|
|
JOIN visible_users owner ON owner.id = acquired_chats.owner_id
|
|
)
|
|
SELECT *
|
|
FROM chats_expanded;
|
|
|
|
-- 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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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: UpdateChatStatusPreserveUpdatedAt :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 = @updated_at::timestamptz
|
|
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.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.last_injected_context,
|
|
updated_chat.dynamic_tools,
|
|
updated_chat.organization_id,
|
|
updated_chat.plan_mode,
|
|
updated_chat.client_type,
|
|
updated_chat.last_turn_summary,
|
|
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
|
|
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
|
|
INSERT INTO chat_queued_messages (chat_id, content, model_config_id, api_key_id)
|
|
VALUES (
|
|
@chat_id,
|
|
@content,
|
|
sqlc.narg('model_config_id')::uuid,
|
|
sqlc.narg('api_key_id')::text
|
|
)
|
|
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
|
|
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
|
|
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.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.last_injected_context,
|
|
locked_chat.dynamic_tools,
|
|
locked_chat.organization_id,
|
|
locked_chat.plan_mode,
|
|
locked_chat.client_type,
|
|
locked_chat.last_turn_summary,
|
|
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
|
|
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: 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: 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_expanded
|
|
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_expanded
|
|
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. 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', '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
|
|
-- 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;
|