diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index afae604507..a698f163c7 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -211,6 +211,13 @@ AI BRIDGE PROXY OPTIONS: certificates not trusted by the system. If not provided, the system certificate pool is used. +CHAT OPTIONS: +Configure the background chat processing daemon. + + --chat-debug-logging-enabled bool, $CODER_CHAT_DEBUG_LOGGING_ENABLED (default: false) + Force chat debug logging on for every chat, bypassing the runtime + admin and user opt-in settings. + CLIENT OPTIONS: These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 2da6b7918f..951b547217 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -757,6 +757,10 @@ chat: # How many pending chats a worker should acquire per polling cycle. # (default: 10, type: int) acquireBatchSize: 10 + # Force chat debug logging on for every chat, bypassing the runtime admin and user + # opt-in settings. + # (default: false, type: bool) + debugLoggingEnabled: false aibridge: # Whether to start an in-memory aibridged instance. # (default: false, type: bool) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index af8657d52a..a96b81e229 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14691,6 +14691,9 @@ const docTemplate = `{ "properties": { "acquire_batch_size": { "type": "integer" + }, + "debug_logging_enabled": { + "type": "boolean" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fbc07fa8e6..a1dcb82c3d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13204,6 +13204,9 @@ "properties": { "acquire_batch_size": { "type": "integer" + }, + "debug_logging_enabled": { + "type": "boolean" } } }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index f8daa8f81a..86641aa70c 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1533,6 +1533,22 @@ func nullInt64Ptr(v sql.NullInt64) *int64 { return &value } +func nullStringPtr(v sql.NullString) *string { + if !v.Valid { + return nil + } + value := v.String + return &value +} + +func nullTimePtr(v sql.NullTime) *time.Time { + if !v.Valid { + return nil + } + value := v.Time + return &value +} + // Chat converts a database.Chat to a codersdk.Chat. It coalesces // nil slices and maps to empty values for JSON serialization and // derives RootChatID from the parent chain when not explicitly set. @@ -1620,6 +1636,102 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database return chat } +func chatDebugAttempts(raw json.RawMessage) []map[string]any { + if len(raw) == 0 { + return nil + } + + var attempts []map[string]any + if err := json.Unmarshal(raw, &attempts); err != nil { + return []map[string]any{{ + "error": "malformed attempts payload", + "parse_error": err.Error(), + "raw": string(raw), + }} + } + // Guard against JSON literal "null" which unmarshals successfully + // but leaves the slice nil. The DB column is JSONB NOT NULL but + // that only rejects SQL NULL, not JSONB null. + if attempts == nil { + return []map[string]any{} + } + return attempts +} + +// rawJSONObject deserializes a JSON object payload for debug display. +// If the payload is malformed, it returns a map with "error" and "raw" +// keys preserving the original content for diagnostics. Callers that +// consume the result programmatically should check for the "error" key. +func rawJSONObject(raw json.RawMessage) map[string]any { + if len(raw) == 0 { + return nil + } + + var object map[string]any + if err := json.Unmarshal(raw, &object); err != nil { + return map[string]any{ + "error": "malformed debug payload", + "parse_error": err.Error(), + "raw": string(raw), + } + } + // Guard against JSON literal "null" which unmarshals successfully + // but leaves the map nil. The DB column is JSONB NOT NULL but + // that only rejects SQL NULL, not JSONB null. + if object == nil { + return map[string]any{} + } + return object +} + +func nullRawJSONObject(raw pqtype.NullRawMessage) map[string]any { + if !raw.Valid { + return nil + } + return rawJSONObject(raw.RawMessage) +} + +// ChatDebugRunSummary converts a database.ChatDebugRun to a +// codersdk.ChatDebugRunSummary. +func ChatDebugRunSummary(r database.ChatDebugRun) codersdk.ChatDebugRunSummary { + return codersdk.ChatDebugRunSummary{ + ID: r.ID, + ChatID: r.ChatID, + Kind: codersdk.ChatDebugRunKind(r.Kind), + Status: codersdk.ChatDebugStatus(r.Status), + Provider: nullStringPtr(r.Provider), + Model: nullStringPtr(r.Model), + Summary: rawJSONObject(r.Summary), + StartedAt: r.StartedAt, + UpdatedAt: r.UpdatedAt, + FinishedAt: nullTimePtr(r.FinishedAt), + } +} + +// ChatDebugStep converts a database.ChatDebugStep to a +// codersdk.ChatDebugStep. +func ChatDebugStep(s database.ChatDebugStep) codersdk.ChatDebugStep { + return codersdk.ChatDebugStep{ + ID: s.ID, + RunID: s.RunID, + ChatID: s.ChatID, + StepNumber: s.StepNumber, + Operation: codersdk.ChatDebugStepOperation(s.Operation), + Status: codersdk.ChatDebugStatus(s.Status), + HistoryTipMessageID: nullInt64Ptr(s.HistoryTipMessageID), + AssistantMessageID: nullInt64Ptr(s.AssistantMessageID), + NormalizedRequest: rawJSONObject(s.NormalizedRequest), + NormalizedResponse: nullRawJSONObject(s.NormalizedResponse), + Usage: nullRawJSONObject(s.Usage), + Attempts: chatDebugAttempts(s.Attempts), + Error: nullRawJSONObject(s.Error), + Metadata: rawJSONObject(s.Metadata), + StartedAt: s.StartedAt, + UpdatedAt: s.UpdatedAt, + FinishedAt: nullTimePtr(s.FinishedAt), + } +} + // 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, diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index d7903d1d54..f41b14921e 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -210,6 +210,279 @@ func TestTemplateVersionParameter_BadDescription(t *testing.T) { req.NotEmpty(sdk.DescriptionPlaintext, "broke the markdown parser with %v", desc) } +func TestChatDebugRunSummary(t *testing.T) { + t.Parallel() + + startedAt := time.Now().UTC().Round(time.Second) + finishedAt := startedAt.Add(5 * time.Second) + + run := database.ChatDebugRun{ + ID: uuid.New(), + ChatID: uuid.New(), + Kind: "chat_turn", + Status: "completed", + Provider: sql.NullString{String: "openai", Valid: true}, + Model: sql.NullString{String: "gpt-4o", Valid: true}, + Summary: json.RawMessage(`{"step_count":3,"has_error":false}`), + StartedAt: startedAt, + UpdatedAt: finishedAt, + FinishedAt: sql.NullTime{Time: finishedAt, Valid: true}, + } + + sdk := db2sdk.ChatDebugRunSummary(run) + + require.Equal(t, run.ID, sdk.ID) + require.Equal(t, run.ChatID, sdk.ChatID) + require.Equal(t, codersdk.ChatDebugRunKindChatTurn, sdk.Kind) + require.Equal(t, codersdk.ChatDebugStatusCompleted, sdk.Status) + require.NotNil(t, sdk.Provider) + require.Equal(t, "openai", *sdk.Provider) + require.NotNil(t, sdk.Model) + require.Equal(t, "gpt-4o", *sdk.Model) + require.Equal(t, map[string]any{"step_count": float64(3), "has_error": false}, sdk.Summary) + require.Equal(t, startedAt, sdk.StartedAt) + require.Equal(t, finishedAt, sdk.UpdatedAt) + require.NotNil(t, sdk.FinishedAt) + require.Equal(t, finishedAt, *sdk.FinishedAt) +} + +func TestChatDebugRunSummary_NullableFieldsNil(t *testing.T) { + t.Parallel() + + run := database.ChatDebugRun{ + ID: uuid.New(), + ChatID: uuid.New(), + Kind: "title_generation", + Status: "in_progress", + Summary: json.RawMessage(`{}`), + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + sdk := db2sdk.ChatDebugRunSummary(run) + + require.Nil(t, sdk.Provider, "NULL Provider should map to nil") + require.Nil(t, sdk.Model, "NULL Model should map to nil") + require.Nil(t, sdk.FinishedAt, "NULL FinishedAt should map to nil") +} + +func TestChatDebugStep(t *testing.T) { + t.Parallel() + + startedAt := time.Now().UTC().Round(time.Second) + finishedAt := startedAt.Add(2 * time.Second) + attempts := json.RawMessage(`[ + { + "attempt_number": 1, + "status": "completed", + "raw_request": {"url": "https://example.com"}, + "raw_response": {"status": "200"}, + "duration_ms": 123, + "started_at": "2026-03-01T10:00:01Z", + "finished_at": "2026-03-01T10:00:02Z" + } + ]`) + step := database.ChatDebugStep{ + ID: uuid.New(), + RunID: uuid.New(), + ChatID: uuid.New(), + StepNumber: 1, + Operation: "stream", + Status: "completed", + NormalizedRequest: json.RawMessage(`{"messages":[]}`), + Attempts: attempts, + Metadata: json.RawMessage(`{"provider":"openai"}`), + StartedAt: startedAt, + UpdatedAt: finishedAt, + FinishedAt: sql.NullTime{Time: finishedAt, Valid: true}, + } + + sdk := db2sdk.ChatDebugStep(step) + + // Verify all scalar fields are mapped correctly. + require.Equal(t, step.ID, sdk.ID) + require.Equal(t, step.RunID, sdk.RunID) + require.Equal(t, step.ChatID, sdk.ChatID) + require.Equal(t, step.StepNumber, sdk.StepNumber) + require.Equal(t, codersdk.ChatDebugStepOperationStream, sdk.Operation) + require.Equal(t, codersdk.ChatDebugStatusCompleted, sdk.Status) + require.Equal(t, startedAt, sdk.StartedAt) + require.Equal(t, finishedAt, sdk.UpdatedAt) + require.Equal(t, &finishedAt, sdk.FinishedAt) + + // Verify JSON object fields are deserialized. + require.NotNil(t, sdk.NormalizedRequest) + require.Equal(t, map[string]any{"messages": []any{}}, sdk.NormalizedRequest) + require.NotNil(t, sdk.Metadata) + require.Equal(t, map[string]any{"provider": "openai"}, sdk.Metadata) + + // Verify nullable fields are nil when the DB row has NULL values. + require.Nil(t, sdk.HistoryTipMessageID, "NULL HistoryTipMessageID should map to nil") + require.Nil(t, sdk.AssistantMessageID, "NULL AssistantMessageID should map to nil") + require.Nil(t, sdk.NormalizedResponse, "NULL NormalizedResponse should map to nil") + require.Nil(t, sdk.Usage, "NULL Usage should map to nil") + require.Nil(t, sdk.Error, "NULL Error should map to nil") + + // Verify attempts are preserved with all fields. + require.Len(t, sdk.Attempts, 1) + require.Equal(t, float64(1), sdk.Attempts[0]["attempt_number"]) + require.Equal(t, "completed", sdk.Attempts[0]["status"]) + require.Equal(t, float64(123), sdk.Attempts[0]["duration_ms"]) + require.Equal(t, map[string]any{"url": "https://example.com"}, sdk.Attempts[0]["raw_request"]) + require.Equal(t, map[string]any{"status": "200"}, sdk.Attempts[0]["raw_response"]) +} + +func TestChatDebugStep_NullableFieldsPopulated(t *testing.T) { + t.Parallel() + + tipID := int64(42) + asstID := int64(99) + step := database.ChatDebugStep{ + ID: uuid.New(), + RunID: uuid.New(), + ChatID: uuid.New(), + StepNumber: 2, + Operation: "generate", + Status: "completed", + HistoryTipMessageID: sql.NullInt64{Int64: tipID, Valid: true}, + AssistantMessageID: sql.NullInt64{Int64: asstID, Valid: true}, + NormalizedRequest: json.RawMessage(`{}`), + NormalizedResponse: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"text":"hi"}`), Valid: true}, + Usage: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"tokens":10}`), Valid: true}, + Error: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"code":"rate_limit"}`), Valid: true}, + Attempts: json.RawMessage(`[]`), + Metadata: json.RawMessage(`{}`), + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + sdk := db2sdk.ChatDebugStep(step) + + require.NotNil(t, sdk.HistoryTipMessageID) + require.Equal(t, tipID, *sdk.HistoryTipMessageID) + require.NotNil(t, sdk.AssistantMessageID) + require.Equal(t, asstID, *sdk.AssistantMessageID) + require.NotNil(t, sdk.NormalizedResponse) + require.Equal(t, map[string]any{"text": "hi"}, sdk.NormalizedResponse) + require.NotNil(t, sdk.Usage) + require.Equal(t, map[string]any{"tokens": float64(10)}, sdk.Usage) + require.NotNil(t, sdk.Error) + require.Equal(t, map[string]any{"code": "rate_limit"}, sdk.Error) +} + +func TestChatDebugStep_PreservesMalformedAttempts(t *testing.T) { + t.Parallel() + + step := database.ChatDebugStep{ + ID: uuid.New(), + RunID: uuid.New(), + ChatID: uuid.New(), + StepNumber: 1, + Operation: "stream", + Status: "completed", + NormalizedRequest: json.RawMessage(`{"messages":[]}`), + Attempts: json.RawMessage(`{"bad":true}`), + Metadata: json.RawMessage(`{"provider":"openai"}`), + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + sdk := db2sdk.ChatDebugStep(step) + require.Len(t, sdk.Attempts, 1) + require.Equal(t, "malformed attempts payload", sdk.Attempts[0]["error"]) + require.NotEmpty(t, sdk.Attempts[0]["parse_error"], "parse_error should contain the unmarshal error") + require.Equal(t, `{"bad":true}`, sdk.Attempts[0]["raw"]) +} + +func TestChatDebugRunSummary_PreservesMalformedSummary(t *testing.T) { + t.Parallel() + + run := database.ChatDebugRun{ + ID: uuid.New(), + ChatID: uuid.New(), + Kind: "chat_turn", + Status: "completed", + Summary: json.RawMessage(`not-an-object`), + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + sdk := db2sdk.ChatDebugRunSummary(run) + require.Equal(t, "malformed debug payload", sdk.Summary["error"]) + require.NotEmpty(t, sdk.Summary["parse_error"], "parse_error should contain the unmarshal error") + require.Equal(t, "not-an-object", sdk.Summary["raw"]) +} + +func TestChatDebugStep_PreservesMalformedRequest(t *testing.T) { + t.Parallel() + + step := database.ChatDebugStep{ + ID: uuid.New(), + RunID: uuid.New(), + ChatID: uuid.New(), + StepNumber: 1, + Operation: "stream", + Status: "completed", + NormalizedRequest: json.RawMessage(`[1,2,3]`), + Attempts: json.RawMessage(`[]`), + Metadata: json.RawMessage(`"just-a-string"`), + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + sdk := db2sdk.ChatDebugStep(step) + require.Equal(t, "malformed debug payload", sdk.NormalizedRequest["error"]) + require.NotEmpty(t, sdk.NormalizedRequest["parse_error"], "parse_error should contain the unmarshal error") + require.Equal(t, "[1,2,3]", sdk.NormalizedRequest["raw"]) + require.Equal(t, "malformed debug payload", sdk.Metadata["error"]) + require.NotEmpty(t, sdk.Metadata["parse_error"], "parse_error should contain the unmarshal error") + require.Equal(t, `"just-a-string"`, sdk.Metadata["raw"]) +} + +func TestChatDebugRunSummary_JSONNullYieldsEmptyMap(t *testing.T) { + t.Parallel() + + run := database.ChatDebugRun{ + ID: uuid.New(), + ChatID: uuid.New(), + Kind: "chat_turn", + Status: "completed", + Summary: json.RawMessage(`null`), + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + sdk := db2sdk.ChatDebugRunSummary(run) + require.NotNil(t, sdk.Summary, "JSON literal null must produce non-nil map") + require.Empty(t, sdk.Summary, "JSON literal null must produce empty map") +} + +func TestChatDebugStep_JSONNullYieldsEmptyStructures(t *testing.T) { + t.Parallel() + + step := database.ChatDebugStep{ + ID: uuid.New(), + RunID: uuid.New(), + ChatID: uuid.New(), + StepNumber: 1, + Operation: "stream", + Status: "completed", + NormalizedRequest: json.RawMessage(`null`), + Attempts: json.RawMessage(`null`), + Metadata: json.RawMessage(`null`), + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + sdk := db2sdk.ChatDebugStep(step) + require.NotNil(t, sdk.NormalizedRequest, "JSON literal null must produce non-nil map") + require.Empty(t, sdk.NormalizedRequest, "JSON literal null must produce empty map") + require.NotNil(t, sdk.Attempts, "JSON literal null must produce non-nil slice") + require.Empty(t, sdk.Attempts, "JSON literal null must produce empty slice") + require.NotNil(t, sdk.Metadata, "JSON literal null must produce non-nil map") + require.Empty(t, sdk.Metadata, "JSON literal null must produce empty map") +} + func TestAIBridgeInterception(t *testing.T) { t.Parallel() diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 0ace3ba527..c336d696e7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1860,6 +1860,28 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } +func (q *querier) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return 0, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return 0, err + } + return q.db.DeleteChatDebugDataAfterMessageID(ctx, arg) +} + +func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) { + chat, err := q.db.GetChatByID(ctx, chatID) + if err != nil { + return 0, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return 0, err + } + return q.db.DeleteChatDebugDataByChatID(ctx, chatID) +} + func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err @@ -2347,6 +2369,14 @@ func (q *querier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, return q.db.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt) } +func (q *querier) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) { + // Background sweep operates across all chats. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil { + return database.FinalizeStaleChatDebugRowsRow{}, err + } + return q.db.FinalizeStaleChatDebugRows(ctx, updatedBefore) +} + func (q *querier) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) { _, err := q.GetTemplateVersionByID(ctx, arg.TemplateVersionID) if err != nil { @@ -2555,6 +2585,59 @@ func (q *querier) GetChatCostSummary(ctx context.Context, arg database.GetChatCo return q.db.GetChatCostSummary(ctx, arg) } +func (q *querier) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) { + // The allow-users flag is a deployment-wide setting read by any + // authenticated chat user. We only require that an explicit actor + // is present in the context so unauthenticated calls fail closed. + if _, ok := ActorFromContext(ctx); !ok { + return false, ErrNoActor + } + return q.db.GetChatDebugLoggingAllowUsers(ctx) +} + +func (q *querier) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) { + run, err := q.db.GetChatDebugRunByID(ctx, id) + if err != nil { + return database.ChatDebugRun{}, err + } + // Authorize via the owning chat. + chat, err := q.db.GetChatByID(ctx, run.ChatID) + if err != nil { + return database.ChatDebugRun{}, err + } + if err := q.authorizeContext(ctx, policy.ActionRead, chat); err != nil { + return database.ChatDebugRun{}, err + } + return run, nil +} + +func (q *querier) GetChatDebugRunsByChatID(ctx context.Context, arg database.GetChatDebugRunsByChatIDParams) ([]database.ChatDebugRun, error) { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return nil, err + } + if err := q.authorizeContext(ctx, policy.ActionRead, chat); err != nil { + return nil, err + } + return q.db.GetChatDebugRunsByChatID(ctx, arg) +} + +func (q *querier) GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]database.ChatDebugStep, error) { + run, err := q.db.GetChatDebugRunByID(ctx, runID) + if err != nil { + return nil, err + } + // Authorize via the owning chat. + chat, err := q.db.GetChatByID(ctx, run.ChatID) + if err != nil { + return nil, err + } + if err := q.authorizeContext(ctx, policy.ActionRead, chat); err != nil { + return nil, err + } + return q.db.GetChatDebugStepsByRunID(ctx, runID) +} + func (q *querier) GetChatDesktopEnabled(ctx context.Context) (bool, error) { // The desktop-enabled flag is a deployment-wide setting read by any // authenticated chat user and by chatd when deciding whether to expose @@ -4103,6 +4186,17 @@ func (q *querier) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID) return q.db.GetUserChatCustomPrompt(ctx, userID) } +func (q *querier) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) { + u, err := q.db.GetUserByID(ctx, userID) + if err != nil { + return false, err + } + if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { + return false, err + } + return q.db.GetUserChatDebugLoggingEnabled(ctx, userID) +} + func (q *querier) GetUserChatProviderKeys(ctx context.Context, userID uuid.UUID) ([]database.UserChatProviderKey, error) { u, err := q.db.GetUserByID(ctx, userID) if err != nil { @@ -4849,6 +4943,33 @@ func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), q.db.InsertChat)(ctx, arg) } +func (q *querier) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatDebugRun{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatDebugRun{}, err + } + return q.db.InsertChatDebugRun(ctx, arg) +} + +// InsertChatDebugStep creates a new step in a debug run. The underlying +// SQL uses INSERT ... SELECT ... FROM chat_debug_runs to enforce that the +// run exists and belongs to the specified chat. If the run_id is invalid +// or the chat_id doesn't match, the INSERT produces 0 rows and SQLC +// returns sql.ErrNoRows. +func (q *querier) InsertChatDebugStep(ctx context.Context, arg database.InsertChatDebugStepParams) (database.ChatDebugStep, error) { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatDebugStep{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatDebugStep{}, err + } + return q.db.InsertChatDebugStep(ctx, arg) +} + func (q *querier) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) { // Authorize create on chat resource scoped to the owner and org. return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), q.db.InsertChatFile)(ctx, arg) @@ -5847,6 +5968,28 @@ func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByI return q.db.UpdateChatByID(ctx, arg) } +func (q *querier) UpdateChatDebugRun(ctx context.Context, arg database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatDebugRun{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatDebugRun{}, err + } + return q.db.UpdateChatDebugRun(ctx, arg) +} + +func (q *querier) UpdateChatDebugStep(ctx context.Context, arg database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatDebugStep{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatDebugStep{}, err + } + return q.db.UpdateChatDebugStep(ctx, arg) +} + func (q *querier) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) { // The batch heartbeat is a system-level operation filtered by // worker_id. Authorization is enforced by the AsChatd context @@ -7079,6 +7222,13 @@ func (q *querier) UpsertBoundaryUsageStats(ctx context.Context, arg database.Ups return q.db.UpsertBoundaryUsageStats(ctx, arg) } +func (q *querier) UpsertChatDebugLoggingAllowUsers(ctx context.Context, allowUsers bool) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertChatDebugLoggingAllowUsers(ctx, allowUsers) +} + func (q *querier) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err @@ -7309,6 +7459,17 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error { return q.db.UpsertTemplateUsageStats(ctx) } +func (q *querier) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg database.UpsertUserChatDebugLoggingEnabledParams) error { + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil { + return err + } + return q.db.UpsertUserChatDebugLoggingEnabled(ctx, arg) +} + func (q *querier) UpsertUserChatProviderKey(ctx context.Context, arg database.UpsertUserChatProviderKeyParams) (database.UserChatProviderKey, error) { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 333ea10002..7a6a5ec158 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -461,6 +461,89 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().DeleteChatQueuedMessage(gomock.Any(), args).Return(nil).AnyTimes() check.Args(args).Asserts(chat, policy.ActionUpdate).Returns() })) + s.Run("DeleteChatDebugDataAfterMessageID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.DeleteChatDebugDataAfterMessageIDParams{ChatID: chat.ID, MessageID: 123} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().DeleteChatDebugDataAfterMessageID(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) + })) + s.Run("DeleteChatDebugDataByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().DeleteChatDebugDataByChatID(gomock.Any(), chat.ID).Return(int64(1), nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) + })) + s.Run("FinalizeStaleChatDebugRows", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + updatedBefore := dbtime.Now() + row := database.FinalizeStaleChatDebugRowsRow{RunsFinalized: 1, StepsFinalized: 2} + dbm.EXPECT().FinalizeStaleChatDebugRows(gomock.Any(), updatedBefore).Return(row, nil).AnyTimes() + check.Args(updatedBefore).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns(row) + })) + s.Run("GetChatDebugLoggingAllowUsers", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatDebugLoggingAllowUsers(gomock.Any()).Return(true, nil).AnyTimes() + check.Args().Asserts().Returns(true) + })) + s.Run("GetChatDebugRunByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + run := database.ChatDebugRun{ID: uuid.New(), ChatID: chat.ID} + dbm.EXPECT().GetChatDebugRunByID(gomock.Any(), run.ID).Return(run, nil).AnyTimes() + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + check.Args(run.ID).Asserts(chat, policy.ActionRead).Returns(run) + })) + s.Run("GetChatDebugRunsByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + runs := []database.ChatDebugRun{{ID: uuid.New(), ChatID: chat.ID}} + arg := database.GetChatDebugRunsByChatIDParams{ChatID: chat.ID, LimitVal: 100} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetChatDebugRunsByChatID(gomock.Any(), arg).Return(runs, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionRead).Returns(runs) + })) + s.Run("GetChatDebugStepsByRunID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + run := database.ChatDebugRun{ID: uuid.New(), ChatID: chat.ID} + steps := []database.ChatDebugStep{{ID: uuid.New(), RunID: run.ID, ChatID: chat.ID}} + dbm.EXPECT().GetChatDebugRunByID(gomock.Any(), run.ID).Return(run, nil).AnyTimes() + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), run.ID).Return(steps, nil).AnyTimes() + check.Args(run.ID).Asserts(chat, policy.ActionRead).Returns(steps) + })) + s.Run("InsertChatDebugRun", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.InsertChatDebugRunParams{ChatID: chat.ID, Kind: "chat_turn", Status: "in_progress"} + run := database.ChatDebugRun{ID: uuid.New(), ChatID: chat.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().InsertChatDebugRun(gomock.Any(), arg).Return(run, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(run) + })) + s.Run("InsertChatDebugStep", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.InsertChatDebugStepParams{RunID: uuid.New(), ChatID: chat.ID, StepNumber: 1, Operation: "stream", Status: "in_progress"} + step := database.ChatDebugStep{ID: uuid.New(), RunID: arg.RunID, ChatID: chat.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().InsertChatDebugStep(gomock.Any(), arg).Return(step, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(step) + })) + s.Run("UpdateChatDebugRun", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatDebugRunParams{ID: uuid.New(), ChatID: chat.ID} + run := database.ChatDebugRun{ID: arg.ID, ChatID: chat.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatDebugRun(gomock.Any(), arg).Return(run, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(run) + })) + s.Run("UpdateChatDebugStep", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatDebugStepParams{ID: uuid.New(), ChatID: chat.ID} + step := database.ChatDebugStep{ID: arg.ID, ChatID: chat.ID} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatDebugStep(gomock.Any(), arg).Return(step, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(step) + })) + s.Run("UpsertChatDebugLoggingAllowUsers", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertChatDebugLoggingAllowUsers(gomock.Any(), true).Return(nil).AnyTimes() + check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("GetChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() @@ -2494,6 +2577,19 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpsertUserChatProviderKey(gomock.Any(), arg).Return(key, nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(key) })) + s.Run("GetUserChatDebugLoggingEnabled", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().GetUserChatDebugLoggingEnabled(gomock.Any(), u.ID).Return(true, nil).AnyTimes() + check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns(true) + })) + s.Run("UpsertUserChatDebugLoggingEnabled", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpsertUserChatDebugLoggingEnabledParams{UserID: u.ID, DebugLoggingEnabled: true} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpsertUserChatDebugLoggingEnabled(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) + })) s.Run("UpdateUserChatCustomPrompt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) uc := database.UserConfig{UserID: u.ID, Key: "chat_custom_prompt", Value: "my custom prompt"} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 3f029c8cd6..a07cd8fa96 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -416,6 +416,22 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return r0 } +func (m queryMetricsStore) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.DeleteChatDebugDataAfterMessageID(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteChatDebugDataAfterMessageID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatDebugDataAfterMessageID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) { + start := time.Now() + r0, r1 := m.s.DeleteChatDebugDataByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("DeleteChatDebugDataByChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatDebugDataByChatID").Inc() + return r0, r1 +} + func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteChatModelConfigByID(ctx, id) @@ -872,6 +888,14 @@ func (m queryMetricsStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context. return r0, r1 } +func (m queryMetricsStore) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) { + start := time.Now() + r0, r1 := m.s.FinalizeStaleChatDebugRows(ctx, updatedBefore) + m.queryLatencies.WithLabelValues("FinalizeStaleChatDebugRows").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "FinalizeStaleChatDebugRows").Inc() + return r0, r1 +} + func (m queryMetricsStore) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) { start := time.Now() r0, r1 := m.s.FindMatchingPresetID(ctx, arg) @@ -1128,6 +1152,38 @@ func (m queryMetricsStore) GetChatCostSummary(ctx context.Context, arg database. return r0, r1 } +func (m queryMetricsStore) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetChatDebugLoggingAllowUsers(ctx) + m.queryLatencies.WithLabelValues("GetChatDebugLoggingAllowUsers").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugLoggingAllowUsers").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) { + start := time.Now() + r0, r1 := m.s.GetChatDebugRunByID(ctx, id) + m.queryLatencies.WithLabelValues("GetChatDebugRunByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugRunByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatDebugRunsByChatID(ctx context.Context, chatID database.GetChatDebugRunsByChatIDParams) ([]database.ChatDebugRun, error) { + start := time.Now() + r0, r1 := m.s.GetChatDebugRunsByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("GetChatDebugRunsByChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugRunsByChatID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]database.ChatDebugStep, error) { + start := time.Now() + r0, r1 := m.s.GetChatDebugStepsByRunID(ctx, runID) + m.queryLatencies.WithLabelValues("GetChatDebugStepsByRunID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugStepsByRunID").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatDesktopEnabled(ctx context.Context) (bool, error) { start := time.Now() r0, r1 := m.s.GetChatDesktopEnabled(ctx) @@ -2616,6 +2672,14 @@ func (m queryMetricsStore) GetUserChatCustomPrompt(ctx context.Context, userID u return r0, r1 } +func (m queryMetricsStore) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetUserChatDebugLoggingEnabled(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserChatDebugLoggingEnabled").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserChatDebugLoggingEnabled").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetUserChatProviderKeys(ctx context.Context, userID uuid.UUID) ([]database.UserChatProviderKey, error) { start := time.Now() r0, r1 := m.s.GetUserChatProviderKeys(ctx, userID) @@ -3312,6 +3376,22 @@ func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertCh return r0, r1 } +func (m queryMetricsStore) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { + start := time.Now() + r0, r1 := m.s.InsertChatDebugRun(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatDebugRun").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatDebugRun").Inc() + return r0, r1 +} + +func (m queryMetricsStore) InsertChatDebugStep(ctx context.Context, arg database.InsertChatDebugStepParams) (database.ChatDebugStep, error) { + start := time.Now() + r0, r1 := m.s.InsertChatDebugStep(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatDebugStep").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatDebugStep").Inc() + return r0, r1 +} + func (m queryMetricsStore) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) { start := time.Now() r0, r1 := m.s.InsertChatFile(ctx, arg) @@ -4208,6 +4288,22 @@ func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.Upda return r0, r1 } +func (m queryMetricsStore) UpdateChatDebugRun(ctx context.Context, arg database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatDebugRun(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatDebugRun").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatDebugRun").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateChatDebugStep(ctx context.Context, arg database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatDebugStep(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatDebugStep").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatDebugStep").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) { start := time.Now() r0, r1 := m.s.UpdateChatHeartbeats(ctx, arg) @@ -5048,6 +5144,14 @@ func (m queryMetricsStore) UpsertBoundaryUsageStats(ctx context.Context, arg dat return r0, r1 } +func (m queryMetricsStore) UpsertChatDebugLoggingAllowUsers(ctx context.Context, allowUsers bool) error { + start := time.Now() + r0 := m.s.UpsertChatDebugLoggingAllowUsers(ctx, allowUsers) + m.queryLatencies.WithLabelValues("UpsertChatDebugLoggingAllowUsers").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatDebugLoggingAllowUsers").Inc() + return r0 +} + func (m queryMetricsStore) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error { start := time.Now() r0 := m.s.UpsertChatDesktopEnabled(ctx, enableDesktop) @@ -5280,6 +5384,14 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error { return r0 } +func (m queryMetricsStore) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg database.UpsertUserChatDebugLoggingEnabledParams) error { + start := time.Now() + r0 := m.s.UpsertUserChatDebugLoggingEnabled(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertUserChatDebugLoggingEnabled").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertUserChatDebugLoggingEnabled").Inc() + return r0 +} + func (m queryMetricsStore) UpsertUserChatProviderKey(ctx context.Context, arg database.UpsertUserChatProviderKeyParams) (database.UserChatProviderKey, error) { start := time.Now() r0, r1 := m.s.UpsertUserChatProviderKey(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 4a719331c5..141197ee32 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -671,6 +671,36 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } +// DeleteChatDebugDataAfterMessageID mocks base method. +func (m *MockStore) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatDebugDataAfterMessageID", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteChatDebugDataAfterMessageID indicates an expected call of DeleteChatDebugDataAfterMessageID. +func (mr *MockStoreMockRecorder) DeleteChatDebugDataAfterMessageID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatDebugDataAfterMessageID", reflect.TypeOf((*MockStore)(nil).DeleteChatDebugDataAfterMessageID), ctx, arg) +} + +// DeleteChatDebugDataByChatID mocks base method. +func (m *MockStore) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatDebugDataByChatID", ctx, chatID) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteChatDebugDataByChatID indicates an expected call of DeleteChatDebugDataByChatID. +func (mr *MockStoreMockRecorder) DeleteChatDebugDataByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatDebugDataByChatID", reflect.TypeOf((*MockStore)(nil).DeleteChatDebugDataByChatID), ctx, chatID) +} + // DeleteChatModelConfigByID mocks base method. func (m *MockStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -1487,6 +1517,21 @@ func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsUpdatedAfter(ctx, u return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsUpdatedAfter), ctx, updatedAt) } +// FinalizeStaleChatDebugRows mocks base method. +func (m *MockStore) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FinalizeStaleChatDebugRows", ctx, updatedBefore) + ret0, _ := ret[0].(database.FinalizeStaleChatDebugRowsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FinalizeStaleChatDebugRows indicates an expected call of FinalizeStaleChatDebugRows. +func (mr *MockStoreMockRecorder) FinalizeStaleChatDebugRows(ctx, updatedBefore any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FinalizeStaleChatDebugRows", reflect.TypeOf((*MockStore)(nil).FinalizeStaleChatDebugRows), ctx, updatedBefore) +} + // FindMatchingPresetID mocks base method. func (m *MockStore) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) { m.ctrl.T.Helper() @@ -2072,6 +2117,66 @@ func (mr *MockStoreMockRecorder) GetChatCostSummary(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatCostSummary", reflect.TypeOf((*MockStore)(nil).GetChatCostSummary), ctx, arg) } +// GetChatDebugLoggingAllowUsers mocks base method. +func (m *MockStore) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatDebugLoggingAllowUsers", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatDebugLoggingAllowUsers indicates an expected call of GetChatDebugLoggingAllowUsers. +func (mr *MockStoreMockRecorder) GetChatDebugLoggingAllowUsers(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugLoggingAllowUsers", reflect.TypeOf((*MockStore)(nil).GetChatDebugLoggingAllowUsers), ctx) +} + +// GetChatDebugRunByID mocks base method. +func (m *MockStore) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatDebugRunByID", ctx, id) + ret0, _ := ret[0].(database.ChatDebugRun) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatDebugRunByID indicates an expected call of GetChatDebugRunByID. +func (mr *MockStoreMockRecorder) GetChatDebugRunByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugRunByID", reflect.TypeOf((*MockStore)(nil).GetChatDebugRunByID), ctx, id) +} + +// GetChatDebugRunsByChatID mocks base method. +func (m *MockStore) GetChatDebugRunsByChatID(ctx context.Context, arg database.GetChatDebugRunsByChatIDParams) ([]database.ChatDebugRun, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatDebugRunsByChatID", ctx, arg) + ret0, _ := ret[0].([]database.ChatDebugRun) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatDebugRunsByChatID indicates an expected call of GetChatDebugRunsByChatID. +func (mr *MockStoreMockRecorder) GetChatDebugRunsByChatID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugRunsByChatID", reflect.TypeOf((*MockStore)(nil).GetChatDebugRunsByChatID), ctx, arg) +} + +// GetChatDebugStepsByRunID mocks base method. +func (m *MockStore) GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]database.ChatDebugStep, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatDebugStepsByRunID", ctx, runID) + ret0, _ := ret[0].([]database.ChatDebugStep) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatDebugStepsByRunID indicates an expected call of GetChatDebugStepsByRunID. +func (mr *MockStoreMockRecorder) GetChatDebugStepsByRunID(ctx, runID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugStepsByRunID", reflect.TypeOf((*MockStore)(nil).GetChatDebugStepsByRunID), ctx, runID) +} + // GetChatDesktopEnabled mocks base method. func (m *MockStore) GetChatDesktopEnabled(ctx context.Context) (bool, error) { m.ctrl.T.Helper() @@ -4892,6 +4997,21 @@ func (mr *MockStoreMockRecorder) GetUserChatCustomPrompt(ctx, userID any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatCustomPrompt", reflect.TypeOf((*MockStore)(nil).GetUserChatCustomPrompt), ctx, userID) } +// GetUserChatDebugLoggingEnabled mocks base method. +func (m *MockStore) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserChatDebugLoggingEnabled", ctx, userID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserChatDebugLoggingEnabled indicates an expected call of GetUserChatDebugLoggingEnabled. +func (mr *MockStoreMockRecorder) GetUserChatDebugLoggingEnabled(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatDebugLoggingEnabled", reflect.TypeOf((*MockStore)(nil).GetUserChatDebugLoggingEnabled), ctx, userID) +} + // GetUserChatProviderKeys mocks base method. func (m *MockStore) GetUserChatProviderKeys(ctx context.Context, userID uuid.UUID) ([]database.UserChatProviderKey, error) { m.ctrl.T.Helper() @@ -6211,6 +6331,36 @@ func (mr *MockStoreMockRecorder) InsertChat(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChat", reflect.TypeOf((*MockStore)(nil).InsertChat), ctx, arg) } +// InsertChatDebugRun mocks base method. +func (m *MockStore) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatDebugRun", ctx, arg) + ret0, _ := ret[0].(database.ChatDebugRun) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatDebugRun indicates an expected call of InsertChatDebugRun. +func (mr *MockStoreMockRecorder) InsertChatDebugRun(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatDebugRun", reflect.TypeOf((*MockStore)(nil).InsertChatDebugRun), ctx, arg) +} + +// InsertChatDebugStep mocks base method. +func (m *MockStore) InsertChatDebugStep(ctx context.Context, arg database.InsertChatDebugStepParams) (database.ChatDebugStep, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatDebugStep", ctx, arg) + ret0, _ := ret[0].(database.ChatDebugStep) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatDebugStep indicates an expected call of InsertChatDebugStep. +func (mr *MockStoreMockRecorder) InsertChatDebugStep(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatDebugStep", reflect.TypeOf((*MockStore)(nil).InsertChatDebugStep), ctx, arg) +} + // InsertChatFile mocks base method. func (m *MockStore) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) { m.ctrl.T.Helper() @@ -7969,6 +8119,36 @@ func (mr *MockStoreMockRecorder) UpdateChatByID(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatByID", reflect.TypeOf((*MockStore)(nil).UpdateChatByID), ctx, arg) } +// UpdateChatDebugRun mocks base method. +func (m *MockStore) UpdateChatDebugRun(ctx context.Context, arg database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatDebugRun", ctx, arg) + ret0, _ := ret[0].(database.ChatDebugRun) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatDebugRun indicates an expected call of UpdateChatDebugRun. +func (mr *MockStoreMockRecorder) UpdateChatDebugRun(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatDebugRun", reflect.TypeOf((*MockStore)(nil).UpdateChatDebugRun), ctx, arg) +} + +// UpdateChatDebugStep mocks base method. +func (m *MockStore) UpdateChatDebugStep(ctx context.Context, arg database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatDebugStep", ctx, arg) + ret0, _ := ret[0].(database.ChatDebugStep) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatDebugStep indicates an expected call of UpdateChatDebugStep. +func (mr *MockStoreMockRecorder) UpdateChatDebugStep(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatDebugStep", reflect.TypeOf((*MockStore)(nil).UpdateChatDebugStep), ctx, arg) +} + // UpdateChatHeartbeats mocks base method. func (m *MockStore) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) { m.ctrl.T.Helper() @@ -9489,6 +9669,20 @@ func (mr *MockStoreMockRecorder) UpsertBoundaryUsageStats(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertBoundaryUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertBoundaryUsageStats), ctx, arg) } +// UpsertChatDebugLoggingAllowUsers mocks base method. +func (m *MockStore) UpsertChatDebugLoggingAllowUsers(ctx context.Context, allowUsers bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatDebugLoggingAllowUsers", ctx, allowUsers) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatDebugLoggingAllowUsers indicates an expected call of UpsertChatDebugLoggingAllowUsers. +func (mr *MockStoreMockRecorder) UpsertChatDebugLoggingAllowUsers(ctx, allowUsers any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDebugLoggingAllowUsers", reflect.TypeOf((*MockStore)(nil).UpsertChatDebugLoggingAllowUsers), ctx, allowUsers) +} + // UpsertChatDesktopEnabled mocks base method. func (m *MockStore) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error { m.ctrl.T.Helper() @@ -9906,6 +10100,20 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx) } +// UpsertUserChatDebugLoggingEnabled mocks base method. +func (m *MockStore) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg database.UpsertUserChatDebugLoggingEnabledParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertUserChatDebugLoggingEnabled", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertUserChatDebugLoggingEnabled indicates an expected call of UpsertUserChatDebugLoggingEnabled. +func (mr *MockStoreMockRecorder) UpsertUserChatDebugLoggingEnabled(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserChatDebugLoggingEnabled", reflect.TypeOf((*MockStore)(nil).UpsertUserChatDebugLoggingEnabled), ctx, arg) +} + // UpsertUserChatProviderKey mocks base method. func (m *MockStore) UpsertUserChatProviderKey(ctx context.Context, arg database.UpsertUserChatProviderKeyParams) (database.UserChatProviderKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e14330e5a5..7f6f0f6d63 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1255,6 +1255,44 @@ COMMENT ON COLUMN boundary_usage_stats.window_start IS 'Start of the time window COMMENT ON COLUMN boundary_usage_stats.updated_at IS 'Timestamp of the last update to this row.'; +CREATE TABLE chat_debug_runs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + chat_id uuid NOT NULL, + root_chat_id uuid, + parent_chat_id uuid, + model_config_id uuid, + trigger_message_id bigint, + history_tip_message_id bigint, + kind text NOT NULL, + status text NOT NULL, + provider text, + model text, + summary jsonb DEFAULT '{}'::jsonb NOT NULL, + started_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + finished_at timestamp with time zone +); + +CREATE TABLE chat_debug_steps ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + run_id uuid NOT NULL, + chat_id uuid NOT NULL, + step_number integer NOT NULL, + operation text NOT NULL, + status text NOT NULL, + history_tip_message_id bigint, + assistant_message_id bigint, + normalized_request jsonb NOT NULL, + normalized_response jsonb, + usage jsonb, + attempts jsonb DEFAULT '[]'::jsonb NOT NULL, + error jsonb, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + started_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + finished_at timestamp with time zone +); + CREATE TABLE chat_diff_statuses ( chat_id uuid NOT NULL, url text, @@ -3360,6 +3398,12 @@ ALTER TABLE ONLY audit_logs ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); +ALTER TABLE ONLY chat_debug_runs + ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY chat_debug_steps + ADD CONSTRAINT chat_debug_steps_pkey PRIMARY KEY (id); + ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id); @@ -3754,6 +3798,20 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id); CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); +CREATE INDEX idx_chat_debug_runs_chat_started ON chat_debug_runs USING btree (chat_id, started_at DESC); + +CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id); + +CREATE INDEX idx_chat_debug_runs_stale ON chat_debug_runs USING btree (updated_at) WHERE (finished_at IS NULL); + +CREATE INDEX idx_chat_debug_steps_chat_assistant_msg ON chat_debug_steps USING btree (chat_id, assistant_message_id) WHERE (assistant_message_id IS NOT NULL); + +CREATE INDEX idx_chat_debug_steps_chat_tip ON chat_debug_steps USING btree (chat_id, history_tip_message_id); + +CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btree (run_id, step_number); + +CREATE INDEX idx_chat_debug_steps_stale ON chat_debug_steps USING btree (updated_at) WHERE (finished_at IS NULL); + CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses USING btree (stale_at); CREATE INDEX idx_chat_file_links_chat_id ON chat_file_links USING btree (chat_id); @@ -4059,6 +4117,12 @@ ALTER TABLE ONLY aibridge_interceptions ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY chat_debug_runs + ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chat_debug_steps + ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; @@ -4134,6 +4198,9 @@ ALTER TABLE ONLY connection_logs ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); +ALTER TABLE ONLY chat_debug_steps + ADD CONSTRAINT fk_chat_debug_steps_run_chat FOREIGN KEY (run_id, chat_id) REFERENCES chat_debug_runs(id, chat_id) ON DELETE CASCADE; + ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT fk_oauth2_provider_app_tokens_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 18fd2908a9..ae4e6ee364 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -9,6 +9,8 @@ const ( ForeignKeyAiSeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id); ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatFileLinksChatID ForeignKeyConstraint = "chat_file_links_chat_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatFileLinksFileID ForeignKeyConstraint = "chat_file_links_file_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_file_id_fkey FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE; @@ -34,6 +36,7 @@ const ( ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; ForeignKeyConnectionLogsWorkspaceOwnerID ForeignKeyConstraint = "connection_logs_workspace_owner_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_owner_id_fkey FOREIGN KEY (workspace_owner_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyFkChatDebugStepsRunChat ForeignKeyConstraint = "fk_chat_debug_steps_run_chat" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT fk_chat_debug_steps_run_chat FOREIGN KEY (run_id, chat_id) REFERENCES chat_debug_runs(id, chat_id) ON DELETE CASCADE; ForeignKeyFkOauth2ProviderAppTokensUserID ForeignKeyConstraint = "fk_oauth2_provider_app_tokens_user_id" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT fk_oauth2_provider_app_tokens_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/migrations/000468_chat_debug_runs_and_steps.down.sql b/coderd/database/migrations/000468_chat_debug_runs_and_steps.down.sql new file mode 100644 index 0000000000..7efde87127 --- /dev/null +++ b/coderd/database/migrations/000468_chat_debug_runs_and_steps.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS chat_debug_steps; +DROP TABLE IF EXISTS chat_debug_runs; diff --git a/coderd/database/migrations/000468_chat_debug_runs_and_steps.up.sql b/coderd/database/migrations/000468_chat_debug_runs_and_steps.up.sql new file mode 100644 index 0000000000..6d11eceadb --- /dev/null +++ b/coderd/database/migrations/000468_chat_debug_runs_and_steps.up.sql @@ -0,0 +1,63 @@ +CREATE TABLE chat_debug_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + -- root_chat_id and parent_chat_id are intentionally NOT + -- foreign-keyed to chats(id). They are snapshot values that + -- record the subchat hierarchy at run time. The referenced + -- chat may be archived or deleted independently, and we want + -- to preserve the historical lineage in debug rows rather + -- than cascade-delete them. + root_chat_id UUID, + parent_chat_id UUID, + -- model_config_id follows the same snapshot rationale as + -- root_chat_id / parent_chat_id above: it records the model + -- configuration in effect at run time and must survive if + -- the referenced config is later deleted or rotated. + model_config_id UUID, + trigger_message_id BIGINT, + history_tip_message_id BIGINT, + kind TEXT NOT NULL, + status TEXT NOT NULL, + provider TEXT, + model TEXT, + summary JSONB NOT NULL DEFAULT '{}'::jsonb, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs(id, chat_id); +CREATE INDEX idx_chat_debug_runs_chat_started ON chat_debug_runs(chat_id, started_at DESC); + +CREATE TABLE chat_debug_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL, + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + step_number INT NOT NULL, + operation TEXT NOT NULL, + status TEXT NOT NULL, + history_tip_message_id BIGINT, + assistant_message_id BIGINT, + normalized_request JSONB NOT NULL, + normalized_response JSONB, + usage JSONB, + attempts JSONB NOT NULL DEFAULT '[]'::jsonb, + error JSONB, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, + CONSTRAINT fk_chat_debug_steps_run_chat + FOREIGN KEY (run_id, chat_id) + REFERENCES chat_debug_runs(id, chat_id) + ON DELETE CASCADE +); + +CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps(run_id, step_number); +CREATE INDEX idx_chat_debug_steps_chat_tip ON chat_debug_steps(chat_id, history_tip_message_id); +-- Supports DeleteChatDebugDataAfterMessageID assistant_message_id branch. +CREATE INDEX idx_chat_debug_steps_chat_assistant_msg ON chat_debug_steps(chat_id, assistant_message_id) WHERE assistant_message_id IS NOT NULL; + +-- Supports FinalizeStaleChatDebugRows worker query. +CREATE INDEX idx_chat_debug_runs_stale ON chat_debug_runs(updated_at) WHERE finished_at IS NULL; +CREATE INDEX idx_chat_debug_steps_stale ON chat_debug_steps(updated_at) WHERE finished_at IS NULL; diff --git a/coderd/database/migrations/testdata/fixtures/000468_chat_debug_runs_and_steps.up.sql b/coderd/database/migrations/testdata/fixtures/000468_chat_debug_runs_and_steps.up.sql new file mode 100644 index 0000000000..5c960e747a --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000468_chat_debug_runs_and_steps.up.sql @@ -0,0 +1,65 @@ +INSERT INTO chat_debug_runs ( + id, + chat_id, + model_config_id, + history_tip_message_id, + kind, + status, + provider, + model, + summary, + started_at, + updated_at, + finished_at +) VALUES ( + 'c98518f8-9fb3-458b-a642-57552af1db63', + '72c0438a-18eb-4688-ab80-e4c6a126ef96', + '9af5f8d5-6a57-4505-8a69-3d6c787b95fd', + (SELECT MAX(id) FROM chat_messages WHERE chat_id = '72c0438a-18eb-4688-ab80-e4c6a126ef96'), + 'chat_turn', + 'completed', + 'openai', + 'gpt-5.2', + '{"step_count":1,"has_error":false}'::jsonb, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:01+00', + '2024-01-01 00:00:01+00' +); + +INSERT INTO chat_debug_steps ( + id, + run_id, + chat_id, + step_number, + operation, + status, + history_tip_message_id, + assistant_message_id, + normalized_request, + normalized_response, + usage, + attempts, + error, + metadata, + started_at, + updated_at, + finished_at +) VALUES ( + '59471c60-7851-4fa6-bf05-e21dd939721f', + 'c98518f8-9fb3-458b-a642-57552af1db63', + '72c0438a-18eb-4688-ab80-e4c6a126ef96', + 1, + 'stream', + 'completed', + (SELECT MAX(id) FROM chat_messages WHERE chat_id = '72c0438a-18eb-4688-ab80-e4c6a126ef96'), + (SELECT MAX(id) FROM chat_messages WHERE chat_id = '72c0438a-18eb-4688-ab80-e4c6a126ef96'), + '{"messages":[]}'::jsonb, + '{"finish_reason":"stop"}'::jsonb, + '{"input_tokens":1,"output_tokens":1}'::jsonb, + '[]'::jsonb, + NULL, + '{"provider":"openai"}'::jsonb, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:01+00', + '2024-01-01 00:00:01+00' +); diff --git a/coderd/database/modelqueries_internal_test.go b/coderd/database/modelqueries_internal_test.go index 3f425b4347..698954e39b 100644 --- a/coderd/database/modelqueries_internal_test.go +++ b/coderd/database/modelqueries_internal_test.go @@ -2,6 +2,7 @@ package database import ( "regexp" + "slices" "strings" "testing" "time" @@ -9,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -128,6 +130,44 @@ func TestConnectionLogsQueryConsistency(t *testing.T) { require.Equal(t, getWhereClause, countWhereClause, "getConnectionLogsOffset and countConnectionLogs queries should have the same WHERE clause") } +// TestFinalizeStaleChatDebugRows_TerminalStatusAlignment asserts that the +// NOT IN ('completed', 'error', 'interrupted') literals in the +// FinalizeStaleChatDebugRows SQL query match the terminal statuses +// defined by ChatDebugTerminalStatuses in codersdk. If a new terminal +// status is added to Go but not to the SQL, this test fails. +func TestFinalizeStaleChatDebugRows_TerminalStatusAlignment(t *testing.T) { + t.Parallel() + + // Extract all NOT IN (...) lists from the SQL constant. + re := regexp.MustCompile(`NOT IN\s*\(([^)]+)\)`) + matches := re.FindAllStringSubmatch(finalizeStaleChatDebugRows, -1) + require.NotEmpty(t, matches, "expected at least one NOT IN clause in finalizeStaleChatDebugRows") + + // Parse the quoted status literals from each NOT IN clause. + literalRe := regexp.MustCompile(`'([^']+)'`) + goTerminal := codersdk.ChatDebugTerminalStatuses() + + for _, match := range matches { + literals := literalRe.FindAllStringSubmatch(match[1], -1) + var sqlStatuses []string + for _, lit := range literals { + sqlStatuses = append(sqlStatuses, lit[1]) + } + slices.Sort(sqlStatuses) + + var goStatuses []string + for _, s := range goTerminal { + goStatuses = append(goStatuses, string(s)) + } + slices.Sort(goStatuses) + + require.Equal(t, goStatuses, sqlStatuses, + "terminal statuses in FinalizeStaleChatDebugRows SQL must match "+ + "codersdk.ChatDebugTerminalStatuses(); update both when adding "+ + "a new terminal status") + } +} + // extractWhereClause extracts the WHERE clause from a SQL query string func extractWhereClause(query string) string { // Find WHERE and get everything after it diff --git a/coderd/database/models.go b/coderd/database/models.go index 3a8b88a392..398b67d187 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4249,6 +4249,44 @@ type Chat struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } +type ChatDebugRun struct { + ID uuid.UUID `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + TriggerMessageID sql.NullInt64 `db:"trigger_message_id" json:"trigger_message_id"` + HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"` + Kind string `db:"kind" json:"kind"` + Status string `db:"status" json:"status"` + Provider sql.NullString `db:"provider" json:"provider"` + Model sql.NullString `db:"model" json:"model"` + Summary json.RawMessage `db:"summary" json:"summary"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"` +} + +type ChatDebugStep struct { + ID uuid.UUID `db:"id" json:"id"` + RunID uuid.UUID `db:"run_id" json:"run_id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + StepNumber int32 `db:"step_number" json:"step_number"` + Operation string `db:"operation" json:"operation"` + Status string `db:"status" json:"status"` + HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"` + AssistantMessageID sql.NullInt64 `db:"assistant_message_id" json:"assistant_message_id"` + NormalizedRequest json.RawMessage `db:"normalized_request" json:"normalized_request"` + NormalizedResponse pqtype.NullRawMessage `db:"normalized_response" json:"normalized_response"` + Usage pqtype.NullRawMessage `db:"usage" json:"usage"` + Attempts json.RawMessage `db:"attempts" json:"attempts"` + Error pqtype.NullRawMessage `db:"error" json:"error"` + Metadata json.RawMessage `db:"metadata" json:"metadata"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"` +} + type ChatDiffStatus struct { ChatID uuid.UUID `db:"chat_id" json:"chat_id"` Url sql.NullString `db:"url" json:"url"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 162bfcd93b..54fd2b0132 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -102,6 +102,8 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + DeleteChatDebugDataAfterMessageID(ctx context.Context, arg DeleteChatDebugDataAfterMessageIDParams) (int64, error) + DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error @@ -194,6 +196,16 @@ type sqlcQuerier interface { FetchNewMessageMetadata(ctx context.Context, arg FetchNewMessageMetadataParams) (FetchNewMessageMetadataRow, error) FetchVolumesResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceAgentVolumeResourceMonitor, error) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error) + // Marks orphaned in-progress rows as interrupted so they do not stay + // in a non-terminal state forever. The NOT IN list must match the + // terminal statuses defined by ChatDebugStatus in codersdk/chats.go. + // + // The steps CTE also catches steps whose parent run was just finalized + // (via run_id IN), because PostgreSQL data-modifying CTEs share the + // same snapshot and cannot see each other's row updates. Without this, + // a step with a recent updated_at would survive its run's finalization + // and remain in 'in_progress' state permanently. + FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (FinalizeStaleChatDebugRowsRow, error) // FindMatchingPresetID finds a preset ID that is the largest exact subset of the provided parameters. // It returns the preset ID if a match is found, or NULL if no match is found. // The query finds presets where all preset parameters are present in the provided parameters, @@ -258,6 +270,15 @@ type sqlcQuerier interface { // Aggregate cost summary for a single user within a date range. // Only counts assistant-role messages. GetChatCostSummary(ctx context.Context, arg GetChatCostSummaryParams) (GetChatCostSummaryRow, error) + // GetChatDebugLoggingAllowUsers returns the runtime admin setting that + // allows users to opt into chat debug logging when the deployment does + // not already force debug logging on globally. + GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) + GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (ChatDebugRun, error) + // Returns the most recent debug runs for a chat, ordered newest-first. + // Callers must supply an explicit limit to avoid unbounded result sets. + GetChatDebugRunsByChatID(ctx context.Context, arg GetChatDebugRunsByChatIDParams) ([]ChatDebugRun, error) + GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]ChatDebugStep, error) GetChatDesktopEnabled(ctx context.Context) (bool, error) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error) @@ -619,6 +640,7 @@ type sqlcQuerier interface { GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserChatCompactionThreshold(ctx context.Context, arg GetUserChatCompactionThresholdParams) (string, error) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID) (string, error) + GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) GetUserChatProviderKeys(ctx context.Context, userID uuid.UUID) ([]UserChatProviderKey, error) GetUserChatSpendInPeriod(ctx context.Context, arg GetUserChatSpendInPeriodParams) (int64, error) GetUserCount(ctx context.Context, includeSystem bool) (int64, error) @@ -738,6 +760,8 @@ type sqlcQuerier interface { InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) + InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error) + InsertChatDebugStep(ctx context.Context, arg InsertChatDebugStepParams) (ChatDebugStep, error) InsertChatFile(ctx context.Context, arg InsertChatFileParams) (InsertChatFileRow, error) InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) InsertChatModelConfig(ctx context.Context, arg InsertChatModelConfigParams) (ChatModelConfig, error) @@ -916,6 +940,16 @@ type sqlcQuerier interface { UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateChatBuildAgentBinding(ctx context.Context, arg UpdateChatBuildAgentBindingParams) (Chat, error) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error) + // Uses COALESCE so that passing NULL from Go means "keep the + // existing value." This is intentional: debug rows follow a + // write-once-finalize pattern where fields are set at creation + // or finalization and never cleared back to NULL. + UpdateChatDebugRun(ctx context.Context, arg UpdateChatDebugRunParams) (ChatDebugRun, error) + // Uses COALESCE so that passing NULL from Go means "keep the + // existing value." This is intentional: debug rows follow a + // write-once-finalize pattern where fields are set at creation + // or finalization and never cleared back to NULL. + UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebugStepParams) (ChatDebugStep, error) // 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 @@ -1044,6 +1078,9 @@ type sqlcQuerier interface { // cumulative values for unique counts (accurate period totals). Request counts // are always deltas, accumulated in DB. Returns true if insert, false if update. UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBoundaryUsageStatsParams) (bool, error) + // UpsertChatDebugLoggingAllowUsers updates the runtime admin setting that + // allows users to opt into chat debug logging. + UpsertChatDebugLoggingAllowUsers(ctx context.Context, allowUsers bool) error UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error) UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error) @@ -1081,6 +1118,7 @@ type sqlcQuerier interface { // used to store the data, and the minutes are summed for each user and template // combination. The result is stored in the template_usage_stats table. UpsertTemplateUsageStats(ctx context.Context) error + UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg UpsertUserChatDebugLoggingEnabledParams) error UpsertUserChatProviderKey(ctx context.Context, arg UpsertUserChatProviderKeyParams) (UserChatProviderKey, error) UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c7d24e35b5..f57a6e7a28 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -11259,6 +11259,1004 @@ func TestChatLabels(t *testing.T) { }) } +func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) { + t.Parallel() + + store, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + + providerName := "openai" + modelName := "debug-model-" + uuid.NewString() + + _, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: providerName, + DisplayName: "Debug Provider", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: providerName, + Model: modelName, + DisplayName: "Debug Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: "chat-debug-rollback-" + uuid.NewString(), + }) + require.NoError(t, err) + + const cutoff int64 = 50 + + affectedRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: cutoff + 10, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: cutoff - 5, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + }) + require.NoError(t, err) + + _, err = store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: affectedRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "in_progress", + }) + require.NoError(t, err) + + affectedByStepHistoryTipRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: cutoff - 1, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: cutoff - 1, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + }) + require.NoError(t, err) + + _, err = store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: affectedByStepHistoryTipRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "interrupted", + HistoryTipMessageID: sql.NullInt64{Int64: cutoff + 7, Valid: true}, + }) + require.NoError(t, err) + + // affectedByStepAssistantMsgRun: run-level fields are at/below + // the cutoff, but its step has assistant_message_id above the + // cutoff. This exercises the step.assistant_message_id > cutoff + // branch of the UNION independently of history_tip_message_id. + affectedByStepAssistantMsgRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: cutoff - 2, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: cutoff - 2, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + }) + require.NoError(t, err) + + _, err = store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: affectedByStepAssistantMsgRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "completed", + AssistantMessageID: sql.NullInt64{Int64: cutoff + 3, Valid: true}, + }) + require.NoError(t, err) + + unaffectedRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: cutoff, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: cutoff, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + }) + require.NoError(t, err) + + unaffectedStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: unaffectedRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "in_progress", + AssistantMessageID: sql.NullInt64{Int64: cutoff, Valid: true}, + }) + require.NoError(t, err) + + deletedRows, err := store.DeleteChatDebugDataAfterMessageID(ctx, database.DeleteChatDebugDataAfterMessageIDParams{ + ChatID: chat.ID, + MessageID: cutoff, + }) + require.NoError(t, err) + require.EqualValues(t, 3, deletedRows) + + _, err = store.GetChatDebugRunByID(ctx, affectedRun.ID) + require.ErrorIs(t, err, sql.ErrNoRows) + + affectedSteps, err := store.GetChatDebugStepsByRunID(ctx, affectedRun.ID) + require.NoError(t, err) + require.Empty(t, affectedSteps) + + _, err = store.GetChatDebugRunByID(ctx, affectedByStepHistoryTipRun.ID) + require.ErrorIs(t, err, sql.ErrNoRows) + + affectedByStepHistoryTipSteps, err := store.GetChatDebugStepsByRunID(ctx, affectedByStepHistoryTipRun.ID) + require.NoError(t, err) + require.Empty(t, affectedByStepHistoryTipSteps) + + // Verify the run caught by step-level assistant_message_id is + // also deleted. This would survive if the + // step.assistant_message_id > @message_id clause were removed. + _, err = store.GetChatDebugRunByID(ctx, affectedByStepAssistantMsgRun.ID) + require.ErrorIs(t, err, sql.ErrNoRows) + + affectedByStepAssistantMsgSteps, err := store.GetChatDebugStepsByRunID(ctx, affectedByStepAssistantMsgRun.ID) + require.NoError(t, err) + require.Empty(t, affectedByStepAssistantMsgSteps) + + remainingRuns, err := store.GetChatDebugRunsByChatID(ctx, database.GetChatDebugRunsByChatIDParams{ + ChatID: chat.ID, + LimitVal: 100, + }) + require.NoError(t, err) + require.Len(t, remainingRuns, 1) + require.Equal(t, unaffectedRun.ID, remainingRuns[0].ID) + + remainingRun, err := store.GetChatDebugRunByID(ctx, unaffectedRun.ID) + require.NoError(t, err) + require.Equal(t, unaffectedRun.ID, remainingRun.ID) + + remainingSteps, err := store.GetChatDebugStepsByRunID(ctx, unaffectedRun.ID) + require.NoError(t, err) + require.Len(t, remainingSteps, 1) + require.Equal(t, unaffectedStep.ID, remainingSteps[0].ID) +} + +func TestFinalizeStaleChatDebugRows(t *testing.T) { + t.Parallel() + + store, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + + providerName := "openai" + modelName := "debug-model-finalize-" + uuid.NewString() + + _, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: providerName, + DisplayName: "Debug Provider", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: providerName, + Model: modelName, + DisplayName: "Debug Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: "chat-finalize-" + uuid.NewString(), + }) + require.NoError(t, err) + + // staleTime is well before the threshold so rows stamped with it + // are considered stale. The threshold sits between staleTime and + // NOW(), letting us create rows that are stale-by-age and rows + // that are fresh-by-age in the same test. + staleTime := time.Now().Add(-2 * time.Hour) + staleThreshold := time.Now().Add(-1 * time.Hour) + + // --- staleRun: in_progress run with no finished_at --- should be + // finalized. + staleRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: 1, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: 1, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + UpdatedAt: sql.NullTime{Time: staleTime, Valid: true}, + }) + require.NoError(t, err) + + // staleStep: in_progress step attached to staleRun. + staleStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: staleRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "in_progress", + UpdatedAt: sql.NullTime{Time: staleTime, Valid: true}, + }) + require.NoError(t, err) + + // --- orphanStep: in_progress step whose run is already completed --- + // its own updated_at is old, so it should be finalized directly. + completedRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: 2, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: 2, Valid: true}, + Kind: "chat_turn", + Status: "completed", + }) + require.NoError(t, err) + + // Mark the run as completed with a finished_at timestamp. + _, err = store.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{ + ID: completedRun.ID, + ChatID: completedRun.ChatID, + Status: sql.NullString{String: "completed", Valid: true}, + FinishedAt: sql.NullTime{ + Time: time.Now(), + Valid: true, + }, + }) + require.NoError(t, err) + + orphanStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: completedRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "in_progress", + UpdatedAt: sql.NullTime{Time: staleTime, Valid: true}, + }) + require.NoError(t, err) + + // --- cascadeRun: stale in_progress run with a FRESH step --- + // The run's updated_at is old so the run itself is finalized by + // age. The step's updated_at is recent (default NOW()), so it is + // NOT caught by the age predicate. It must be finalized solely + // via the cascade CTE clause: run_id IN (SELECT id FROM + // finalized_runs). Removing that clause would leave this step + // stuck in 'in_progress'. + cascadeRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: 10, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: 10, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + UpdatedAt: sql.NullTime{Time: staleTime, Valid: true}, + }) + require.NoError(t, err) + + // cascadeStep: recent updated_at (default NOW()), so only the + // cascade path can finalize it. + cascadeStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: cascadeRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "in_progress", + }) + require.NoError(t, err) + + // --- alreadyDone: completed run/step --- should NOT be touched. + doneRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: 3, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: 3, Valid: true}, + Kind: "chat_turn", + Status: "completed", + }) + require.NoError(t, err) + + _, err = store.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{ + ID: doneRun.ID, + ChatID: doneRun.ChatID, + Status: sql.NullString{String: "completed", Valid: true}, + FinishedAt: sql.NullTime{ + Time: time.Now(), + Valid: true, + }, + }) + require.NoError(t, err) + + doneStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: doneRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "completed", + }) + require.NoError(t, err) + + _, err = store.UpdateChatDebugStep(ctx, database.UpdateChatDebugStepParams{ + ID: doneStep.ID, + ChatID: chat.ID, + Status: sql.NullString{String: "completed", Valid: true}, + FinishedAt: sql.NullTime{ + Time: time.Now(), + Valid: true, + }, + }) + require.NoError(t, err) + + // --- errorRun: error run/step --- should NOT be touched either, + // exercising the 'error' branch of the NOT IN clause. + errorRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: 4, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: 4, Valid: true}, + Kind: "chat_turn", + Status: "error", + }) + require.NoError(t, err) + + _, err = store.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{ + ID: errorRun.ID, + ChatID: errorRun.ChatID, + Status: sql.NullString{String: "error", Valid: true}, + FinishedAt: sql.NullTime{ + Time: time.Now(), + Valid: true, + }, + }) + require.NoError(t, err) + + errorStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: errorRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "error", + }) + require.NoError(t, err) + + _, err = store.UpdateChatDebugStep(ctx, database.UpdateChatDebugStepParams{ + ID: errorStep.ID, + ChatID: chat.ID, + Status: sql.NullString{String: "error", Valid: true}, + FinishedAt: sql.NullTime{ + Time: time.Now(), + Valid: true, + }, + }) + require.NoError(t, err) + + // --- freshRun: recent in_progress run with current timestamp --- + // should NOT be finalized because its updated_at is after the + // threshold, exercising the age predicate (not just terminal + // status) as the survival reason. + freshRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: 20, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: 20, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + // UpdatedAt defaults to NOW(), which is after staleThreshold. + }) + require.NoError(t, err) + + freshStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: freshRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "in_progress", + // UpdatedAt defaults to NOW(). + }) + require.NoError(t, err) + + // --- Execute the finalization sweep. --- + result, err := store.FinalizeStaleChatDebugRows(ctx, staleThreshold) + require.NoError(t, err) + + // staleRun + cascadeRun were finalized; completedRun and doneRun + // were already terminal, and freshRun survives because its + // updated_at is after the threshold — so only 2 runs are expected. + assert.EqualValues(t, 2, result.RunsFinalized, + "stale + cascade in_progress runs should be finalized") + // staleStep (age), orphanStep (age), cascadeStep (cascade only) + // should all be finalized. + assert.EqualValues(t, 3, result.StepsFinalized, + "stale step + orphan step + cascade step should all be finalized") + + // Verify the stale run was set to interrupted. + updatedStaleRun, err := store.GetChatDebugRunByID(ctx, staleRun.ID) + require.NoError(t, err) + assert.Equal(t, "interrupted", updatedStaleRun.Status) + assert.True(t, updatedStaleRun.FinishedAt.Valid, + "finalized run should have a finished_at timestamp") + + // Verify the stale step was set to interrupted. + staleSteps, err := store.GetChatDebugStepsByRunID(ctx, staleRun.ID) + require.NoError(t, err) + require.Len(t, staleSteps, 1) + assert.Equal(t, staleStep.ID, staleSteps[0].ID) + assert.Equal(t, "interrupted", staleSteps[0].Status) + assert.True(t, staleSteps[0].FinishedAt.Valid, + "finalized step should have a finished_at timestamp") + + // Verify the orphan step was also finalized. + orphanSteps, err := store.GetChatDebugStepsByRunID(ctx, completedRun.ID) + require.NoError(t, err) + require.Len(t, orphanSteps, 1) + assert.Equal(t, orphanStep.ID, orphanSteps[0].ID) + assert.Equal(t, "interrupted", orphanSteps[0].Status) + + // Verify the cascade run was finalized. + updatedCascadeRun, err := store.GetChatDebugRunByID(ctx, cascadeRun.ID) + require.NoError(t, err) + assert.Equal(t, "interrupted", updatedCascadeRun.Status) + assert.True(t, updatedCascadeRun.FinishedAt.Valid, + "cascade run should have a finished_at timestamp") + + // Verify the cascade step was finalized despite its recent + // updated_at, proving the cascade CTE clause is required. + cascadeSteps, err := store.GetChatDebugStepsByRunID(ctx, cascadeRun.ID) + require.NoError(t, err) + require.Len(t, cascadeSteps, 1) + assert.Equal(t, cascadeStep.ID, cascadeSteps[0].ID) + assert.Equal(t, "interrupted", cascadeSteps[0].Status, + "fresh step should be finalized via cascade, not age") + assert.True(t, cascadeSteps[0].FinishedAt.Valid, + "cascade step should have a finished_at timestamp") + + // Verify the completed run/step are untouched. + unchangedRun, err := store.GetChatDebugRunByID(ctx, doneRun.ID) + require.NoError(t, err) + assert.Equal(t, "completed", unchangedRun.Status) + + doneSteps, err := store.GetChatDebugStepsByRunID(ctx, doneRun.ID) + require.NoError(t, err) + require.Len(t, doneSteps, 1) + assert.Equal(t, "completed", doneSteps[0].Status) + + // Verify the error run/step are untouched. + unchangedErrorRun, err := store.GetChatDebugRunByID(ctx, errorRun.ID) + require.NoError(t, err) + assert.Equal(t, "error", unchangedErrorRun.Status) + + errorSteps, err := store.GetChatDebugStepsByRunID(ctx, errorRun.ID) + require.NoError(t, err) + require.Len(t, errorSteps, 1) + assert.Equal(t, "error", errorSteps[0].Status) + + // Verify the fresh in_progress run survived due to recency, + // not terminal status — its updated_at is after the threshold. + unchangedFreshRun, err := store.GetChatDebugRunByID(ctx, freshRun.ID) + require.NoError(t, err) + assert.Equal(t, "in_progress", unchangedFreshRun.Status, + "fresh in_progress run must survive due to recency") + assert.False(t, unchangedFreshRun.FinishedAt.Valid, + "fresh run should not have a finished_at timestamp") + + freshSteps, err := store.GetChatDebugStepsByRunID(ctx, freshRun.ID) + require.NoError(t, err) + require.Len(t, freshSteps, 1) + assert.Equal(t, freshStep.ID, freshSteps[0].ID) + assert.Equal(t, "in_progress", freshSteps[0].Status, + "fresh in_progress step must survive due to recency") + assert.False(t, freshSteps[0].FinishedAt.Valid, + "fresh step should not have a finished_at timestamp") + + // A second sweep should be a no-op. + result2, err := store.FinalizeStaleChatDebugRows(ctx, staleThreshold) + require.NoError(t, err) + assert.EqualValues(t, 0, result2.RunsFinalized, + "second sweep should find nothing to finalize") + assert.EqualValues(t, 0, result2.StepsFinalized, + "second sweep should find nothing to finalize") +} + +func TestChatDebugSQLGuards(t *testing.T) { + t.Parallel() + + store, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + + providerName := "openai" + modelName := "debug-model-guards-" + uuid.NewString() + + _, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: providerName, + DisplayName: "Debug Provider", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: providerName, + Model: modelName, + DisplayName: "Debug Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chatA, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: "chat-guard-A-" + uuid.NewString(), + }) + require.NoError(t, err) + + chatB, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: "chat-guard-B-" + uuid.NewString(), + }) + require.NoError(t, err) + + runA, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chatA.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: 1, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: 1, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + }) + require.NoError(t, err) + + stepA, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: runA.ID, + ChatID: chatA.ID, + StepNumber: 1, + Operation: "stream", + Status: "in_progress", + }) + require.NoError(t, err) + + // InsertChatDebugStep: valid run_id but chat_id belongs to a + // different chat. The INSERT...SELECT guard should produce zero + // rows, surfacing as sql.ErrNoRows. + t.Run("InsertChatDebugStep_MismatchedChatID", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + _, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: runA.ID, + ChatID: chatB.ID, // wrong chat + StepNumber: 2, + Operation: "stream", + Status: "in_progress", + }) + require.ErrorIs(t, err, sql.ErrNoRows, + "InsertChatDebugStep should fail when chat_id does not match the run's chat_id") + }) + + // UpdateChatDebugRun: valid run ID but wrong chat_id. + t.Run("UpdateChatDebugRun_MismatchedChatID", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + _, err := store.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{ + ID: runA.ID, + ChatID: chatB.ID, // wrong chat + Status: sql.NullString{String: "completed", Valid: true}, + FinishedAt: sql.NullTime{ + Time: time.Now(), + Valid: true, + }, + }) + require.ErrorIs(t, err, sql.ErrNoRows, + "UpdateChatDebugRun should fail when chat_id does not match") + }) + + // UpdateChatDebugStep: valid step ID but wrong chat_id. + t.Run("UpdateChatDebugStep_MismatchedChatID", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + _, err := store.UpdateChatDebugStep(ctx, database.UpdateChatDebugStepParams{ + ID: stepA.ID, + ChatID: chatB.ID, // wrong chat + Status: sql.NullString{String: "completed", Valid: true}, + FinishedAt: sql.NullTime{ + Time: time.Now(), + Valid: true, + }, + }) + require.ErrorIs(t, err, sql.ErrNoRows, + "UpdateChatDebugStep should fail when chat_id does not match") + }) +} + +// TestChatDebugRunCOALESCEPreservation verifies that the COALESCE +// pattern in UpdateChatDebugRun preserves every field that was not +// explicitly supplied in the update. If COALESCE were removed from +// any column, the corresponding field would silently null out. +func TestChatDebugRunCOALESCEPreservation(t *testing.T) { + t.Parallel() + + store, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + + providerName := "openai" + modelName := "debug-model-coalesce-" + uuid.NewString() + + _, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: providerName, + DisplayName: "Debug Provider", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: providerName, + Model: modelName, + DisplayName: "Debug Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: "chat-debug-coalesce-" + uuid.NewString(), + }) + require.NoError(t, err) + + rootChatID := uuid.New() + parentChatID := uuid.New() + + // Insert a fully-populated run so every nullable field has a value. + original, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true}, + ParentChatID: uuid.NullUUID{UUID: parentChatID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: 42, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: 41, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + Summary: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"key":"val"}`), Valid: true}, + }) + require.NoError(t, err) + + // Update only Status and FinishedAt. Every other nullable param + // is left as its Go zero value (Valid: false → SQL NULL), which + // the COALESCE pattern should interpret as "keep existing." + now := time.Now() + updated, err := store.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{ + ID: original.ID, + ChatID: chat.ID, + Status: sql.NullString{String: "completed", Valid: true}, + FinishedAt: sql.NullTime{ + Time: now, + Valid: true, + }, + }) + require.NoError(t, err) + + // Status and FinishedAt should be updated. + require.Equal(t, "completed", updated.Status) + require.True(t, updated.FinishedAt.Valid) + + // UpdatedAt should advance (set to NOW() unconditionally). + require.True(t, updated.UpdatedAt.After(original.UpdatedAt) || + updated.UpdatedAt.Equal(original.UpdatedAt)) + + // Every field not in the update call must be preserved exactly. + require.Equal(t, original.RootChatID, updated.RootChatID, + "RootChatID should survive a partial update") + require.Equal(t, original.ParentChatID, updated.ParentChatID, + "ParentChatID should survive a partial update") + require.Equal(t, original.ModelConfigID, updated.ModelConfigID, + "ModelConfigID should survive a partial update") + require.Equal(t, original.TriggerMessageID, updated.TriggerMessageID, + "TriggerMessageID should survive a partial update") + require.Equal(t, original.HistoryTipMessageID, updated.HistoryTipMessageID, + "HistoryTipMessageID should survive a partial update") + require.Equal(t, original.Provider, updated.Provider, + "Provider should survive a partial update") + require.Equal(t, original.Model, updated.Model, + "Model should survive a partial update") + require.JSONEq(t, string(original.Summary), string(updated.Summary), + "Summary should survive a partial update") + require.Equal(t, original.Kind, updated.Kind, + "Kind should survive a partial update") + require.Equal(t, original.StartedAt.UTC(), updated.StartedAt.UTC(), + "StartedAt should survive a partial update") +} + +// TestChatDebugStepCOALESCEPreservation verifies that the COALESCE +// pattern in UpdateChatDebugStep preserves every field that was not +// explicitly supplied in the update. If COALESCE were removed from +// any column, the corresponding field would silently null out. +func TestChatDebugStepCOALESCEPreservation(t *testing.T) { + t.Parallel() + + store, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + + providerName := "openai" + modelName := "debug-step-coalesce-" + uuid.NewString() + + _, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: providerName, + DisplayName: "Debug Provider", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: providerName, + Model: modelName, + DisplayName: "Debug Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: "chat-step-coalesce-" + uuid.NewString(), + }) + require.NoError(t, err) + + run, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + Kind: "chat_turn", + Status: "in_progress", + }) + require.NoError(t, err) + + // Insert a fully-populated step so every nullable field has a value. + original, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: run.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "llm_call", + Status: "in_progress", + HistoryTipMessageID: sql.NullInt64{Int64: 10, Valid: true}, + AssistantMessageID: sql.NullInt64{Int64: 11, Valid: true}, + NormalizedRequest: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"prompt":"hello"}`), Valid: true}, + NormalizedResponse: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"text":"world"}`), Valid: true}, + Usage: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"tokens":42}`), Valid: true}, + Attempts: pqtype.NullRawMessage{RawMessage: json.RawMessage(`[{"n":1}]`), Valid: true}, + Error: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"code":"transient"}`), Valid: true}, + Metadata: pqtype.NullRawMessage{RawMessage: json.RawMessage(`{"trace_id":"abc"}`), Valid: true}, + }) + require.NoError(t, err) + + // Update only Status and FinishedAt. Every other nullable param + // is left as its Go zero value (Valid: false -> SQL NULL), which + // the COALESCE pattern should interpret as "keep existing." + now := time.Now() + updated, err := store.UpdateChatDebugStep(ctx, database.UpdateChatDebugStepParams{ + ID: original.ID, + ChatID: chat.ID, + Status: sql.NullString{String: "completed", Valid: true}, + FinishedAt: sql.NullTime{ + Time: now, + Valid: true, + }, + }) + require.NoError(t, err) + + // Status and FinishedAt should be updated. + require.Equal(t, "completed", updated.Status) + require.True(t, updated.FinishedAt.Valid) + + // UpdatedAt should advance (set to NOW() unconditionally). + require.True(t, updated.UpdatedAt.After(original.UpdatedAt) || + updated.UpdatedAt.Equal(original.UpdatedAt)) + + // Every field not in the update call must be preserved exactly. + require.Equal(t, original.HistoryTipMessageID, updated.HistoryTipMessageID, + "HistoryTipMessageID should survive a partial update") + require.Equal(t, original.AssistantMessageID, updated.AssistantMessageID, + "AssistantMessageID should survive a partial update") + require.JSONEq(t, string(original.NormalizedRequest), string(updated.NormalizedRequest), + "NormalizedRequest should survive a partial update") + require.JSONEq(t, string(original.NormalizedResponse.RawMessage), string(updated.NormalizedResponse.RawMessage), + "NormalizedResponse should survive a partial update") + require.JSONEq(t, string(original.Usage.RawMessage), string(updated.Usage.RawMessage), + "Usage should survive a partial update") + require.JSONEq(t, string(original.Attempts), string(updated.Attempts), + "Attempts should survive a partial update") + require.JSONEq(t, string(original.Error.RawMessage), string(updated.Error.RawMessage), + "Error should survive a partial update") + require.JSONEq(t, string(original.Metadata), string(updated.Metadata), + "Metadata should survive a partial update") + require.Equal(t, original.Operation, updated.Operation, + "Operation should survive a partial update") + require.Equal(t, original.StepNumber, updated.StepNumber, + "StepNumber should survive a partial update") + require.Equal(t, original.StartedAt.UTC(), updated.StartedAt.UTC(), + "StartedAt should survive a partial update") +} + +// TestDeleteChatDebugDataAfterMessageIDNullMessagesSurvive verifies +// that runs whose message ID columns are all NULL are never matched +// by DeleteChatDebugDataAfterMessageID. SQL's three-valued logic +// means NULL > N evaluates to NULL (not TRUE), so these rows must +// survive. Without this test a future change could break the +// invariant with no test failure. +func TestDeleteChatDebugDataAfterMessageIDNullMessagesSurvive(t *testing.T) { + t.Parallel() + + store, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + + providerName := "openai" + modelName := "debug-model-null-msg-" + uuid.NewString() + + _, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: providerName, + DisplayName: "Debug Provider", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: providerName, + Model: modelName, + DisplayName: "Debug Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: "chat-debug-null-msg-" + uuid.NewString(), + }) + require.NoError(t, err) + + // Insert a run with all message ID columns left as NULL (Valid: false). + nullMsgRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + Kind: "chat_turn", + Status: "in_progress", + Provider: sql.NullString{String: providerName, Valid: true}, + Model: sql.NullString{String: modelName, Valid: true}, + // TriggerMessageID and HistoryTipMessageID intentionally + // omitted (zero-value → SQL NULL). + }) + require.NoError(t, err) + + // Attach a step with NULL message IDs too. + nullMsgStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: nullMsgRun.ID, + ChatID: chat.ID, + StepNumber: 1, + Operation: "stream", + Status: "in_progress", + // HistoryTipMessageID and AssistantMessageID intentionally + // omitted (zero-value → SQL NULL). + }) + require.NoError(t, err) + + // Delete with an arbitrary cutoff. The run and its step should + // survive because NULL > cutoff evaluates to NULL, not TRUE. + deletedRows, err := store.DeleteChatDebugDataAfterMessageID(ctx, database.DeleteChatDebugDataAfterMessageIDParams{ + ChatID: chat.ID, + MessageID: 1, + }) + require.NoError(t, err) + require.EqualValues(t, 0, deletedRows, "rows with NULL message IDs must not be deleted") + + // Verify run still exists. + remaining, err := store.GetChatDebugRunByID(ctx, nullMsgRun.ID) + require.NoError(t, err) + require.Equal(t, nullMsgRun.ID, remaining.ID) + + // Verify step still exists. + remainingSteps, err := store.GetChatDebugStepsByRunID(ctx, nullMsgRun.ID) + require.NoError(t, err) + require.Len(t, remainingSteps, 1) + require.Equal(t, nullMsgStep.ID, remainingSteps[0].ID) +} + func TestChatHasUnread(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d383f13a47..bb542ef974 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2900,6 +2900,583 @@ func (q *sqlQuerier) UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBou return new_period, err } +const deleteChatDebugDataAfterMessageID = `-- name: DeleteChatDebugDataAfterMessageID :execrows +WITH affected_runs AS ( + SELECT DISTINCT run.id + FROM chat_debug_runs run + WHERE run.chat_id = $1::uuid + AND ( + run.history_tip_message_id > $2::bigint + OR run.trigger_message_id > $2::bigint + ) + + UNION + + SELECT DISTINCT step.run_id AS id + FROM chat_debug_steps step + WHERE step.chat_id = $1::uuid + AND ( + step.assistant_message_id > $2::bigint + OR step.history_tip_message_id > $2::bigint + ) +) +DELETE FROM chat_debug_runs +WHERE chat_id = $1::uuid + AND id IN (SELECT id FROM affected_runs) +` + +type DeleteChatDebugDataAfterMessageIDParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + MessageID int64 `db:"message_id" json:"message_id"` +} + +func (q *sqlQuerier) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg DeleteChatDebugDataAfterMessageIDParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteChatDebugDataAfterMessageID, arg.ChatID, arg.MessageID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteChatDebugDataByChatID = `-- name: DeleteChatDebugDataByChatID :execrows +DELETE FROM chat_debug_runs +WHERE chat_id = $1::uuid +` + +func (q *sqlQuerier) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteChatDebugDataByChatID, chatID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const finalizeStaleChatDebugRows = `-- name: FinalizeStaleChatDebugRows :one +WITH finalized_runs AS ( + UPDATE chat_debug_runs + SET + status = 'interrupted', + updated_at = NOW(), + finished_at = NOW() + WHERE updated_at < $1::timestamptz + AND finished_at IS NULL + AND status NOT IN ('completed', 'error', 'interrupted') + RETURNING id +), finalized_steps AS ( + UPDATE chat_debug_steps + SET + status = 'interrupted', + updated_at = NOW(), + finished_at = NOW() + WHERE ( + updated_at < $1::timestamptz + OR run_id IN (SELECT id FROM finalized_runs) + ) + AND finished_at IS NULL + AND status NOT IN ('completed', 'error', 'interrupted') + RETURNING 1 +) +SELECT + (SELECT COUNT(*) FROM finalized_runs)::bigint AS runs_finalized, + (SELECT COUNT(*) FROM finalized_steps)::bigint AS steps_finalized +` + +type FinalizeStaleChatDebugRowsRow struct { + RunsFinalized int64 `db:"runs_finalized" json:"runs_finalized"` + StepsFinalized int64 `db:"steps_finalized" json:"steps_finalized"` +} + +// Marks orphaned in-progress rows as interrupted so they do not stay +// in a non-terminal state forever. The NOT IN list must match the +// terminal statuses defined by ChatDebugStatus in codersdk/chats.go. +// +// The steps CTE also catches steps whose parent run was just finalized +// (via run_id IN), because PostgreSQL data-modifying CTEs share the +// same snapshot and cannot see each other's row updates. Without this, +// a step with a recent updated_at would survive its run's finalization +// and remain in 'in_progress' state permanently. +func (q *sqlQuerier) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (FinalizeStaleChatDebugRowsRow, error) { + row := q.db.QueryRowContext(ctx, finalizeStaleChatDebugRows, updatedBefore) + var i FinalizeStaleChatDebugRowsRow + err := row.Scan(&i.RunsFinalized, &i.StepsFinalized) + return i, err +} + +const getChatDebugRunByID = `-- name: GetChatDebugRunByID :one +SELECT id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at +FROM chat_debug_runs +WHERE id = $1::uuid +` + +func (q *sqlQuerier) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (ChatDebugRun, error) { + row := q.db.QueryRowContext(ctx, getChatDebugRunByID, id) + var i ChatDebugRun + err := row.Scan( + &i.ID, + &i.ChatID, + &i.RootChatID, + &i.ParentChatID, + &i.ModelConfigID, + &i.TriggerMessageID, + &i.HistoryTipMessageID, + &i.Kind, + &i.Status, + &i.Provider, + &i.Model, + &i.Summary, + &i.StartedAt, + &i.UpdatedAt, + &i.FinishedAt, + ) + return i, err +} + +const getChatDebugRunsByChatID = `-- name: GetChatDebugRunsByChatID :many +SELECT id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at +FROM chat_debug_runs +WHERE chat_id = $1::uuid +ORDER BY started_at DESC, id DESC +LIMIT $2::int +` + +type GetChatDebugRunsByChatIDParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + LimitVal int32 `db:"limit_val" json:"limit_val"` +} + +// Returns the most recent debug runs for a chat, ordered newest-first. +// Callers must supply an explicit limit to avoid unbounded result sets. +func (q *sqlQuerier) GetChatDebugRunsByChatID(ctx context.Context, arg GetChatDebugRunsByChatIDParams) ([]ChatDebugRun, error) { + rows, err := q.db.QueryContext(ctx, getChatDebugRunsByChatID, arg.ChatID, arg.LimitVal) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatDebugRun + for rows.Next() { + var i ChatDebugRun + if err := rows.Scan( + &i.ID, + &i.ChatID, + &i.RootChatID, + &i.ParentChatID, + &i.ModelConfigID, + &i.TriggerMessageID, + &i.HistoryTipMessageID, + &i.Kind, + &i.Status, + &i.Provider, + &i.Model, + &i.Summary, + &i.StartedAt, + &i.UpdatedAt, + &i.FinishedAt, + ); 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 getChatDebugStepsByRunID = `-- name: GetChatDebugStepsByRunID :many +SELECT id, run_id, chat_id, step_number, operation, status, history_tip_message_id, assistant_message_id, normalized_request, normalized_response, usage, attempts, error, metadata, started_at, updated_at, finished_at +FROM chat_debug_steps +WHERE run_id = $1::uuid +ORDER BY step_number ASC, started_at ASC +` + +func (q *sqlQuerier) GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]ChatDebugStep, error) { + rows, err := q.db.QueryContext(ctx, getChatDebugStepsByRunID, runID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatDebugStep + for rows.Next() { + var i ChatDebugStep + if err := rows.Scan( + &i.ID, + &i.RunID, + &i.ChatID, + &i.StepNumber, + &i.Operation, + &i.Status, + &i.HistoryTipMessageID, + &i.AssistantMessageID, + &i.NormalizedRequest, + &i.NormalizedResponse, + &i.Usage, + &i.Attempts, + &i.Error, + &i.Metadata, + &i.StartedAt, + &i.UpdatedAt, + &i.FinishedAt, + ); 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 insertChatDebugRun = `-- name: InsertChatDebugRun :one +INSERT INTO chat_debug_runs ( + chat_id, + root_chat_id, + parent_chat_id, + model_config_id, + trigger_message_id, + history_tip_message_id, + kind, + status, + provider, + model, + summary, + started_at, + updated_at, + finished_at +) +VALUES ( + $1::uuid, + $2::uuid, + $3::uuid, + $4::uuid, + $5::bigint, + $6::bigint, + $7::text, + $8::text, + $9::text, + $10::text, + COALESCE($11::jsonb, '{}'::jsonb), + COALESCE($12::timestamptz, NOW()), + COALESCE($13::timestamptz, NOW()), + $14::timestamptz +) +RETURNING id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at +` + +type InsertChatDebugRunParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + TriggerMessageID sql.NullInt64 `db:"trigger_message_id" json:"trigger_message_id"` + HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"` + Kind string `db:"kind" json:"kind"` + Status string `db:"status" json:"status"` + Provider sql.NullString `db:"provider" json:"provider"` + Model sql.NullString `db:"model" json:"model"` + Summary pqtype.NullRawMessage `db:"summary" json:"summary"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"` + FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"` +} + +func (q *sqlQuerier) InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error) { + row := q.db.QueryRowContext(ctx, insertChatDebugRun, + arg.ChatID, + arg.RootChatID, + arg.ParentChatID, + arg.ModelConfigID, + arg.TriggerMessageID, + arg.HistoryTipMessageID, + arg.Kind, + arg.Status, + arg.Provider, + arg.Model, + arg.Summary, + arg.StartedAt, + arg.UpdatedAt, + arg.FinishedAt, + ) + var i ChatDebugRun + err := row.Scan( + &i.ID, + &i.ChatID, + &i.RootChatID, + &i.ParentChatID, + &i.ModelConfigID, + &i.TriggerMessageID, + &i.HistoryTipMessageID, + &i.Kind, + &i.Status, + &i.Provider, + &i.Model, + &i.Summary, + &i.StartedAt, + &i.UpdatedAt, + &i.FinishedAt, + ) + return i, err +} + +const insertChatDebugStep = `-- name: InsertChatDebugStep :one +INSERT INTO chat_debug_steps ( + run_id, + chat_id, + step_number, + operation, + status, + history_tip_message_id, + assistant_message_id, + normalized_request, + normalized_response, + usage, + attempts, + error, + metadata, + started_at, + updated_at, + finished_at +) +SELECT + $1::uuid, + run.chat_id, + $2::int, + $3::text, + $4::text, + $5::bigint, + $6::bigint, + COALESCE($7::jsonb, '{}'::jsonb), + $8::jsonb, + $9::jsonb, + COALESCE($10::jsonb, '[]'::jsonb), + $11::jsonb, + COALESCE($12::jsonb, '{}'::jsonb), + COALESCE($13::timestamptz, NOW()), + COALESCE($14::timestamptz, NOW()), + $15::timestamptz +FROM chat_debug_runs run +WHERE run.id = $1::uuid + AND run.chat_id = $16::uuid +RETURNING id, run_id, chat_id, step_number, operation, status, history_tip_message_id, assistant_message_id, normalized_request, normalized_response, usage, attempts, error, metadata, started_at, updated_at, finished_at +` + +type InsertChatDebugStepParams struct { + RunID uuid.UUID `db:"run_id" json:"run_id"` + StepNumber int32 `db:"step_number" json:"step_number"` + Operation string `db:"operation" json:"operation"` + Status string `db:"status" json:"status"` + HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"` + AssistantMessageID sql.NullInt64 `db:"assistant_message_id" json:"assistant_message_id"` + NormalizedRequest pqtype.NullRawMessage `db:"normalized_request" json:"normalized_request"` + NormalizedResponse pqtype.NullRawMessage `db:"normalized_response" json:"normalized_response"` + Usage pqtype.NullRawMessage `db:"usage" json:"usage"` + Attempts pqtype.NullRawMessage `db:"attempts" json:"attempts"` + Error pqtype.NullRawMessage `db:"error" json:"error"` + Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"` + FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` +} + +func (q *sqlQuerier) InsertChatDebugStep(ctx context.Context, arg InsertChatDebugStepParams) (ChatDebugStep, error) { + row := q.db.QueryRowContext(ctx, insertChatDebugStep, + arg.RunID, + arg.StepNumber, + arg.Operation, + arg.Status, + arg.HistoryTipMessageID, + arg.AssistantMessageID, + arg.NormalizedRequest, + arg.NormalizedResponse, + arg.Usage, + arg.Attempts, + arg.Error, + arg.Metadata, + arg.StartedAt, + arg.UpdatedAt, + arg.FinishedAt, + arg.ChatID, + ) + var i ChatDebugStep + err := row.Scan( + &i.ID, + &i.RunID, + &i.ChatID, + &i.StepNumber, + &i.Operation, + &i.Status, + &i.HistoryTipMessageID, + &i.AssistantMessageID, + &i.NormalizedRequest, + &i.NormalizedResponse, + &i.Usage, + &i.Attempts, + &i.Error, + &i.Metadata, + &i.StartedAt, + &i.UpdatedAt, + &i.FinishedAt, + ) + return i, err +} + +const updateChatDebugRun = `-- name: UpdateChatDebugRun :one +UPDATE chat_debug_runs +SET + root_chat_id = COALESCE($1::uuid, root_chat_id), + parent_chat_id = COALESCE($2::uuid, parent_chat_id), + model_config_id = COALESCE($3::uuid, model_config_id), + trigger_message_id = COALESCE($4::bigint, trigger_message_id), + history_tip_message_id = COALESCE($5::bigint, history_tip_message_id), + status = COALESCE($6::text, status), + provider = COALESCE($7::text, provider), + model = COALESCE($8::text, model), + summary = COALESCE($9::jsonb, summary), + finished_at = COALESCE($10::timestamptz, finished_at), + updated_at = NOW() +WHERE id = $11::uuid + AND chat_id = $12::uuid +RETURNING id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at +` + +type UpdateChatDebugRunParams struct { + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + TriggerMessageID sql.NullInt64 `db:"trigger_message_id" json:"trigger_message_id"` + HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"` + Status sql.NullString `db:"status" json:"status"` + Provider sql.NullString `db:"provider" json:"provider"` + Model sql.NullString `db:"model" json:"model"` + Summary pqtype.NullRawMessage `db:"summary" json:"summary"` + FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"` + ID uuid.UUID `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` +} + +// Uses COALESCE so that passing NULL from Go means "keep the +// existing value." This is intentional: debug rows follow a +// write-once-finalize pattern where fields are set at creation +// or finalization and never cleared back to NULL. +func (q *sqlQuerier) UpdateChatDebugRun(ctx context.Context, arg UpdateChatDebugRunParams) (ChatDebugRun, error) { + row := q.db.QueryRowContext(ctx, updateChatDebugRun, + arg.RootChatID, + arg.ParentChatID, + arg.ModelConfigID, + arg.TriggerMessageID, + arg.HistoryTipMessageID, + arg.Status, + arg.Provider, + arg.Model, + arg.Summary, + arg.FinishedAt, + arg.ID, + arg.ChatID, + ) + var i ChatDebugRun + err := row.Scan( + &i.ID, + &i.ChatID, + &i.RootChatID, + &i.ParentChatID, + &i.ModelConfigID, + &i.TriggerMessageID, + &i.HistoryTipMessageID, + &i.Kind, + &i.Status, + &i.Provider, + &i.Model, + &i.Summary, + &i.StartedAt, + &i.UpdatedAt, + &i.FinishedAt, + ) + return i, err +} + +const updateChatDebugStep = `-- name: UpdateChatDebugStep :one +UPDATE chat_debug_steps +SET + status = COALESCE($1::text, status), + history_tip_message_id = COALESCE($2::bigint, history_tip_message_id), + assistant_message_id = COALESCE($3::bigint, assistant_message_id), + normalized_request = COALESCE($4::jsonb, normalized_request), + normalized_response = COALESCE($5::jsonb, normalized_response), + usage = COALESCE($6::jsonb, usage), + attempts = COALESCE($7::jsonb, attempts), + error = COALESCE($8::jsonb, error), + metadata = COALESCE($9::jsonb, metadata), + finished_at = COALESCE($10::timestamptz, finished_at), + updated_at = NOW() +WHERE id = $11::uuid + AND chat_id = $12::uuid +RETURNING id, run_id, chat_id, step_number, operation, status, history_tip_message_id, assistant_message_id, normalized_request, normalized_response, usage, attempts, error, metadata, started_at, updated_at, finished_at +` + +type UpdateChatDebugStepParams struct { + Status sql.NullString `db:"status" json:"status"` + HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"` + AssistantMessageID sql.NullInt64 `db:"assistant_message_id" json:"assistant_message_id"` + NormalizedRequest pqtype.NullRawMessage `db:"normalized_request" json:"normalized_request"` + NormalizedResponse pqtype.NullRawMessage `db:"normalized_response" json:"normalized_response"` + Usage pqtype.NullRawMessage `db:"usage" json:"usage"` + Attempts pqtype.NullRawMessage `db:"attempts" json:"attempts"` + Error pqtype.NullRawMessage `db:"error" json:"error"` + Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"` + FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"` + ID uuid.UUID `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` +} + +// Uses COALESCE so that passing NULL from Go means "keep the +// existing value." This is intentional: debug rows follow a +// write-once-finalize pattern where fields are set at creation +// or finalization and never cleared back to NULL. +func (q *sqlQuerier) UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebugStepParams) (ChatDebugStep, error) { + row := q.db.QueryRowContext(ctx, updateChatDebugStep, + arg.Status, + arg.HistoryTipMessageID, + arg.AssistantMessageID, + arg.NormalizedRequest, + arg.NormalizedResponse, + arg.Usage, + arg.Attempts, + arg.Error, + arg.Metadata, + arg.FinishedAt, + arg.ID, + arg.ChatID, + ) + var i ChatDebugStep + err := row.Scan( + &i.ID, + &i.RunID, + &i.ChatID, + &i.StepNumber, + &i.Operation, + &i.Status, + &i.HistoryTipMessageID, + &i.AssistantMessageID, + &i.NormalizedRequest, + &i.NormalizedResponse, + &i.Usage, + &i.Attempts, + &i.Error, + &i.Metadata, + &i.StartedAt, + &i.UpdatedAt, + &i.FinishedAt, + ) + return i, err +} + const deleteOldChatFiles = `-- name: DeleteOldChatFiles :execrows WITH kept_file_ids AS ( -- NOTE: This uses updated_at as a proxy for archive time @@ -19168,6 +19745,21 @@ func (q *sqlQuerier) GetApplicationName(ctx context.Context) (string, error) { return value, err } +const getChatDebugLoggingAllowUsers = `-- name: GetChatDebugLoggingAllowUsers :one +SELECT + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_debug_logging_allow_users'), false) :: boolean AS allow_users +` + +// GetChatDebugLoggingAllowUsers returns the runtime admin setting that +// allows users to opt into chat debug logging when the deployment does +// not already force debug logging on globally. +func (q *sqlQuerier) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) { + row := q.db.QueryRowContext(ctx, getChatDebugLoggingAllowUsers) + var allow_users bool + err := row.Scan(&allow_users) + return allow_users, err +} + const getChatDesktopEnabled = `-- name: GetChatDesktopEnabled :one SELECT COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop @@ -19479,6 +20071,30 @@ func (q *sqlQuerier) UpsertApplicationName(ctx context.Context, value string) er return err } +const upsertChatDebugLoggingAllowUsers = `-- name: UpsertChatDebugLoggingAllowUsers :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'agents_chat_debug_logging_allow_users', + CASE + WHEN $1::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN $1::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'agents_chat_debug_logging_allow_users' +` + +// UpsertChatDebugLoggingAllowUsers updates the runtime admin setting that +// allows users to opt into chat debug logging. +func (q *sqlQuerier) UpsertChatDebugLoggingAllowUsers(ctx context.Context, allowUsers bool) error { + _, err := q.db.ExecContext(ctx, upsertChatDebugLoggingAllowUsers, allowUsers) + return err +} + const upsertChatDesktopEnabled = `-- name: UpsertChatDesktopEnabled :exec INSERT INTO site_configs (key, value) VALUES ( @@ -23733,6 +24349,23 @@ func (q *sqlQuerier) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UU return chat_custom_prompt, err } +const getUserChatDebugLoggingEnabled = `-- name: GetUserChatDebugLoggingEnabled :one +SELECT + COALESCE(( + SELECT value = 'true' + FROM user_configs + WHERE user_id = $1 + AND key = 'chat_debug_logging_enabled' + ), false) :: boolean AS debug_logging_enabled +` + +func (q *sqlQuerier) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) { + row := q.db.QueryRowContext(ctx, getUserChatDebugLoggingEnabled, userID) + var debug_logging_enabled bool + err := row.Scan(&debug_logging_enabled) + return debug_logging_enabled, err +} + const getUserCount = `-- name: GetUserCount :one SELECT COUNT(*) @@ -24727,6 +25360,35 @@ func (q *sqlQuerier) UpdateUserThemePreference(ctx context.Context, arg UpdateUs return i, err } +const upsertUserChatDebugLoggingEnabled = `-- name: UpsertUserChatDebugLoggingEnabled :exec +INSERT INTO user_configs (user_id, key, value) +VALUES ( + $1, + 'chat_debug_logging_enabled', + CASE + WHEN $2::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT ON CONSTRAINT user_configs_pkey +DO UPDATE SET value = CASE + WHEN $2::bool THEN 'true' + ELSE 'false' +END +WHERE user_configs.user_id = $1 + AND user_configs.key = 'chat_debug_logging_enabled' +` + +type UpsertUserChatDebugLoggingEnabledParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + DebugLoggingEnabled bool `db:"debug_logging_enabled" json:"debug_logging_enabled"` +} + +func (q *sqlQuerier) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg UpsertUserChatDebugLoggingEnabledParams) error { + _, err := q.db.ExecContext(ctx, upsertUserChatDebugLoggingEnabled, arg.UserID, arg.DebugLoggingEnabled) + return err +} + const validateUserIDs = `-- name: ValidateUserIDs :one WITH input AS ( SELECT diff --git a/coderd/database/queries/chatdebug.sql b/coderd/database/queries/chatdebug.sql new file mode 100644 index 0000000000..1fea29fa0f --- /dev/null +++ b/coderd/database/queries/chatdebug.sql @@ -0,0 +1,205 @@ +-- name: InsertChatDebugRun :one +INSERT INTO chat_debug_runs ( + chat_id, + root_chat_id, + parent_chat_id, + model_config_id, + trigger_message_id, + history_tip_message_id, + kind, + status, + provider, + model, + summary, + started_at, + updated_at, + finished_at +) +VALUES ( + @chat_id::uuid, + sqlc.narg('root_chat_id')::uuid, + sqlc.narg('parent_chat_id')::uuid, + sqlc.narg('model_config_id')::uuid, + sqlc.narg('trigger_message_id')::bigint, + sqlc.narg('history_tip_message_id')::bigint, + @kind::text, + @status::text, + sqlc.narg('provider')::text, + sqlc.narg('model')::text, + COALESCE(sqlc.narg('summary')::jsonb, '{}'::jsonb), + COALESCE(sqlc.narg('started_at')::timestamptz, NOW()), + COALESCE(sqlc.narg('updated_at')::timestamptz, NOW()), + sqlc.narg('finished_at')::timestamptz +) +RETURNING *; + +-- name: UpdateChatDebugRun :one +-- Uses COALESCE so that passing NULL from Go means "keep the +-- existing value." This is intentional: debug rows follow a +-- write-once-finalize pattern where fields are set at creation +-- or finalization and never cleared back to NULL. +UPDATE chat_debug_runs +SET + root_chat_id = COALESCE(sqlc.narg('root_chat_id')::uuid, root_chat_id), + parent_chat_id = COALESCE(sqlc.narg('parent_chat_id')::uuid, parent_chat_id), + model_config_id = COALESCE(sqlc.narg('model_config_id')::uuid, model_config_id), + trigger_message_id = COALESCE(sqlc.narg('trigger_message_id')::bigint, trigger_message_id), + history_tip_message_id = COALESCE(sqlc.narg('history_tip_message_id')::bigint, history_tip_message_id), + status = COALESCE(sqlc.narg('status')::text, status), + provider = COALESCE(sqlc.narg('provider')::text, provider), + model = COALESCE(sqlc.narg('model')::text, model), + summary = COALESCE(sqlc.narg('summary')::jsonb, summary), + finished_at = COALESCE(sqlc.narg('finished_at')::timestamptz, finished_at), + updated_at = NOW() +WHERE id = @id::uuid + AND chat_id = @chat_id::uuid +RETURNING *; + +-- name: InsertChatDebugStep :one +INSERT INTO chat_debug_steps ( + run_id, + chat_id, + step_number, + operation, + status, + history_tip_message_id, + assistant_message_id, + normalized_request, + normalized_response, + usage, + attempts, + error, + metadata, + started_at, + updated_at, + finished_at +) +SELECT + @run_id::uuid, + run.chat_id, + @step_number::int, + @operation::text, + @status::text, + sqlc.narg('history_tip_message_id')::bigint, + sqlc.narg('assistant_message_id')::bigint, + COALESCE(sqlc.narg('normalized_request')::jsonb, '{}'::jsonb), + sqlc.narg('normalized_response')::jsonb, + sqlc.narg('usage')::jsonb, + COALESCE(sqlc.narg('attempts')::jsonb, '[]'::jsonb), + sqlc.narg('error')::jsonb, + COALESCE(sqlc.narg('metadata')::jsonb, '{}'::jsonb), + COALESCE(sqlc.narg('started_at')::timestamptz, NOW()), + COALESCE(sqlc.narg('updated_at')::timestamptz, NOW()), + sqlc.narg('finished_at')::timestamptz +FROM chat_debug_runs run +WHERE run.id = @run_id::uuid + AND run.chat_id = @chat_id::uuid +RETURNING *; + +-- name: UpdateChatDebugStep :one +-- Uses COALESCE so that passing NULL from Go means "keep the +-- existing value." This is intentional: debug rows follow a +-- write-once-finalize pattern where fields are set at creation +-- or finalization and never cleared back to NULL. +UPDATE chat_debug_steps +SET + status = COALESCE(sqlc.narg('status')::text, status), + history_tip_message_id = COALESCE(sqlc.narg('history_tip_message_id')::bigint, history_tip_message_id), + assistant_message_id = COALESCE(sqlc.narg('assistant_message_id')::bigint, assistant_message_id), + normalized_request = COALESCE(sqlc.narg('normalized_request')::jsonb, normalized_request), + normalized_response = COALESCE(sqlc.narg('normalized_response')::jsonb, normalized_response), + usage = COALESCE(sqlc.narg('usage')::jsonb, usage), + attempts = COALESCE(sqlc.narg('attempts')::jsonb, attempts), + error = COALESCE(sqlc.narg('error')::jsonb, error), + metadata = COALESCE(sqlc.narg('metadata')::jsonb, metadata), + finished_at = COALESCE(sqlc.narg('finished_at')::timestamptz, finished_at), + updated_at = NOW() +WHERE id = @id::uuid + AND chat_id = @chat_id::uuid +RETURNING *; + +-- name: GetChatDebugRunsByChatID :many +-- Returns the most recent debug runs for a chat, ordered newest-first. +-- Callers must supply an explicit limit to avoid unbounded result sets. +SELECT * +FROM chat_debug_runs +WHERE chat_id = @chat_id::uuid +ORDER BY started_at DESC, id DESC +LIMIT @limit_val::int; + +-- name: GetChatDebugRunByID :one +SELECT * +FROM chat_debug_runs +WHERE id = @id::uuid; + +-- name: GetChatDebugStepsByRunID :many +SELECT * +FROM chat_debug_steps +WHERE run_id = @run_id::uuid +ORDER BY step_number ASC, started_at ASC; + +-- name: DeleteChatDebugDataByChatID :execrows +DELETE FROM chat_debug_runs +WHERE chat_id = @chat_id::uuid; + +-- name: DeleteChatDebugDataAfterMessageID :execrows +WITH affected_runs AS ( + SELECT DISTINCT run.id + FROM chat_debug_runs run + WHERE run.chat_id = @chat_id::uuid + AND ( + run.history_tip_message_id > @message_id::bigint + OR run.trigger_message_id > @message_id::bigint + ) + + UNION + + SELECT DISTINCT step.run_id AS id + FROM chat_debug_steps step + WHERE step.chat_id = @chat_id::uuid + AND ( + step.assistant_message_id > @message_id::bigint + OR step.history_tip_message_id > @message_id::bigint + ) +) +DELETE FROM chat_debug_runs +WHERE chat_id = @chat_id::uuid + AND id IN (SELECT id FROM affected_runs); + +-- name: FinalizeStaleChatDebugRows :one +-- Marks orphaned in-progress rows as interrupted so they do not stay +-- in a non-terminal state forever. The NOT IN list must match the +-- terminal statuses defined by ChatDebugStatus in codersdk/chats.go. +-- +-- The steps CTE also catches steps whose parent run was just finalized +-- (via run_id IN), because PostgreSQL data-modifying CTEs share the +-- same snapshot and cannot see each other's row updates. Without this, +-- a step with a recent updated_at would survive its run's finalization +-- and remain in 'in_progress' state permanently. +WITH finalized_runs AS ( + UPDATE chat_debug_runs + SET + status = 'interrupted', + updated_at = NOW(), + finished_at = NOW() + WHERE updated_at < @updated_before::timestamptz + AND finished_at IS NULL + AND status NOT IN ('completed', 'error', 'interrupted') + RETURNING id +), finalized_steps AS ( + UPDATE chat_debug_steps + SET + status = 'interrupted', + updated_at = NOW(), + finished_at = NOW() + WHERE ( + updated_at < @updated_before::timestamptz + OR run_id IN (SELECT id FROM finalized_runs) + ) + AND finished_at IS NULL + AND status NOT IN ('completed', 'error', 'interrupted') + RETURNING 1 +) +SELECT + (SELECT COUNT(*) FROM finalized_runs)::bigint AS runs_finalized, + (SELECT COUNT(*) FROM finalized_steps)::bigint AS steps_finalized; diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index eccd636abd..ac7df002b2 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -179,6 +179,31 @@ SET value = CASE END WHERE site_configs.key = 'agents_desktop_enabled'; +-- GetChatDebugLoggingAllowUsers returns the runtime admin setting that +-- allows users to opt into chat debug logging when the deployment does +-- not already force debug logging on globally. +-- name: GetChatDebugLoggingAllowUsers :one +SELECT + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_debug_logging_allow_users'), false) :: boolean AS allow_users; + +-- UpsertChatDebugLoggingAllowUsers updates the runtime admin setting that +-- allows users to opt into chat debug logging. +-- name: UpsertChatDebugLoggingAllowUsers :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'agents_chat_debug_logging_allow_users', + CASE + WHEN sqlc.arg(allow_users)::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN sqlc.arg(allow_users)::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'agents_chat_debug_logging_allow_users'; + -- GetChatTemplateAllowlist returns the JSON-encoded template allowlist. -- Returns an empty string when no allowlist has been configured (all templates allowed). -- name: GetChatTemplateAllowlist :one diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 85ff08b9c6..d2ff9d87b8 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -213,6 +213,33 @@ RETURNING *; -- name: DeleteUserChatCompactionThreshold :exec DELETE FROM user_configs WHERE user_id = @user_id AND key = @key; +-- name: GetUserChatDebugLoggingEnabled :one +SELECT + COALESCE(( + SELECT value = 'true' + FROM user_configs + WHERE user_id = @user_id + AND key = 'chat_debug_logging_enabled' + ), false) :: boolean AS debug_logging_enabled; + +-- name: UpsertUserChatDebugLoggingEnabled :exec +INSERT INTO user_configs (user_id, key, value) +VALUES ( + @user_id, + 'chat_debug_logging_enabled', + CASE + WHEN sqlc.arg(debug_logging_enabled)::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT ON CONSTRAINT user_configs_pkey +DO UPDATE SET value = CASE + WHEN sqlc.arg(debug_logging_enabled)::bool THEN 'true' + ELSE 'false' +END +WHERE user_configs.user_id = @user_id + AND user_configs.key = 'chat_debug_logging_enabled'; + -- name: GetUserTaskNotificationAlertDismissed :one SELECT value::boolean as task_notification_alert_dismissed diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 0a6bd3fa7b..b3b2a99201 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -15,6 +15,8 @@ const ( UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); UniqueBoundaryUsageStatsPkey UniqueConstraint = "boundary_usage_stats_pkey" // ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); + UniqueChatDebugRunsPkey UniqueConstraint = "chat_debug_runs_pkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id); + UniqueChatDebugStepsPkey UniqueConstraint = "chat_debug_steps_pkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_pkey PRIMARY KEY (id); UniqueChatDiffStatusesPkey UniqueConstraint = "chat_diff_statuses_pkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id); UniqueChatFileLinksChatIDFileIDKey UniqueConstraint = "chat_file_links_chat_id_file_id_key" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_file_id_key UNIQUE (chat_id, file_id); UniqueChatFilesPkey UniqueConstraint = "chat_files_pkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id); @@ -128,6 +130,8 @@ const ( UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + UniqueIndexChatDebugRunsIDChat UniqueConstraint = "idx_chat_debug_runs_id_chat" // CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id); + UniqueIndexChatDebugStepsRunStep UniqueConstraint = "idx_chat_debug_steps_run_step" // CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btree (run_id, step_number); UniqueIndexChatModelConfigsSingleDefault UniqueConstraint = "idx_chat_model_configs_single_default" // CREATE UNIQUE INDEX idx_chat_model_configs_single_default ON chat_model_configs USING btree ((1)) WHERE ((is_default = true) AND (deleted = false)); UniqueIndexConnectionLogsConnectionIDWorkspaceIDAgentName UniqueConstraint = "idx_connection_logs_connection_id_workspace_id_agent_name" // CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name); UniqueIndexCustomRolesNameLowerOrganizationID UniqueConstraint = "idx_custom_roles_name_lower_organization_id" // CREATE UNIQUE INDEX idx_custom_roles_name_lower_organization_id ON custom_roles USING btree (lower(name), COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid)); diff --git a/codersdk/chats.go b/codersdk/chats.go index 6d1c4a2175..c4f176f7b4 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -549,6 +549,135 @@ type UpdateChatDesktopEnabledRequest struct { EnableDesktop bool `json:"enable_desktop"` } +// ChatDebugLoggingAdminSettings describes the runtime admin setting +// that allows users to opt into chat debug logging. +type ChatDebugLoggingAdminSettings struct { + AllowUsers bool `json:"allow_users"` + ForcedByDeployment bool `json:"forced_by_deployment"` +} + +// UserChatDebugLoggingSettings describes whether debug logging is +// active for the current user and whether the user may control it. +type UserChatDebugLoggingSettings struct { + DebugLoggingEnabled bool `json:"debug_logging_enabled"` + UserToggleAllowed bool `json:"user_toggle_allowed"` + ForcedByDeployment bool `json:"forced_by_deployment"` +} + +// UpdateChatDebugLoggingAllowUsersRequest is the admin request to +// toggle whether users may opt into chat debug logging. +type UpdateChatDebugLoggingAllowUsersRequest struct { + AllowUsers bool `json:"allow_users"` +} + +// UpdateUserChatDebugLoggingRequest is the per-user request to +// opt into or out of chat debug logging. +type UpdateUserChatDebugLoggingRequest struct { + DebugLoggingEnabled bool `json:"debug_logging_enabled"` +} + +// ChatDebugStatus enumerates the lifecycle states shared by debug +// runs and steps. These values must match the literals used in +// FinalizeStaleChatDebugRows and all insert/update callers. +type ChatDebugStatus string + +const ( + ChatDebugStatusInProgress ChatDebugStatus = "in_progress" + ChatDebugStatusCompleted ChatDebugStatus = "completed" + ChatDebugStatusError ChatDebugStatus = "error" + ChatDebugStatusInterrupted ChatDebugStatus = "interrupted" +) + +// ChatDebugTerminalStatuses returns the statuses that represent a +// finished lifecycle. The SQL query FinalizeStaleChatDebugRows uses +// a NOT IN list that must match these exactly. A test in +// coderd/database asserts this alignment at CI time. +func ChatDebugTerminalStatuses() []ChatDebugStatus { + return []ChatDebugStatus{ + ChatDebugStatusCompleted, + ChatDebugStatusError, + ChatDebugStatusInterrupted, + } +} + +// ChatDebugRunKind labels the operation that produced the debug +// run. Each value corresponds to a distinct call-site in chatd. +type ChatDebugRunKind string + +const ( + ChatDebugRunKindChatTurn ChatDebugRunKind = "chat_turn" + ChatDebugRunKindTitleGeneration ChatDebugRunKind = "title_generation" + ChatDebugRunKindQuickgen ChatDebugRunKind = "quickgen" + ChatDebugRunKindCompaction ChatDebugRunKind = "compaction" +) + +// ChatDebugStepOperation labels the model interaction type for a +// debug step. +type ChatDebugStepOperation string + +const ( + ChatDebugStepOperationStream ChatDebugStepOperation = "stream" + ChatDebugStepOperationGenerate ChatDebugStepOperation = "generate" +) + +// ChatDebugRunSummary is a lightweight run entry for list endpoints. +type ChatDebugRunSummary struct { + ID uuid.UUID `json:"id" format:"uuid"` + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + Kind ChatDebugRunKind `json:"kind"` + Status ChatDebugStatus `json:"status"` + Provider *string `json:"provider,omitempty"` + Model *string `json:"model,omitempty"` + Summary map[string]any `json:"summary"` + StartedAt time.Time `json:"started_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"` +} + +// ChatDebugRun is the detailed run response including steps. +// This type is consumed by the run-detail handler added in a later +// PR in this stack; it is forward-declared here so that all SDK +// types live in the same schema-layer commit. +type ChatDebugRun struct { + ID uuid.UUID `json:"id" format:"uuid"` + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` + ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` + ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` + TriggerMessageID *int64 `json:"trigger_message_id,omitempty"` + HistoryTipMessageID *int64 `json:"history_tip_message_id,omitempty"` + Kind ChatDebugRunKind `json:"kind"` + Status ChatDebugStatus `json:"status"` + Provider *string `json:"provider,omitempty"` + Model *string `json:"model,omitempty"` + Summary map[string]any `json:"summary"` + StartedAt time.Time `json:"started_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"` + Steps []ChatDebugStep `json:"steps"` +} + +// ChatDebugStep is a single step within a debug run. +type ChatDebugStep struct { + ID uuid.UUID `json:"id" format:"uuid"` + RunID uuid.UUID `json:"run_id" format:"uuid"` + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + StepNumber int32 `json:"step_number"` + Operation ChatDebugStepOperation `json:"operation"` + Status ChatDebugStatus `json:"status"` + HistoryTipMessageID *int64 `json:"history_tip_message_id,omitempty"` + AssistantMessageID *int64 `json:"assistant_message_id,omitempty"` + NormalizedRequest map[string]any `json:"normalized_request"` + NormalizedResponse map[string]any `json:"normalized_response,omitempty"` + Usage map[string]any `json:"usage,omitempty"` + Attempts []map[string]any `json:"attempts"` + Error map[string]any `json:"error,omitempty"` + Metadata map[string]any `json:"metadata"` + StartedAt time.Time `json:"started_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"` +} + // DefaultChatWorkspaceTTL is the default TTL for chat workspaces. // Zero means disabled — the template's own autostop setting applies. const DefaultChatWorkspaceTTL = 0 diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 1c975c19f3..b2c05a700a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3624,6 +3624,16 @@ Write out the current server config as YAML to stdout.`, YAML: "acquireBatchSize", Hidden: true, // Hidden because most operators should not need to modify this. }, + { + Name: "Chat: Debug Logging Enabled", + Description: "Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings.", + Flag: "chat-debug-logging-enabled", + Env: "CODER_CHAT_DEBUG_LOGGING_ENABLED", + Value: &c.AI.Chat.DebugLoggingEnabled, + Default: "false", + Group: &deploymentGroupChat, + YAML: "debugLoggingEnabled", + }, // AI Bridge Options { Name: "AI Bridge Enabled", @@ -4090,7 +4100,8 @@ type AIBridgeProxyConfig struct { } type ChatConfig struct { - AcquireBatchSize serpent.Int64 `json:"acquire_batch_size" typescript:",notnull"` + AcquireBatchSize serpent.Int64 `json:"acquire_batch_size" typescript:",notnull"` + DebugLoggingEnabled serpent.Bool `json:"debug_logging_enabled" typescript:",notnull"` } type AIConfig struct { diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 949661f83f..90307b8530 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -209,7 +209,8 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "structured_logging": true }, "chat": { - "acquire_batch_size": 0 + "acquire_batch_size": 0, + "debug_logging_enabled": true } }, "allow_workspace_renames": true, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 302b9bbd37..563c33f971 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1240,7 +1240,8 @@ "structured_logging": true }, "chat": { - "acquire_batch_size": 0 + "acquire_batch_size": 0, + "debug_logging_enabled": true } } ``` @@ -2021,15 +2022,17 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { - "acquire_batch_size": 0 + "acquire_batch_size": 0, + "debug_logging_enabled": true } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------------|---------|----------|--------------|-------------| -| `acquire_batch_size` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------|---------|----------|--------------|-------------| +| `acquire_batch_size` | integer | false | | | +| `debug_logging_enabled` | boolean | false | | | ## codersdk.ChatRetentionDaysResponse @@ -3261,7 +3264,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "structured_logging": true }, "chat": { - "acquire_batch_size": 0 + "acquire_batch_size": 0, + "debug_logging_enabled": true } }, "allow_workspace_renames": true, @@ -3839,7 +3843,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "structured_logging": true }, "chat": { - "acquire_batch_size": 0 + "acquire_batch_size": 0, + "debug_logging_enabled": true } }, "allow_workspace_renames": true, diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index e282f1ad19..43e293e56a 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1702,6 +1702,17 @@ How often to reconcile workspace prebuilds state. Hide AI tasks from the dashboard. +### --chat-debug-logging-enabled + +| | | +|-------------|------------------------------------------------| +| Type | bool | +| Environment | $CODER_CHAT_DEBUG_LOGGING_ENABLED | +| YAML | chat.debugLoggingEnabled | +| Default | false | + +Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings. + ### --aibridge-enabled | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 5e2a7d4290..c74f26fd7f 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -212,6 +212,13 @@ AI BRIDGE PROXY OPTIONS: certificates not trusted by the system. If not provided, the system certificate pool is used. +CHAT OPTIONS: +Configure the background chat processing daemon. + + --chat-debug-logging-enabled bool, $CODER_CHAT_DEBUG_LOGGING_ENABLED (default: false) + Force chat debug logging on for every chat, bypassing the runtime + admin and user opt-in settings. + CLIENT OPTIONS: These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e55158ecb1..863cd0222b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1237,6 +1237,7 @@ export const ChatCompactionThresholdKeyPrefix = // From codersdk/deployment.go export interface ChatConfig { readonly acquire_batch_size: number; + readonly debug_logging_enabled: boolean; } // From codersdk/chats.go @@ -1364,6 +1365,127 @@ export interface ChatCostUsersResponse { readonly users: readonly ChatCostUserRollup[]; } +// From codersdk/chats.go +/** + * ChatDebugLoggingAdminSettings describes the runtime admin setting + * that allows users to opt into chat debug logging. + */ +export interface ChatDebugLoggingAdminSettings { + readonly allow_users: boolean; + readonly forced_by_deployment: boolean; +} + +// From codersdk/chats.go +/** + * ChatDebugRun is the detailed run response including steps. + * This type is consumed by the run-detail handler added in a later + * PR in this stack; it is forward-declared here so that all SDK + * types live in the same schema-layer commit. + */ +export interface ChatDebugRun { + readonly id: string; + readonly chat_id: string; + readonly root_chat_id?: string; + readonly parent_chat_id?: string; + readonly model_config_id?: string; + readonly trigger_message_id?: number; + readonly history_tip_message_id?: number; + readonly kind: ChatDebugRunKind; + readonly status: ChatDebugStatus; + readonly provider?: string; + readonly model?: string; + // empty interface{} type, falling back to unknown + readonly summary: Record; + readonly started_at: string; + readonly updated_at: string; + readonly finished_at?: string; + readonly steps: readonly ChatDebugStep[]; +} + +// From codersdk/chats.go +export type ChatDebugRunKind = + | "chat_turn" + | "compaction" + | "quickgen" + | "title_generation"; + +export const ChatDebugRunKinds: ChatDebugRunKind[] = [ + "chat_turn", + "compaction", + "quickgen", + "title_generation", +]; + +// From codersdk/chats.go +/** + * ChatDebugRunSummary is a lightweight run entry for list endpoints. + */ +export interface ChatDebugRunSummary { + readonly id: string; + readonly chat_id: string; + readonly kind: ChatDebugRunKind; + readonly status: ChatDebugStatus; + readonly provider?: string; + readonly model?: string; + // empty interface{} type, falling back to unknown + readonly summary: Record; + readonly started_at: string; + readonly updated_at: string; + readonly finished_at?: string; +} + +// From codersdk/chats.go +export type ChatDebugStatus = + | "completed" + | "error" + | "in_progress" + | "interrupted"; + +export const ChatDebugStatuses: ChatDebugStatus[] = [ + "completed", + "error", + "in_progress", + "interrupted", +]; + +// From codersdk/chats.go +/** + * ChatDebugStep is a single step within a debug run. + */ +export interface ChatDebugStep { + readonly id: string; + readonly run_id: string; + readonly chat_id: string; + readonly step_number: number; + readonly operation: ChatDebugStepOperation; + readonly status: ChatDebugStatus; + readonly history_tip_message_id?: number; + readonly assistant_message_id?: number; + // empty interface{} type, falling back to unknown + readonly normalized_request: Record; + // empty interface{} type, falling back to unknown + readonly normalized_response?: Record; + // empty interface{} type, falling back to unknown + readonly usage?: Record; + // empty interface{} type, falling back to unknown + readonly attempts: readonly Record[]; + // empty interface{} type, falling back to unknown + readonly error?: Record; + // empty interface{} type, falling back to unknown + readonly metadata: Record; + readonly started_at: string; + readonly updated_at: string; + readonly finished_at?: string; +} + +// From codersdk/chats.go +export type ChatDebugStepOperation = "generate" | "stream"; + +export const ChatDebugStepOperations: ChatDebugStepOperation[] = [ + "generate", + "stream", +]; + // From codersdk/chats.go /** * ChatDesktopEnabledResponse is the response for getting the desktop setting. @@ -7362,6 +7484,15 @@ export interface UpdateAppearanceConfig { readonly announcement_banners: readonly BannerConfig[]; } +// From codersdk/chats.go +/** + * UpdateChatDebugLoggingAllowUsersRequest is the admin request to + * toggle whether users may opt into chat debug logging. + */ +export interface UpdateChatDebugLoggingAllowUsersRequest { + readonly allow_users: boolean; +} + // From codersdk/chats.go /** * UpdateChatDesktopEnabledRequest is the request to update the desktop setting. @@ -7665,6 +7796,15 @@ export interface UpdateUserChatCompactionThresholdRequest { readonly threshold_percent: number; } +// From codersdk/chats.go +/** + * UpdateUserChatDebugLoggingRequest is the per-user request to + * opt into or out of chat debug logging. + */ +export interface UpdateUserChatDebugLoggingRequest { + readonly debug_logging_enabled: boolean; +} + // From codersdk/notifications.go export interface UpdateUserNotificationPreferences { readonly template_disabled_map: Record; @@ -7964,6 +8104,17 @@ export interface UserChatCustomPrompt { readonly custom_prompt: string; } +// From codersdk/chats.go +/** + * UserChatDebugLoggingSettings describes whether debug logging is + * active for the current user and whether the user may control it. + */ +export interface UserChatDebugLoggingSettings { + readonly debug_logging_enabled: boolean; + readonly user_toggle_allowed: boolean; + readonly forced_by_deployment: boolean; +} + // From codersdk/chats.go /** * UserChatProviderConfig is a summary of a provider that allows