Conversation
Documentation CheckUpdates Needed
Automated review via Coder Tasks |
7d40c26 to
7080442
Compare
mafredri
left a comment
There was a problem hiding this comment.
🤖 Agent on behalf of mafredri
Clean approach to adding labels as a first-class chat property. The COALESCE pattern for optional label updates, GIN index with containment queries, and the validation suite with boundary tests are well executed.
1 P1 (title-clobber race), 2 P2s (missing integration tests, non-atomic patch), 1 P3, 1 observation, and 3 nits across 8 inline comments.
Fifteen reviewers agreed on one thing: that COALESCE pattern for labels is exactly right. They just want the title to get the same treatment.
| SET | ||
| title = @title::text, | ||
| labels = COALESCE(sqlc.narg('labels')::jsonb, labels), | ||
| updated_at = NOW() |
There was a problem hiding this comment.
P1 patchChat label update can silently revert a concurrently generated title. (Meruem P1, Hisoka P1, Ryosuke P1, Knuckle P2, Mafuuu P2)
UpdateChatByIDunconditionally setstitle = @title::textwhile usingCOALESCE(sqlc.narg('labels')::jsonb, labels)for labels. ThepatchChathandler passesTitle: chat.Titlefrom the middleware-loaded snapshot. Ifquickgengenerates a new title between the middleware read and the label-update write, the generated title is overwritten with the stale value.
Meruem traces the interleaving:
- User creates chat (title = "New Chat"), quickgen triggers in background.
- Client sends PATCH with labels. Middleware loads
chat.Title = "New Chat".- quickgen writes
Title: "Fix CI pipeline", labels preserved via COALESCE.- patchChat writes
Title: "New Chat", labels updated. Title silently reverts.
The PR description says this feature backs the upcoming Automations system, which will PATCH labels via webhook events shortly after chat creation, exactly when quickgen is most likely running.
Two clean fixes:
- Apply COALESCE to title too:
title = COALESCE(sqlc.narg('title')::text, title). Each caller updates only what it owns. - Add a dedicated
UpdateChatLabelsquery that only toucheslabelsandupdated_at. This follows the existing convention (UpdateChatWorkspace,UpdateChatMCPServerIDs,UpdateChatStatusare all single-concern queries).
Option 2 is structurally cleaner; option 1 is a smaller diff.
There was a problem hiding this comment.
Went with option 2 — added a dedicated UpdateChatLabelsByID query that only touches labels and updated_at. Reverted UpdateChatByID back to title-only. Follows the existing single-concern convention (UpdateChatWorkspace, UpdateChatMCPServerIDs, UpdateChatStatus).
There was a problem hiding this comment.
Went with option 2: added a dedicated UpdateChatLabelsByID query that only touches labels and updated_at. Reverted UpdateChatByID back to title-only. Follows the existing single-concern convention (UpdateChatWorkspace, UpdateChatMCPServerIDs, UpdateChatStatus).
| @@ -23,6 +23,7 @@ import ( | |||
| "github.com/go-chi/chi/v5" | |||
There was a problem hiding this comment.
P2 No integration tests for any label behavior. (Bisky P2, Ryosuke P2, Mafu-san P1, Knuckle P3)
The only label tests are the validation unit tests in
coderd/httpapi/chatlabels_test.go, which verifyValidateChatLabelsin isolation. No test proves that: labels survive a create/read round-trip,?label=key:valuefiltering works with the@>containment query, PATCH replaces or clears labels, or COALESCE preserves labels when omitted from an update.
The validation function being correct doesn't prove it's wired in. If someone removes the ValidateChatLabels call from postChats or patchChat, no test breaks. For a feature described as "foundational" that automations will depend on, at least one test per operation (create with labels, list with filter, patch labels) would catch regressions.
There was a problem hiding this comment.
Added TestChatLabels with 5 subtests covering: create with labels, create without (default empty), update labels (set + clear), title update preserves labels, and label containment filtering (single-key, multi-key AND, no-filter returns all).
| } | ||
|
|
||
| if req.Labels != nil { | ||
| if errs := httpapi.ValidateChatLabels(*req.Labels); len(errs) > 0 { |
There was a problem hiding this comment.
P2 Labels and archived updates are separate non-transactional writes. (Hisoka P2, Kurapika P2, Nami P2, Luffy P2, Mafu-san P2)
When a client sends
{"labels": {"env": "prod"}, "archived": true}, the handler first commits labels viaUpdateChatByID, then attempts the archive toggle. If the archive call fails, labels are already committed. The client receives a 500 error with no indication that labels succeeded.
On retry, labels are idempotent, so no data corruption occurs. But the error response misrepresents what happened. Could wrap both in db.InTx (the codebase uses this for CreateChat), or document that fields are applied independently and partial success is expected.
There was a problem hiding this comment.
Accepted as-is. With the P1 fix, labels now go through their own UpdateChatLabelsByID and archive through chatDaemon.ArchiveChat -- fully independent single-concern writes. Both are idempotent so partial success on retry is safe.
coderd/exp_chats.go
Outdated
| return | ||
| } | ||
| updatedChat, err := api.Database.UpdateChatByID(ctx, database.UpdateChatByIDParams{ | ||
| ID: chat.ID, |
There was a problem hiding this comment.
P3 Label update goes directly to api.Database, bypassing chatDaemon pubsub. (Ryosuke P2, Bisky P3)
The archive path routes through
api.chatDaemon.ArchiveChat(), which publishes a pubsub event. The label path callsapi.Database.UpdateChatByID()directly, so no pubsub event fires.
This may be intentional for the initial implementation since the automation use case queries by labels rather than subscribing to label changes. Worth noting for consistency: if label changes should eventually be visible to live subscribers (other browser tabs, chat list UIs), they'll need to go through the daemon.
There was a problem hiding this comment.
Intentional for v1. The automation use case queries by labels rather than subscribing to changes. If label changes need to be visible to live subscribers later, we can route through the daemon then.
| for _, lp := range labelParams { | ||
| parts := strings.SplitN(lp, ":", 2) | ||
| if len(parts) != 2 || parts[0] == "" || parts[1] == "" { | ||
| httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ |
There was a problem hiding this comment.
Obs Label filter keys skip the validation that the create/update path enforces. (Hisoka, Meruem, Mafuuu, Knuckle, Kurapika, Nami, Mafu-san)
listChatschecks for non-empty key and value but does not runValidateChatLabelson the filter map. A query like?label=bad key:valuepasses the parser and reaches the@>query, which harmlessly returns no results. Not exploitable (parameterized query), but the API gives silent empty results instead of a 400 for malformed filters.
Whether to add validation here is a judgment call. Reusing labelKeyRegex on query keys would give callers actionable feedback.
There was a problem hiding this comment.
Added empty key/value rejection in the filter parser. Decided against full labelKeyRegex validation on filter keys -- a filter for a nonexistent key just returns zero results harmlessly, and being strict here would break forward compatibility if key rules ever relax.
| // characters. | ||
| maxLabelValueLength = 256 | ||
| ) | ||
|
|
There was a problem hiding this comment.
Nit len(v) measures bytes, not characters; the constant and error message say "characters." (Mafuuu P3, Ging, Leorio)
For keys this is moot since the regex restricts to ASCII. For values, a 100-character string of 4-byte emoji would be 400 bytes and fail the 256 check. The documented contract says "characters" but the enforcement is bytes.
Either say "bytes" in the comment and error message, or use utf8.RuneCountInString(v) if character semantics are intended. Probably easiest to just say "bytes" since label values will almost always be ASCII identifiers.
There was a problem hiding this comment.
Fixed — comments and error messages now say "bytes" instead of "characters". Label values are expected to be ASCII identifiers in practice, so byte semantics are fine.
There was a problem hiding this comment.
Fixed -- comments and error messages now say "bytes" instead of "characters".
coderd/exp_chats.go
Outdated
| labelMap := make(map[string]string, len(labelParams)) | ||
| for _, lp := range labelParams { | ||
| parts := strings.SplitN(lp, ":", 2) | ||
| if len(parts) != 2 || parts[0] == "" || parts[1] == "" { |
There was a problem hiding this comment.
Nit strings.SplitN + length check could be strings.Cut. (Ging)
strings.Cut(Go 1.18+) returns(key, value, found)directly, removing the slice indexing:key, value, ok := strings.Cut(lp, ":") if !ok || key == "" || value == "" {
PS. The error message "expected non-empty key:value" is slightly ambiguous about what "non-empty" modifies. "Expected format key:value (both key and value must be non-empty)" would be clearer. (Leorio)
There was a problem hiding this comment.
Done -- switched to strings.Cut. Also improved the error message clarity.
| END | ||
| AND CASE | ||
| WHEN sqlc.narg('has_labels')::jsonb IS NOT NULL THEN chats.labels @> sqlc.narg('has_labels')::jsonb | ||
| ELSE true |
There was a problem hiding this comment.
Nit has_labels reads as a boolean flag but carries a JSONB document for containment matching. (Gon)
A caller seeing
HasLabels pqtype.NullRawMessagehas to read the SQL to understand it's a containment filter, not a presence flag.contains_labelsorlabel_filterwould communicate the actual semantics.
There was a problem hiding this comment.
Renamed to label_filter throughout (SQL param, Go struct field, handler variable).
kylecarbs
left a comment
There was a problem hiding this comment.
Replies posted inline.
mafredri
left a comment
There was a problem hiding this comment.
🤖 Agent on behalf of mafredri
Checked the latest push, here's where things stand.
P1 chats.sql title-clobber race — ✅ Fixed. Dedicated UpdateChatLabelsByID query touches only labels + updated_at. UpdateChatByID reverted to title-only. quickgen and patchChat now operate on separate queries with no shared mutable fields. Clean split.
P2 No integration tests — ✅ Fixed. TestChatLabels added with 5 subtests: create with labels, create without (verifies empty default), update + clear labels, title update preserves labels, and label containment filtering (single-key, multi-key AND). Covers the round-trip, COALESCE, and @> semantics.
P2 Non-atomic dual writes — ✅ Accepted. With labels going through UpdateChatLabelsByID and archive through chatDaemon.ArchiveChat, these are independent single-concern writes. Both are idempotent on retry. No transaction needed.
P3 chatDaemon bypass — ✅ Acknowledged as intentional for v1. Automation use case queries by labels, doesn't subscribe to label changes.
Obs Filter key validation — ✅ Acknowledged. Added empty key/value rejection. Author's reasoning on not adding full regex validation is sound: strict filter keys would break forward compatibility if key rules relax.
Nit bytes vs chars — ✅ Fixed. Comments and error messages now say "bytes."
Nit strings.Cut — ✅ Fixed. Also improved the error message clarity.
Nit has_labels naming — ✅ Fixed. Renamed to label_filter in SQL, Go struct, and handler.
All findings addressed.
A good day: eight findings in, eight findings resolved, and the title finally has a query all to itself. Hyvää työtä!
mafredri
left a comment
There was a problem hiding this comment.
One auth gotcha but otherwise LGTM! 🏂🏻
Adds a general-purpose key-value label system to chats, stored as jsonb with a GIN index for efficient containment queries. Labels can be set on chat creation, updated via PATCH, and used to filter chats in list queries via `?label=key:value` query parameters. Multiple label params are AND-ed using PostgreSQL's @> containment operator. This lays the groundwork for the Automations feature, where labels will be used for session identity (matching webhooks to existing chats) instead of bespoke session key tables. Validation enforces: - Max 50 labels per chat - Key max length: 64 chars, must match [a-zA-Z0-9][a-zA-Z0-9._/-]* - Value max length: 256 chars, must be non-empty Changes: - Migration 000451: adds `labels` jsonb column + GIN index to chats - SQL queries: InsertChat, UpdateChatByID, GetChats updated for labels - GetAuthorizedChats: synced with generated GetChats (new column + param) - SDK: Chat, CreateChatRequest, UpdateChatRequest gain Labels fields - HTTP: postChats validates/passes labels, patchChat supports label updates, listChats supports ?label=key:value filtering - chatd: CreateOptions gains Labels field, threaded to InsertChat - Validation helper: httpapi.ValidateLabels with full test coverage
Summary
Adds a general-purpose
map[string]stringlabel system to chats, stored as jsonb with a GIN index for efficient containment queries.This is a standalone foundational feature that will be used by the upcoming Automations feature for session identity (matching webhook events to existing chats), replacing the need for bespoke session-key tables.
Changes
Database
labels jsonb NOT NULL DEFAULT '{}'column tochatstable with a GIN index (idx_chats_labels)InsertChat: Accepts labels on creation viaCOALESCE(@labels, '{}')UpdateChatByID: Supports partial update —COALESCE(sqlc.narg('labels'), labels)preserves existing labels when NULL is passedGetChats: Newhas_labelsfilter using PostgreSQL@>containment operatorGetAuthorizedChats: Synced with generatedGetChats(new column scan + query param)API
POST /chats): Accepts optionallabelsfield, validated before creationPATCH /chats/{chat}): Supportslabelsfield for atomic label replacementGET /chats): Supports?label=key:valuequery parameters (multiple are AND-ed)SDK
Chat,CreateChatRequest,UpdateChatRequest,ListChatsOptionsall gainLabelsfieldsUpdateChatRequest.Labelsis a pointer (*map[string]string) sonilmeans "don't change" vs empty map means "clear all"Validation (
coderd/httpapi/labels.go)[a-zA-Z0-9][a-zA-Z0-9._/-]*(supports namespaced keys likegithub.repo,automation/pr-number)Chat runtime
chatd.CreateOptionsgainsLabelsfield, threaded through toInsertChatUpdateChatByIDcallers (e.g., quickgen title updates) are unaffected — NULL labels preserve existing values via COALESCE