Files
coder/coderd/x/chatd/chattool/chattool.go
T
Ethan ef0151601e feat: report insufficient quota build failures in chat tools (#24956)
## Summary

When a workspace build fails because the user is over their group quota,
the chat tools currently surface the failure as a bare `"workspace build
failed: insufficient quota"` string with no machine-readable error code
and no visibility into the user's current usage. Agents and the UI
cannot distinguish quota failures from any other Terraform error, so
users see an opaque message and have no clear path to recovery.

This PR tags quota failures with a typed error code at the source and
propagates it through the chat tool layer so callers can react to it
explicitly.

Relates to CODAGT-20

## Changes

**Provisioner runner**

- Add `InsufficientQuotaErrorCode = "INSUFFICIENT_QUOTA"` and set it
explicitly at the `commitQuota` failure site via a new
`failedWorkspaceBuildfCode` helper, so `provisioner_jobs.error_code` is
populated only on the genuine quota path. The substring matcher used for
externally produced sentinels (e.g. `"missing parameter"`, `"required
template variables"`) is intentionally not extended; provider errors
that happen to mention "insufficient quota" stay classified as generic
build failures.

**SDK and API contract**

- Add `JobErrorCodeInsufficientQuota` and a
`JobIsInsufficientQuotaErrorCode` helper to `codersdk`.
- Extend the swagger `enums` tag on `ProvisionerJob.ErrorCode` to
include `INSUFFICIENT_QUOTA`.
- Regenerate `coderd/apidoc`, `docs/reference/api/*`, and
`site/src/api/typesGenerated.ts`.

**chattool create_workspace / start_workspace**

- `waitForBuild` now returns a typed `*workspaceBuildError` carrying
both the message and the `JobErrorCode`, instead of a bare error string.
- New `quotaerror.go` introduces a structured `quotaErrorResult` (with
`error_code`, `title`, `message`, `build_id`, and optional `quota`) and
a best-effort `workspaceQuotaDetails` lookup that wraps owner
authorization internally and fetches `credits_consumed` and `budget`
from the database. Quota lookup failures (including authorization
failures) never block the failure payload.
- On quota-coded build failures, both `create_workspace` and
`start_workspace` now return the structured response (with the recovery
guidance inlined into `message`) instead of the bare `"insufficient
quota"` string. This applies to all three failure paths: post-creation,
an in-progress existing build, and a freshly triggered start build.
Non-quota build failures continue to use the existing
`buildToolResponse` / `newBuildError` path.
- Owner authorization is wrapped only on the call sites that need it
(the `CreateFn` and `StartFn` invocations and the quota-detail lookup),
so idempotent fast paths (already running, already in progress,
existing-workspace early returns) do not pay for an extra RBAC
round-trip or fail when role lookup is transient.

## Out of scope

- No changes to quota math, allowances, or bypass behavior.
- No automatic retries.
- No new quota-inspection tools and no changes to MCP
`coder_create_workspace` (which returns immediately and never observed
the build outcome here).
- No frontend UI changes; those will land in a follow-up PR that
consumes the new `INSUFFICIENT_QUOTA` code.
2026-05-07 15:01:58 +10:00

119 lines
3.2 KiB
Go

package chattool
import (
"encoding/json"
"unicode/utf8"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk"
)
func marshalToolResponse(result any) fantasy.ToolResponse {
data, err := json.Marshal(result)
if err != nil {
return fantasy.NewTextResponse("{}")
}
return fantasy.NewTextResponse(string(data))
}
// toolResponse builds a fantasy.ToolResponse from a JSON-serializable
// result map. The map constraint ensures all tool results serialize
// to JSON objects so the frontend can safely parse them.
func toolResponse(result map[string]any) fantasy.ToolResponse {
return marshalToolResponse(result)
}
// buildToolResponse marshals a buildErrorResult into a tool response.
// Separate from toolResponse to keep the map[string]any constraint
// on the general helper while allowing typed error structs.
func buildToolResponse(r buildErrorResult) fantasy.ToolResponse {
return marshalToolResponse(r)
}
// responseErrorResult converts a codersdk.Response into a structured
// tool result. We return these via toolResponse rather than
// NewTextErrorResponse because the fantasy/chatprompt pipeline flattens
// IsError content into a single string and drops validation details.
func responseErrorResult(resp codersdk.Response) map[string]any {
message := resp.Message
if message == "" {
message = "request failed"
}
result := map[string]any{
"error": message,
}
if resp.Detail != "" {
result["detail"] = resp.Detail
}
if len(resp.Validations) > 0 {
result["validations"] = resp.Validations
}
return result
}
func truncateRunes(value string, maxLen int) string {
if maxLen <= 0 || value == "" {
return ""
}
if utf8.RuneCountInString(value) <= maxLen {
return value
}
runes := []rune(value)
if maxLen > len(runes) {
maxLen = len(runes)
}
return string(runes[:maxLen])
}
// buildErrorResult is a structured error response that preserves
// the build ID alongside the error message. This lets the frontend
// keep showing build logs when a build fails instead of losing
// them on the error transition.
type buildErrorResult struct {
Error string `json:"error"`
BuildID string `json:"build_id,omitempty"`
}
func newBuildError(msg string, buildID uuid.UUID) buildErrorResult {
r := buildErrorResult{Error: msg}
if buildID != uuid.Nil {
r.BuildID = buildID.String()
}
return r
}
// setBuildID adds the build_id field to a tool response map when
// the build ID is known (non-zero).
func setBuildID(result map[string]any, buildID uuid.UUID) {
if buildID != uuid.Nil {
result["build_id"] = buildID.String()
}
}
// setNoBuild marks the response with no_build: true when no build
// was triggered. The frontend uses this flag to suppress the
// build-log section for already-running workspaces.
func setNoBuild(result map[string]any, buildID uuid.UUID) {
if buildID == uuid.Nil {
result["no_build"] = true
}
}
// isTemplateAllowed checks whether a template ID is permitted by the
// configured allowlist. A nil function or an empty allowlist means
// all templates are allowed.
func isTemplateAllowed(getAllowlist func() map[uuid.UUID]bool, id uuid.UUID) bool {
if getAllowlist == nil {
return true
}
allowlist := getAllowlist()
if len(allowlist) == 0 {
return true
}
return allowlist[id]
}