mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: exclude subagent chats from sidebar pagination (#24404)
GetChats now returns only root chats (parent_chat_id IS NULL). A new GetChildChatsByParentIDs query fetches children for visible roots and embeds them in each parent's Children field. The singular getChat endpoint does the same. Archive invariant is one-way: parent archived implies child archived. Parent archive/unarchive cascades via root_chat_id. Individual child archive is permitted; child unarchive while the parent is archived is rejected atomically (row lock on child, re-read parent inside the transaction). Embedded children are filtered by the caller's archive state so individually-archived children stay hidden from active-parent views. Gitsync MarkStale uses GetChatsByWorkspaceIDs directly; MarkStaleParams.OwnerID removed (dead after the switch). Frontend: buildChatTree reads from the embedded children field, WebSocket handlers route child events into the parent's children array, and archiving a child strips it from the parent cache.
This commit is contained in:
@@ -1609,6 +1609,11 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
|
||||
parentChatID := c.ParentChatID.UUID
|
||||
chat.ParentChatID = &parentChatID
|
||||
}
|
||||
// Always initialize Children to an empty slice so the JSON
|
||||
// field serializes as [] rather than null. Root chats may
|
||||
// later have children populated; child chats remain empty
|
||||
// because nesting depth is capped at 1.
|
||||
chat.Children = []codersdk.Chat{}
|
||||
switch {
|
||||
case c.RootChatID.Valid:
|
||||
rootChatID := c.RootChatID.UUID
|
||||
@@ -1756,19 +1761,21 @@ func ChatDebugStep(s database.ChatDebugStep) codersdk.ChatDebugStep {
|
||||
}
|
||||
}
|
||||
|
||||
// ChatRows converts a slice of database.GetChatsRow (which embeds
|
||||
// Chat plus HasUnread) to codersdk.Chat, looking up diff statuses
|
||||
// from the provided map. When diffStatusesByChatID is non-nil,
|
||||
// chats without an entry receive an empty DiffStatus.
|
||||
func ChatRows(rows []database.GetChatsRow, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat {
|
||||
result := make([]codersdk.Chat, len(rows))
|
||||
for i, row := range rows {
|
||||
diffStatus, ok := diffStatusesByChatID[row.Chat.ID]
|
||||
// ChildChatRows converts child chat rows to codersdk.Chat values,
|
||||
// resolving diff statuses from the shared map. When diffStatuses
|
||||
// is non-nil, children without an entry receive an empty DiffStatus.
|
||||
func ChildChatRows(
|
||||
children []database.GetChildChatsByParentIDsRow,
|
||||
diffStatuses map[uuid.UUID]database.ChatDiffStatus,
|
||||
) []codersdk.Chat {
|
||||
result := make([]codersdk.Chat, len(children))
|
||||
for i, row := range children {
|
||||
diffStatus, ok := diffStatuses[row.Chat.ID]
|
||||
if ok {
|
||||
result[i] = Chat(row.Chat, &diffStatus, nil)
|
||||
} else {
|
||||
result[i] = Chat(row.Chat, nil, nil)
|
||||
if diffStatusesByChatID != nil {
|
||||
if diffStatuses != nil {
|
||||
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
|
||||
result[i].DiffStatus = &emptyDiffStatus
|
||||
}
|
||||
@@ -1778,6 +1785,43 @@ func ChatRows(rows []database.GetChatsRow, diffStatusesByChatID map[uuid.UUID]da
|
||||
return result
|
||||
}
|
||||
|
||||
// ChatRowsWithChildren converts root chat rows and their child rows
|
||||
// into codersdk.Chat values with children embedded under each parent.
|
||||
// Both root and child diff statuses are resolved from the shared map.
|
||||
func ChatRowsWithChildren(
|
||||
roots []database.GetChatsRow,
|
||||
children []database.GetChildChatsByParentIDsRow,
|
||||
diffStatuses map[uuid.UUID]database.ChatDiffStatus,
|
||||
) []codersdk.Chat {
|
||||
// Group children by parent ID.
|
||||
childrenByParent := make(map[uuid.UUID][]database.GetChildChatsByParentIDsRow, len(children))
|
||||
for _, row := range children {
|
||||
parentID := row.Chat.ParentChatID.UUID
|
||||
childrenByParent[parentID] = append(childrenByParent[parentID], row)
|
||||
}
|
||||
|
||||
result := make([]codersdk.Chat, len(roots))
|
||||
for i, row := range roots {
|
||||
diffStatus, ok := diffStatuses[row.Chat.ID]
|
||||
if ok {
|
||||
result[i] = Chat(row.Chat, &diffStatus, nil)
|
||||
} else {
|
||||
result[i] = Chat(row.Chat, nil, nil)
|
||||
if diffStatuses != nil {
|
||||
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
|
||||
result[i].DiffStatus = &emptyDiffStatus
|
||||
}
|
||||
}
|
||||
result[i].HasUnread = row.HasUnread
|
||||
|
||||
// Embed child chats.
|
||||
if childRows, ok := childrenByParent[row.Chat.ID]; ok {
|
||||
result[i].Children = ChildChatRows(childRows, diffStatuses)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ChatDiffStatus converts a database.ChatDiffStatus to a
|
||||
// codersdk.ChatDiffStatus. When status is nil an empty value
|
||||
// containing only the chatID is returned.
|
||||
|
||||
@@ -856,7 +856,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
|
||||
|
||||
v := reflect.ValueOf(got)
|
||||
typ := v.Type()
|
||||
// HasUnread is populated by ChatRows (which joins the
|
||||
// HasUnread is populated by ChatRowsWithChildren (which joins the
|
||||
// read-cursor query), not by Chat. Warnings is a transient
|
||||
// field populated by handlers, not the converter. Both are
|
||||
// expected to remain zero here.
|
||||
|
||||
@@ -2944,6 +2944,14 @@ func (q *querier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time.Ti
|
||||
return q.db.GetChatsUpdatedAfter(ctx, updatedAfter)
|
||||
}
|
||||
|
||||
func (q *querier) GetChildChatsByParentIDs(ctx context.Context, arg database.GetChildChatsByParentIDsParams) ([]database.GetChildChatsByParentIDsRow, error) {
|
||||
// Each child is independently authorized via post-filter.
|
||||
// The handler calls this after GetChats already authorized
|
||||
// the parent chats, but we still verify read access on
|
||||
// every child row for defense in depth.
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChildChatsByParentIDs)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
// Just like with the audit logs query, shortcut if the user is an owner.
|
||||
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog)
|
||||
|
||||
@@ -820,6 +820,27 @@ func (s *MethodTestSuite) TestChats() {
|
||||
// No asserts here because SQLFilter.
|
||||
check.Args(params).Asserts()
|
||||
}))
|
||||
s.Run("GetChildChatsByParentIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
parentA := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
parentB := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
childA := testutil.Fake(s.T(), faker, database.Chat{
|
||||
ParentChatID: uuid.NullUUID{UUID: parentA.ID, Valid: true},
|
||||
})
|
||||
childB := testutil.Fake(s.T(), faker, database.Chat{
|
||||
ParentChatID: uuid.NullUUID{UUID: parentB.ID, Valid: true},
|
||||
})
|
||||
parentIDs := []uuid.UUID{parentA.ID, parentB.ID}
|
||||
params := database.GetChildChatsByParentIDsParams{
|
||||
ParentIds: parentIDs,
|
||||
Archived: sql.NullBool{Bool: false, Valid: true},
|
||||
}
|
||||
rows := []database.GetChildChatsByParentIDsRow{
|
||||
{Chat: childA},
|
||||
{Chat: childB},
|
||||
}
|
||||
dbm.EXPECT().GetChildChatsByParentIDs(gomock.Any(), params).Return(rows, nil).AnyTimes()
|
||||
check.Args(params).Asserts(childA, policy.ActionRead, childB, policy.ActionRead).Returns(rows)
|
||||
}))
|
||||
s.Run("GetAuthorizedChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
params := database.GetChatsParams{}
|
||||
dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes()
|
||||
|
||||
@@ -1456,6 +1456,14 @@ func (m queryMetricsStore) GetChatsUpdatedAfter(ctx context.Context, updatedAfte
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChildChatsByParentIDs(ctx context.Context, arg database.GetChildChatsByParentIDsParams) ([]database.GetChildChatsByParentIDsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChildChatsByParentIDs(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetChildChatsByParentIDs").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChildChatsByParentIDs").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg)
|
||||
|
||||
@@ -2687,6 +2687,21 @@ func (mr *MockStoreMockRecorder) GetChatsUpdatedAfter(ctx, updatedAfter any) *go
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).GetChatsUpdatedAfter), ctx, updatedAfter)
|
||||
}
|
||||
|
||||
// GetChildChatsByParentIDs mocks base method.
|
||||
func (m *MockStore) GetChildChatsByParentIDs(ctx context.Context, arg database.GetChildChatsByParentIDsParams) ([]database.GetChildChatsByParentIDsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetChildChatsByParentIDs", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetChildChatsByParentIDsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetChildChatsByParentIDs indicates an expected call of GetChildChatsByParentIDs.
|
||||
func (mr *MockStoreMockRecorder) GetChildChatsByParentIDs(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChildChatsByParentIDs", reflect.TypeOf((*MockStore)(nil).GetChildChatsByParentIDs), ctx, arg)
|
||||
}
|
||||
|
||||
// GetConnectionLogsOffset mocks base method.
|
||||
func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -182,6 +182,10 @@ func (r GetChatsRow) RBACObject() rbac.Object {
|
||||
return r.Chat.RBACObject()
|
||||
}
|
||||
|
||||
func (r GetChildChatsByParentIDsRow) RBACObject() rbac.Object {
|
||||
return r.Chat.RBACObject()
|
||||
}
|
||||
|
||||
func (c ChatFile) RBACObject() rbac.Object {
|
||||
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
|
||||
}
|
||||
|
||||
@@ -349,6 +349,11 @@ type sqlcQuerier interface {
|
||||
// snapshot collection. Uses updated_at so that long-running chats
|
||||
// still appear in each snapshot window while they are active.
|
||||
GetChatsUpdatedAfter(ctx context.Context, updatedAfter time.Time) ([]GetChatsUpdatedAfterRow, error)
|
||||
// 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.
|
||||
GetChildChatsByParentIDs(ctx context.Context, arg GetChildChatsByParentIDsParams) ([]GetChildChatsByParentIDsRow, error)
|
||||
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
|
||||
GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error)
|
||||
GetCryptoKeys(ctx context.Context) ([]CryptoKey, error)
|
||||
|
||||
@@ -6620,6 +6620,11 @@ WHERE
|
||||
WHEN $4::jsonb IS NOT NULL THEN chats.labels @> $4::jsonb
|
||||
ELSE true
|
||||
END
|
||||
-- Paginate over root chats only. Children are fetched
|
||||
-- separately via GetChildChatsByParentIDs and embedded under
|
||||
-- each parent. Other callers that need the full set should
|
||||
-- use a narrower query (e.g. GetChatsByWorkspaceIDs).
|
||||
AND chats.parent_chat_id IS NULL
|
||||
-- Authorize Filter clause will be injected below in GetAuthorizedChats
|
||||
-- @authorize_filter
|
||||
ORDER BY
|
||||
@@ -6838,6 +6843,95 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many
|
||||
SELECT
|
||||
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type,
|
||||
EXISTS (
|
||||
SELECT 1 FROM chat_messages cm
|
||||
WHERE cm.chat_id = chats.id
|
||||
AND cm.role = 'assistant'
|
||||
AND cm.deleted = false
|
||||
AND cm.id > COALESCE(chats.last_read_message_id, 0)
|
||||
) AS has_unread
|
||||
FROM
|
||||
chats
|
||||
WHERE
|
||||
chats.parent_chat_id = ANY($1 :: uuid[])
|
||||
AND CASE
|
||||
WHEN $2 :: boolean IS NULL THEN true
|
||||
ELSE chats.archived = $2 :: boolean
|
||||
END
|
||||
ORDER BY
|
||||
chats.created_at ASC,
|
||||
chats.id ASC
|
||||
`
|
||||
|
||||
type GetChildChatsByParentIDsParams struct {
|
||||
ParentIds []uuid.UUID `db:"parent_ids" json:"parent_ids"`
|
||||
Archived sql.NullBool `db:"archived" json:"archived"`
|
||||
}
|
||||
|
||||
type GetChildChatsByParentIDsRow struct {
|
||||
Chat Chat `db:"chat" json:"chat"`
|
||||
HasUnread bool `db:"has_unread" json:"has_unread"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildChatsByParentIDsParams) ([]GetChildChatsByParentIDsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getChildChatsByParentIDs, pq.Array(arg.ParentIds), arg.Archived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetChildChatsByParentIDsRow
|
||||
for rows.Next() {
|
||||
var i GetChildChatsByParentIDsRow
|
||||
if err := rows.Scan(
|
||||
&i.Chat.ID,
|
||||
&i.Chat.OwnerID,
|
||||
&i.Chat.WorkspaceID,
|
||||
&i.Chat.Title,
|
||||
&i.Chat.Status,
|
||||
&i.Chat.WorkerID,
|
||||
&i.Chat.StartedAt,
|
||||
&i.Chat.HeartbeatAt,
|
||||
&i.Chat.CreatedAt,
|
||||
&i.Chat.UpdatedAt,
|
||||
&i.Chat.ParentChatID,
|
||||
&i.Chat.RootChatID,
|
||||
&i.Chat.LastModelConfigID,
|
||||
&i.Chat.Archived,
|
||||
&i.Chat.LastError,
|
||||
&i.Chat.Mode,
|
||||
pq.Array(&i.Chat.MCPServerIDs),
|
||||
&i.Chat.Labels,
|
||||
&i.Chat.BuildID,
|
||||
&i.Chat.AgentID,
|
||||
&i.Chat.PinOrder,
|
||||
&i.Chat.LastReadMessageID,
|
||||
&i.Chat.LastInjectedContext,
|
||||
&i.Chat.DynamicTools,
|
||||
&i.Chat.OrganizationID,
|
||||
&i.Chat.PlanMode,
|
||||
&i.Chat.ClientType,
|
||||
&i.HasUnread,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one
|
||||
SELECT
|
||||
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id
|
||||
|
||||
@@ -373,6 +373,11 @@ WHERE
|
||||
WHEN sqlc.narg('label_filter')::jsonb IS NOT NULL THEN chats.labels @> sqlc.narg('label_filter')::jsonb
|
||||
ELSE true
|
||||
END
|
||||
-- Paginate over root chats only. Children are fetched
|
||||
-- separately via GetChildChatsByParentIDs and embedded under
|
||||
-- each parent. Other callers that need the full set should
|
||||
-- use a narrower query (e.g. GetChatsByWorkspaceIDs).
|
||||
AND chats.parent_chat_id IS NULL
|
||||
-- Authorize Filter clause will be injected below in GetAuthorizedChats
|
||||
-- @authorize_filter
|
||||
ORDER BY
|
||||
@@ -390,6 +395,32 @@ LIMIT
|
||||
-- Default to 50 to prevent accidental excessively large queries.
|
||||
COALESCE(NULLIF(@limit_opt :: int, 0), 50);
|
||||
|
||||
-- name: GetChildChatsByParentIDs :many
|
||||
-- Fetches child chats of the given parents, optionally filtered by
|
||||
-- archive state (NULL = all, true/false = match). The archive
|
||||
-- invariant (parent archived implies child archived) is enforced
|
||||
-- at write time, not here.
|
||||
SELECT
|
||||
sqlc.embed(chats),
|
||||
EXISTS (
|
||||
SELECT 1 FROM chat_messages cm
|
||||
WHERE cm.chat_id = chats.id
|
||||
AND cm.role = 'assistant'
|
||||
AND cm.deleted = false
|
||||
AND cm.id > COALESCE(chats.last_read_message_id, 0)
|
||||
) AS has_unread
|
||||
FROM
|
||||
chats
|
||||
WHERE
|
||||
chats.parent_chat_id = ANY(@parent_ids :: uuid[])
|
||||
AND CASE
|
||||
WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
|
||||
ELSE chats.archived = sqlc.narg('archived') :: boolean
|
||||
END
|
||||
ORDER BY
|
||||
chats.created_at ASC,
|
||||
chats.id ASC;
|
||||
|
||||
-- name: InsertChat :one
|
||||
INSERT INTO chats (
|
||||
organization_id,
|
||||
|
||||
Reference in New Issue
Block a user