mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
82f965a0ae
## What
Adds per-user per-model auto-compaction threshold overrides. Users can
now customize the percentage of context window usage that triggers chat
compaction, independently for each enabled model.
## Why
The compaction threshold was previously only configurable at the
deployment level (`chat_model_configs.compression_threshold`). Different
users have different preferences — some want aggressive compaction to
keep costs low, others prefer higher thresholds to retain more context.
This gives users control without requiring admin intervention.
## Architecture
**Storage:** Reuses the existing `user_configs` table (no migration
needed). Overrides are stored as key/value pairs with keys shaped
`chat_compaction_threshold:<modelConfigID>` and integer percent values.
**API:** Three new experimental endpoints under
`/api/experimental/chats/config/`:
- `GET /user-compaction-thresholds` — list all overrides for the current
user
- `PUT /user-compaction-thresholds/{modelConfig}` — upsert an override
(validates model exists and is enabled, validates 0–100 range)
- `DELETE /user-compaction-thresholds/{modelConfig}` — clear an override
(idempotent)
**Runtime resolution:** In `coderd/chatd/chatd.go`, a new
`resolveUserCompactionThreshold()` helper runs at the start of each chat
turn (inside `runChat()`), after the model config is resolved but before
`CompactionOptions` is built. If a valid override exists, it replaces
`modelConfig.CompressionThreshold`. The threshold source
(`user_override` vs `model_default`) is logged with each compaction
event.
**Precedence:** `effectiveThreshold = userOverride ??
modelConfig.CompressionThreshold`
**UI:** New "Context Compaction" subsection in the Agents → Settings →
Behavior tab, placed after Personal Instructions. Shows one row per
enabled model with the system default, a number input for the override,
and Save/Reset controls.
## Testing
- 9 API subtests covering CRUD, validation (boundary values 0/100,
out-of-range rejection), upsert behavior, idempotent delete, user
isolation, and non-existent model config
- 4 dbauthz tests (16 scenarios) verifying `ActionReadPersonal` /
`ActionUpdatePersonal` on all query methods
- 4 Storybook stories with play functions (Default, WithOverrides,
Loading, Error)
<details>
<summary>Implementation plan</summary>
### Phase 1 — Tests
- Backend API tests in `coderd/chats_test.go` (9 subtests)
- Database auth wrapper tests in
`coderd/database/dbauthz/dbauthz_test.go` (4 methods)
- Frontend stories in `UserCompactionThresholdSettings.stories.tsx` (4
stories)
### Phase 2 — Backend preference surface
- 4 SQL queries in `coderd/database/queries/users.sql` (list, get,
upsert, delete)
- `make gen` to propagate into generated artifacts
- Auth/metrics wrappers in dbauthz and dbmetrics
- SDK types and client methods in `codersdk/chats.go`
- HTTP handlers and routes in `coderd/chats.go` and `coderd/coderd.go`
- Key prefix constant shared between handlers and runtime
### Phase 3 — Runtime override
- `resolveUserCompactionThreshold()` helper in `coderd/chatd/chatd.go`
- Override injection in `runChat()` before building `CompactionOptions`
- `threshold_source` field added to compaction log
### Phase 4 — Settings UI
- API client methods and React Query hooks in `site/src/api/`
- `UserCompactionThresholdSettings` component extracted from
`SettingsPageContent`
- Per-model mutation tracking (only the active row disables during save)
- 100% warning, "System default" label, helpful empty state copy
### Phase 5 — Refactor and review fixes
- Consolidated key prefix constant in `codersdk`
- Explicit PUT range validation (not just struct tags)
- GET handler gracefully skips malformed rows instead of 500
- Boundary value, upsert, and non-existent model config tests
- UX improvements: per-model mutation state, aria-live on errors
</details>
486 lines
11 KiB
SQL
486 lines
11 KiB
SQL
-- name: UpdateUserLoginType :one
|
||
UPDATE
|
||
users
|
||
SET
|
||
login_type = @new_login_type,
|
||
hashed_password = CASE WHEN @new_login_type = 'password' :: login_type THEN
|
||
users.hashed_password
|
||
ELSE
|
||
-- If the login type is not password, then the password should be
|
||
-- cleared.
|
||
'':: bytea
|
||
END
|
||
WHERE
|
||
id = @user_id
|
||
AND NOT is_system
|
||
RETURNING *;
|
||
|
||
-- name: GetUserByID :one
|
||
SELECT
|
||
*
|
||
FROM
|
||
users
|
||
WHERE
|
||
id = $1
|
||
LIMIT
|
||
1;
|
||
|
||
-- name: ValidateUserIDs :one
|
||
WITH input AS (
|
||
SELECT
|
||
unnest(@user_ids::uuid[]) AS id
|
||
)
|
||
SELECT
|
||
array_agg(input.id)::uuid[] as invalid_user_ids,
|
||
COUNT(*) = 0 as ok
|
||
FROM
|
||
-- Preserve rows where there is not a matching left (users) row for each
|
||
-- right (input) row...
|
||
users
|
||
RIGHT JOIN input ON users.id = input.id
|
||
WHERE
|
||
-- ...so that we can retain exactly those rows where an input ID does not
|
||
-- match an existing user...
|
||
users.id IS NULL OR
|
||
-- ...or that only matches a user that was deleted.
|
||
users.deleted = true;
|
||
|
||
-- name: GetUsersByIDs :many
|
||
-- This shouldn't check for deleted, because it's frequently used
|
||
-- to look up references to actions. eg. a user could build a workspace
|
||
-- for another user, then be deleted... we still want them to appear!
|
||
SELECT * FROM users WHERE id = ANY(@ids :: uuid [ ]);
|
||
|
||
-- name: GetUserByEmailOrUsername :one
|
||
SELECT
|
||
*
|
||
FROM
|
||
users
|
||
WHERE
|
||
(LOWER(username) = LOWER(@username) OR (@email != '' AND LOWER(email) = LOWER(@email))) AND
|
||
deleted = false
|
||
LIMIT
|
||
1;
|
||
|
||
-- name: GetUserCount :one
|
||
SELECT
|
||
COUNT(*)
|
||
FROM
|
||
users
|
||
WHERE
|
||
deleted = false
|
||
AND CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END;
|
||
|
||
-- name: GetActiveUserCount :one
|
||
SELECT
|
||
COUNT(*)
|
||
FROM
|
||
users
|
||
WHERE
|
||
status = 'active'::user_status AND deleted = false
|
||
AND CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END;
|
||
|
||
-- name: InsertUser :one
|
||
INSERT INTO
|
||
users (
|
||
id,
|
||
email,
|
||
username,
|
||
name,
|
||
hashed_password,
|
||
created_at,
|
||
updated_at,
|
||
rbac_roles,
|
||
login_type,
|
||
status,
|
||
is_service_account
|
||
)
|
||
VALUES
|
||
($1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||
-- if the status passed in is empty, fallback to dormant, which is what
|
||
-- we were doing before.
|
||
COALESCE(NULLIF(@status::text, '')::user_status, 'dormant'::user_status),
|
||
@is_service_account::bool
|
||
) RETURNING *;
|
||
|
||
-- name: UpdateUserProfile :one
|
||
UPDATE
|
||
users
|
||
SET
|
||
email = $2,
|
||
username = $3,
|
||
avatar_url = $4,
|
||
updated_at = $5,
|
||
name = $6
|
||
WHERE
|
||
id = $1
|
||
RETURNING *;
|
||
|
||
-- name: UpdateUserGithubComUserID :exec
|
||
UPDATE
|
||
users
|
||
SET
|
||
github_com_user_id = $2
|
||
WHERE
|
||
id = $1;
|
||
|
||
-- name: GetUserThemePreference :one
|
||
SELECT
|
||
value as theme_preference
|
||
FROM
|
||
user_configs
|
||
WHERE
|
||
user_id = @user_id
|
||
AND key = 'theme_preference';
|
||
|
||
-- name: UpdateUserThemePreference :one
|
||
INSERT INTO
|
||
user_configs (user_id, key, value)
|
||
VALUES
|
||
(@user_id, 'theme_preference', @theme_preference)
|
||
ON CONFLICT
|
||
ON CONSTRAINT user_configs_pkey
|
||
DO UPDATE
|
||
SET
|
||
value = @theme_preference
|
||
WHERE user_configs.user_id = @user_id
|
||
AND user_configs.key = 'theme_preference'
|
||
RETURNING *;
|
||
|
||
-- name: GetUserTerminalFont :one
|
||
SELECT
|
||
value as terminal_font
|
||
FROM
|
||
user_configs
|
||
WHERE
|
||
user_id = @user_id
|
||
AND key = 'terminal_font';
|
||
|
||
-- name: UpdateUserTerminalFont :one
|
||
INSERT INTO
|
||
user_configs (user_id, key, value)
|
||
VALUES
|
||
(@user_id, 'terminal_font', @terminal_font)
|
||
ON CONFLICT
|
||
ON CONSTRAINT user_configs_pkey
|
||
DO UPDATE
|
||
SET
|
||
value = @terminal_font
|
||
WHERE user_configs.user_id = @user_id
|
||
AND user_configs.key = 'terminal_font'
|
||
RETURNING *;
|
||
|
||
-- name: GetUserChatCustomPrompt :one
|
||
SELECT
|
||
value as chat_custom_prompt
|
||
FROM
|
||
user_configs
|
||
WHERE
|
||
user_id = @user_id
|
||
AND key = 'chat_custom_prompt';
|
||
|
||
-- name: UpdateUserChatCustomPrompt :one
|
||
INSERT INTO
|
||
user_configs (user_id, key, value)
|
||
VALUES
|
||
(@user_id, 'chat_custom_prompt', @chat_custom_prompt)
|
||
ON CONFLICT
|
||
ON CONSTRAINT user_configs_pkey
|
||
DO UPDATE
|
||
SET
|
||
value = @chat_custom_prompt
|
||
WHERE user_configs.user_id = @user_id
|
||
AND user_configs.key = 'chat_custom_prompt'
|
||
RETURNING *;
|
||
|
||
-- name: ListUserChatCompactionThresholds :many
|
||
SELECT user_id, key, value FROM user_configs
|
||
WHERE user_id = @user_id
|
||
AND key LIKE 'chat\_compaction\_threshold\_pct:%'
|
||
ORDER BY key;
|
||
|
||
-- name: GetUserChatCompactionThreshold :one
|
||
SELECT value AS threshold_percent FROM user_configs
|
||
WHERE user_id = @user_id AND key = @key;
|
||
|
||
-- name: UpdateUserChatCompactionThreshold :one
|
||
INSERT INTO user_configs (user_id, key, value)
|
||
VALUES (@user_id, @key, (@threshold_percent::int)::text)
|
||
ON CONFLICT ON CONSTRAINT user_configs_pkey
|
||
DO UPDATE SET value = (@threshold_percent::int)::text
|
||
RETURNING *;
|
||
|
||
-- name: DeleteUserChatCompactionThreshold :exec
|
||
DELETE FROM user_configs WHERE user_id = @user_id AND key = @key;
|
||
|
||
-- name: GetUserTaskNotificationAlertDismissed :one
|
||
SELECT
|
||
value::boolean as task_notification_alert_dismissed
|
||
FROM
|
||
user_configs
|
||
WHERE
|
||
user_id = @user_id
|
||
AND key = 'preference_task_notification_alert_dismissed';
|
||
|
||
-- name: UpdateUserTaskNotificationAlertDismissed :one
|
||
INSERT INTO
|
||
user_configs (user_id, key, value)
|
||
VALUES
|
||
(@user_id, 'preference_task_notification_alert_dismissed', (@task_notification_alert_dismissed::boolean)::text)
|
||
ON CONFLICT
|
||
ON CONSTRAINT user_configs_pkey
|
||
DO UPDATE
|
||
SET
|
||
value = @task_notification_alert_dismissed
|
||
WHERE user_configs.user_id = @user_id
|
||
AND user_configs.key = 'preference_task_notification_alert_dismissed'
|
||
RETURNING value::boolean AS task_notification_alert_dismissed;
|
||
|
||
-- name: UpdateUserRoles :one
|
||
UPDATE
|
||
users
|
||
SET
|
||
-- Remove all duplicates from the roles.
|
||
rbac_roles = ARRAY(SELECT DISTINCT UNNEST(@granted_roles :: text[]))
|
||
WHERE
|
||
id = @id
|
||
RETURNING *;
|
||
|
||
-- name: UpdateUserHashedPassword :exec
|
||
UPDATE
|
||
users
|
||
SET
|
||
hashed_password = $2,
|
||
hashed_one_time_passcode = NULL,
|
||
one_time_passcode_expires_at = NULL
|
||
WHERE
|
||
id = $1;
|
||
|
||
-- name: UpdateUserDeletedByID :exec
|
||
UPDATE
|
||
users
|
||
SET
|
||
deleted = true
|
||
WHERE
|
||
id = $1;
|
||
|
||
-- name: GetUsers :many
|
||
-- This will never return deleted users.
|
||
SELECT
|
||
*, COUNT(*) OVER() AS count
|
||
FROM
|
||
users
|
||
WHERE
|
||
users.deleted = false
|
||
AND CASE
|
||
-- This allows using the last element on a page as effectively a cursor.
|
||
-- This is an important option for scripts that need to paginate without
|
||
-- duplicating or missing data.
|
||
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
||
-- The pagination cursor is the last ID of the previous page.
|
||
-- The query is ordered by the username field, so select all
|
||
-- rows after the cursor.
|
||
(LOWER(username)) > (
|
||
SELECT
|
||
LOWER(username)
|
||
FROM
|
||
users
|
||
WHERE
|
||
id = @after_id
|
||
)
|
||
)
|
||
ELSE true
|
||
END
|
||
-- Start filters
|
||
-- Filter by email or username
|
||
AND CASE
|
||
WHEN @search :: text != '' THEN (
|
||
email ILIKE concat('%', @search, '%')
|
||
OR username ILIKE concat('%', @search, '%')
|
||
)
|
||
ELSE true
|
||
END
|
||
-- Filter by name (display name)
|
||
AND CASE
|
||
WHEN @name :: text != '' THEN
|
||
name ILIKE concat('%', @name, '%')
|
||
ELSE true
|
||
END
|
||
-- Filter by status
|
||
AND CASE
|
||
-- @status needs to be a text because it can be empty, If it was
|
||
-- user_status enum, it would not.
|
||
WHEN cardinality(@status :: user_status[]) > 0 THEN
|
||
status = ANY(@status :: user_status[])
|
||
ELSE true
|
||
END
|
||
-- Filter by rbac_roles
|
||
AND CASE
|
||
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
|
||
-- everyone is a member.
|
||
WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN
|
||
rbac_roles && @rbac_role :: text[]
|
||
ELSE true
|
||
END
|
||
-- Filter by last_seen
|
||
AND CASE
|
||
WHEN @last_seen_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||
last_seen_at <= @last_seen_before
|
||
ELSE true
|
||
END
|
||
AND CASE
|
||
WHEN @last_seen_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||
last_seen_at >= @last_seen_after
|
||
ELSE true
|
||
END
|
||
-- Filter by created_at
|
||
AND CASE
|
||
WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||
created_at <= @created_before
|
||
ELSE true
|
||
END
|
||
AND CASE
|
||
WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||
created_at >= @created_after
|
||
ELSE true
|
||
END
|
||
AND CASE
|
||
WHEN @include_system::bool THEN TRUE
|
||
ELSE
|
||
is_system = false
|
||
END
|
||
AND CASE
|
||
WHEN @github_com_user_id :: bigint != 0 THEN
|
||
github_com_user_id = @github_com_user_id
|
||
ELSE true
|
||
END
|
||
-- Filter by login_type
|
||
AND CASE
|
||
WHEN cardinality(@login_type :: login_type[]) > 0 THEN
|
||
login_type = ANY(@login_type :: login_type[])
|
||
ELSE true
|
||
END
|
||
-- End of filters
|
||
|
||
-- Authorize Filter clause will be injected below in GetAuthorizedUsers
|
||
-- @authorize_filter
|
||
ORDER BY
|
||
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
|
||
LOWER(username) ASC OFFSET @offset_opt
|
||
LIMIT
|
||
-- A null limit means "no limit", so 0 means return all
|
||
NULLIF(@limit_opt :: int, 0);
|
||
|
||
-- name: UpdateUserStatus :one
|
||
UPDATE
|
||
users
|
||
SET
|
||
status = $2,
|
||
updated_at = $3,
|
||
-- If the user is logging in, set last_seen_at to updated_at.
|
||
last_seen_at = CASE WHEN @user_is_seen :: boolean THEN $3 :: timestamptz ELSE last_seen_at END
|
||
WHERE
|
||
id = $1 RETURNING *;
|
||
|
||
-- name: UpdateUserLastSeenAt :one
|
||
UPDATE
|
||
users
|
||
SET
|
||
last_seen_at = $2,
|
||
updated_at = $3
|
||
WHERE
|
||
id = $1 RETURNING *;
|
||
|
||
|
||
-- name: GetAuthorizationUserRoles :one
|
||
-- This function returns roles for authorization purposes. Implied member roles
|
||
-- are included.
|
||
SELECT
|
||
-- username and email are returned just to help for logging purposes
|
||
-- status is used to enforce 'suspended' users, as all roles are ignored
|
||
-- when suspended.
|
||
id, username, status, email,
|
||
-- All user roles, including their org roles.
|
||
array_cat(
|
||
-- All users are members
|
||
array_append(users.rbac_roles, 'member'),
|
||
(
|
||
SELECT
|
||
-- The roles are returned as a flat array, org scoped and site side.
|
||
-- Concatenating the organization id scopes the organization roles.
|
||
array_agg(org_roles || ':' || organization_members.organization_id::text)
|
||
FROM
|
||
organization_members,
|
||
-- All org members get an implied role for their orgs. Most members
|
||
-- get organization-member, but service accounts will get
|
||
-- organization-service-account instead. They're largely the same,
|
||
-- but having them be distinct means we can allow configuring
|
||
-- service-accounts to have slightly broader permissions–such as
|
||
-- for workspace sharing.
|
||
unnest(
|
||
array_append(
|
||
roles,
|
||
CASE WHEN users.is_service_account THEN
|
||
'organization-service-account'
|
||
ELSE
|
||
'organization-member'
|
||
END
|
||
)
|
||
) AS org_roles
|
||
WHERE
|
||
user_id = users.id
|
||
)
|
||
) :: text[] AS roles,
|
||
-- All groups the user is in.
|
||
(
|
||
SELECT
|
||
array_agg(
|
||
group_members.group_id :: text
|
||
)
|
||
FROM
|
||
group_members
|
||
WHERE
|
||
user_id = users.id
|
||
) :: text[] AS groups
|
||
FROM
|
||
users
|
||
WHERE
|
||
id = @user_id;
|
||
|
||
-- name: UpdateUserQuietHoursSchedule :one
|
||
UPDATE
|
||
users
|
||
SET
|
||
quiet_hours_schedule = $2
|
||
WHERE
|
||
id = $1
|
||
RETURNING *;
|
||
|
||
|
||
-- name: UpdateInactiveUsersToDormant :many
|
||
UPDATE
|
||
users
|
||
SET
|
||
status = 'dormant'::user_status,
|
||
updated_at = @updated_at
|
||
WHERE
|
||
last_seen_at < @last_seen_after :: timestamp
|
||
AND status = 'active'::user_status
|
||
AND NOT is_system
|
||
RETURNING id, email, username, last_seen_at;
|
||
|
||
-- AllUserIDs returns all UserIDs regardless of user status or deletion.
|
||
-- name: AllUserIDs :many
|
||
SELECT DISTINCT id FROM USERS
|
||
WHERE CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END;
|
||
|
||
-- name: UpdateUserHashedOneTimePasscode :exec
|
||
UPDATE
|
||
users
|
||
SET
|
||
hashed_one_time_passcode = $2,
|
||
one_time_passcode_expires_at = $3
|
||
WHERE
|
||
id = $1
|
||
;
|