Files
multica/server/internal/handler/dashboard.go
Bohan Jiang 2e9a3d0119 fix(dashboard): stop leaking private agents from the per-agent rollups (MUL-5409) (#6051)
* fix(dashboard): stop leaking private agents from the per-agent rollups (MUL-5409)

Three per-agent dashboard endpoints authorized on workspace membership alone
and returned a bare agent_id for every agent in the workspace:

  GET /api/dashboard/usage/by-agent
  GET /api/dashboard/agent-runtime
  GET /api/dashboard/failures/by-agent

That told a plain member which private agents exist, how much they spend, how
long they run and what they fail on. The client already collapsed those rows,
but client-side filtering is decoration — one curl bypasses it.

Server: rows for agents the caller may not view are now folded onto a
`__restricted_agents__` sentinel before serialization, via one shared helper.
Folded, not dropped: each of these responses is the per-agent half of a pair
whose other half (usage/daily, runtime/daily, failures/daily) is workspace-
scoped and unfiltered, so dropping rows would make the per-agent breakdown stop
adding up to the KPIs rendered beside it. The bucket keeps its provider/model
and failure_reason dimensions — both are derivable by subtraction from the
workspace-level series anyway, and the client needs them to price the bucket and
compute its failure rate.

Owner/admin and agent actors short-circuit before any extra query, so the
governance view is unchanged. Hard-deleted agents are deliberately excluded from
the fold — they have no visibility left to protect and keep their own bucket.

Client: fixes the mislabelling that shipped with this. A live private agent was
folded into a row labelled "Deleted agents" with a bin icon, and counted into
the card's "· N deleted" caption — telling the user N agents were deleted when
they are alive and still running. The restricted bucket is now its own row with
neutral copy, keeps its real Time / Tasks values, and counts as neither an agent
nor a deletion in the caption.

Tests: handler regression coverage proving a plain member's response contains no
private agent UUID while every aggregate still sums to the privileged view's
total, plus view coverage for the label and caption.

Co-authored-by: multica-agent <github@multica.ai>

* fix(dashboard): fold hidden system agent carriers into the restricted bucket (MUL-5409)

Review follow-up. The first pass built the restricted set from ListAllAgents,
which filters `kind = 'user'` — so it missed the hidden `kind = 'system'`
execution carriers behind agent-builder sessions.

Those carriers run real tasks and book real usage, and all three rollups
aggregate over agent_task_queue / task_usage with no kind filter of their own.
No list endpoint returns them either (ListAgents / ListAllAgents both filter on
kind), so no client can resolve one to a name. Net effect: the exact two bugs
this PR exists to fix, still live — a bare UUID exposing one member's builder
session (with its spend and failure profile) to every other member, and, once
the agent list loads, a running agent folded into the client's "Deleted agents"
row and counted as a deletion.

restrictedAgentIDs now reads a new ListAllAgentsAnyKind and restricts every
non-user-kind agent for EVERYONE, workspace owner included — nobody can name
one, so a bare UUID row is wrong for every viewer, not just plain members. User
agents keep the per-viewer visibility rule. The invocation-target lookup is
skipped for actors that rule can never restrict (agent actors, owner/admin), so
the added cost is one indexed list query.

Because the bucket now also carries carriers that are nobody's "restricted"
agents, its copy drops to the neutral "Other agents" — the same wording the
Errors card already uses for its equivalent row, in all four locales.

Adds a regression test seeding a kind=system private carrier with tasks and
usage: no endpoint may return its UUID to either the plain member OR the
workspace owner who owns it, a bucket must be present to carry its rows, and
every metric delta (tokens, seconds, tasks, failures, runs) must equal its exact
contribution. Verified to fail on all three endpoints for both viewers with the
kind-filtered query restored.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 13:08:48 +08:00

625 lines
24 KiB
Go

package handler
import (
"context"
"net/http"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// ---------------------------------------------------------------------------
// Workspace / Project dashboard
//
// Six read endpoints power the workspace dashboard:
//
// GET /api/dashboard/usage/daily per-(date, model) token rows
// GET /api/dashboard/usage/by-agent per-(agent, model) token rows
// GET /api/dashboard/agent-runtime per-agent run-time + task counts
// GET /api/dashboard/runtime/daily per-date run-time + task counts
// GET /api/dashboard/failures/daily per-(date, failure_reason) counts
// GET /api/dashboard/failures/by-agent per-(agent, failure_reason) counts
//
// All of them accept ?days=N (defaults to 30, capped at 365) and an optional
// ?project_id=<uuid> to scope the rollup to a single project. With no
// project_id the data spans the whole workspace.
//
// Cost is computed client-side from a per-model pricing table — the model
// dimension is intentionally preserved on the wire (same convention as the
// per-runtime usage endpoints).
//
// Access control: the workspace-wide series (usage/daily, runtime/daily,
// failures/daily) carry no agent dimension and need workspace membership only —
// token spend / run time / failure volume are workspace-level operational
// metrics. The three per-AGENT rollups additionally apply per-agent visibility:
// see foldRestrictedAgents.
// ---------------------------------------------------------------------------
// restrictedAgentsRowID is the synthetic agent_id that every row this response
// refuses to name is folded onto. Deliberately not a UUID, so it can never
// collide with a real agent id, and distinct from the client's "deleted agents"
// bucket: those agents are gone, these are alive and still running.
const restrictedAgentsRowID = "__restricted_agents__"
// foldRestrictedAgents rewrites every row named by `restricted` (see
// restrictedAgentIDs) onto restrictedAgentsRowID, merging the rewritten rows
// that then collide on whatever dimensions the row has left (provider+model,
// failure_reason, or nothing at all).
//
// "Private" is a promise the rest of the codebase keeps — agent detail 403s,
// ListAgents filters, even an admin cannot invoke someone else's private agent.
// These three endpoints used to break it by returning bare agent UUIDs for the
// whole workspace, which told a plain member that a private agent exists, how
// much it runs, and what it fails on. The client already collapsed those rows,
// but client-side filtering is decoration: one curl bypasses it.
//
// The same fold covers the hidden `kind = 'system'` builder carriers, which no
// list endpoint returns to anyone: aggregating over agent_task_queue /
// task_usage picks them up regardless of kind, so without this they arrive as a
// bare UUID no client can name.
//
// Folding rather than dropping: each of these responses is the per-agent half
// of a pair whose other half (usage/daily, runtime/daily, failures/daily) is
// workspace-scoped and unfiltered, so dropping rows would make the per-agent
// breakdown stop adding up to the totals rendered directly beside it. One
// merged bucket keeps every sum intact while carrying no real agent id and no
// per-agent split.
//
// The retained dimensions leak nothing new: the workspace-level series already
// exposes each (provider, model) / failure_reason total for the whole
// workspace, and every visible agent's rows are returned in full, so the
// restricted remainder was always one subtraction away.
//
// `rewrite` stamps the sentinel id and returns the merge key for what remains;
// `merge` accumulates a later row onto the one already emitted. Row order is
// otherwise preserved, with the bucket sitting where its first member was — the
// client re-ranks all of these anyway.
func foldRestrictedAgents[T any, K comparable](
rows []T,
restricted map[string]struct{},
agentIDOf func(T) string,
rewrite func(T) (T, K),
merge func(dst, src T) T,
) []T {
if len(restricted) == 0 {
return rows
}
out := make([]T, 0, len(rows))
bucketAt := make(map[K]int)
for _, row := range rows {
if _, hidden := restricted[agentIDOf(row)]; !hidden {
out = append(out, row)
continue
}
folded, key := rewrite(row)
if i, ok := bucketAt[key]; ok {
out[i] = merge(out[i], folded)
continue
}
bucketAt[key] = len(out)
out = append(out, folded)
}
return out
}
// dashboardRestrictedAgents resolves the agents this request may not see. On
// failure it writes a 500 and returns ok=false: an unfiltered rollup is the one
// outcome this must never degrade to.
func (h *Handler) dashboardRestrictedAgents(
w http.ResponseWriter,
r *http.Request,
workspaceID, role string,
) (map[string]struct{}, bool) {
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
restricted, ok := h.restrictedAgentIDs(r.Context(), workspaceID, actorType, actorID, role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return nil, false
}
return restricted, true
}
// parseProjectIDParam reads ?project_id=<uuid> off the URL. Returns a
// pgtype.UUID with Valid=false when the param is absent so sqlc's nullable
// argument resolves to SQL NULL and the WHERE clause degrades to "no
// project filter". On a malformed UUID it writes a 400 and returns
// ok=false; callers must return immediately.
func parseProjectIDParam(w http.ResponseWriter, r *http.Request) (pgtype.UUID, bool) {
raw := r.URL.Query().Get("project_id")
if raw == "" {
return pgtype.UUID{}, true
}
u, err := util.ParseUUID(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid project_id")
return pgtype.UUID{}, false
}
return u, true
}
// DashboardUsageDailyResponse is one (date, provider, model) bucket. Cost-side
// math happens on the client from a per-model pricing table; provider + model
// stay on the wire so the client can disambiguate bare model ids that collide
// across providers (e.g. Cursor's `auto`).
type DashboardUsageDailyResponse struct {
Date string `json:"date"`
Provider string `json:"provider"`
Model string `json:"model"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
// Cost split: `CostUSDTicks` is what the provider itself charged for the
// rows behind this aggregate (1e-10 USD), and the `Uncosted*` token
// counts are the tokens from rows the provider did NOT price. The client
// reports authoritative + estimate(uncosted), so a window mixing both
// kinds of row stays whole. See migration 213.
CostUSDTicks int64 `json:"cost_usd_ticks"`
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
TaskCount int32 `json:"task_count"`
}
// GetDashboardUsageDaily returns per-(date, model) token rows for the
// workspace, optionally scoped to a project. Backed by task_usage_hourly,
// sliced into calendar days under the viewer's tz.
func (h *Handler) GetDashboardUsageDaily(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
projectID, ok := parseProjectIDParam(w, r)
if !ok {
return
}
tz := h.resolveViewingTZ(r)
since := parseSinceParamInTZ(r, 30, tz)
resp, err := h.listDashboardUsageDaily(r.Context(), parseUUID(workspaceID), tz, since, projectID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list usage")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) listDashboardUsageDaily(
ctx context.Context,
workspaceID pgtype.UUID,
tz string,
since pgtype.Timestamptz,
projectID pgtype.UUID,
) ([]DashboardUsageDailyResponse, error) {
rows, err := h.Queries.ListDashboardUsageDaily(ctx, db.ListDashboardUsageDailyParams{
WorkspaceID: workspaceID,
Tz: tz,
Since: since,
ProjectID: projectID,
})
if err != nil {
return nil, err
}
resp := make([]DashboardUsageDailyResponse, len(rows))
for i, row := range rows {
resp[i] = DashboardUsageDailyResponse{
Date: row.Date.Time.Format("2006-01-02"),
Provider: row.Provider,
Model: row.Model,
InputTokens: row.InputTokens,
OutputTokens: row.OutputTokens,
CacheReadTokens: row.CacheReadTokens,
CacheWriteTokens: row.CacheWriteTokens,
CostUSDTicks: row.CostUsdTicks,
UncostedInputTokens: row.UncostedInputTokens,
UncostedOutputTokens: row.UncostedOutputTokens,
UncostedCacheReadTokens: row.UncostedCacheReadTokens,
UncostedCacheWriteTokens: row.UncostedCacheWriteTokens,
TaskCount: row.TaskCount,
}
}
return resp, nil
}
// DashboardUsageByAgentResponse is one (agent, provider, model) row. provider
// rides along for the same cross-provider pricing disambiguation as the daily
// response; the client folds by agent_id and sums cost.
type DashboardUsageByAgentResponse struct {
AgentID string `json:"agent_id"`
Provider string `json:"provider"`
Model string `json:"model"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
// Cost split: `CostUSDTicks` is what the provider itself charged for the
// rows behind this aggregate (1e-10 USD), and the `Uncosted*` token
// counts are the tokens from rows the provider did NOT price. The client
// reports authoritative + estimate(uncosted), so a window mixing both
// kinds of row stays whole. See migration 213.
CostUSDTicks int64 `json:"cost_usd_ticks"`
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
TaskCount int32 `json:"task_count"`
}
// GetDashboardUsageByAgent returns per-(agent, model) token aggregates
// for the workspace, optionally scoped to a project. Backed by
// task_usage_hourly with the viewer's tz applied to the `?days=` cutoff.
func (h *Handler) GetDashboardUsageByAgent(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
projectID, ok := parseProjectIDParam(w, r)
if !ok {
return
}
restricted, ok := h.dashboardRestrictedAgents(w, r, workspaceID, member.Role)
if !ok {
return
}
// "By agent" has no date grouping in the SQL — tz only determines
// the cutoff boundary, not the bucket axis.
tz := h.resolveViewingTZ(r)
since := parseSinceParamInTZ(r, 30, tz)
resp, err := h.listDashboardUsageByAgent(r.Context(), parseUUID(workspaceID), since, projectID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list usage by agent")
return
}
writeJSON(w, http.StatusOK, foldRestrictedUsageByAgent(resp, restricted))
}
// providerModelKey keeps the restricted bucket split by (provider, model) so
// the client can still price it from its per-model table — without that the
// bucket's cost is uncomputable and the leaderboard stops summing to the Cost
// KPI, which is the whole reason these rows are folded rather than dropped.
type providerModelKey struct{ provider, model string }
func foldRestrictedUsageByAgent(
rows []DashboardUsageByAgentResponse,
restricted map[string]struct{},
) []DashboardUsageByAgentResponse {
return foldRestrictedAgents(
rows,
restricted,
func(row DashboardUsageByAgentResponse) string { return row.AgentID },
func(row DashboardUsageByAgentResponse) (DashboardUsageByAgentResponse, providerModelKey) {
key := providerModelKey{provider: row.Provider, model: row.Model}
row.AgentID = restrictedAgentsRowID
return row, key
},
func(dst, src DashboardUsageByAgentResponse) DashboardUsageByAgentResponse {
dst.InputTokens += src.InputTokens
dst.OutputTokens += src.OutputTokens
dst.CacheReadTokens += src.CacheReadTokens
dst.CacheWriteTokens += src.CacheWriteTokens
dst.CostUSDTicks += src.CostUSDTicks
dst.UncostedInputTokens += src.UncostedInputTokens
dst.UncostedOutputTokens += src.UncostedOutputTokens
dst.UncostedCacheReadTokens += src.UncostedCacheReadTokens
dst.UncostedCacheWriteTokens += src.UncostedCacheWriteTokens
dst.TaskCount += src.TaskCount
return dst
},
)
}
func (h *Handler) listDashboardUsageByAgent(
ctx context.Context,
workspaceID pgtype.UUID,
since pgtype.Timestamptz,
projectID pgtype.UUID,
) ([]DashboardUsageByAgentResponse, error) {
rows, err := h.Queries.ListDashboardUsageByAgent(ctx, db.ListDashboardUsageByAgentParams{
WorkspaceID: workspaceID,
Since: since,
ProjectID: projectID,
})
if err != nil {
return nil, err
}
resp := make([]DashboardUsageByAgentResponse, len(rows))
for i, row := range rows {
resp[i] = DashboardUsageByAgentResponse{
AgentID: uuidToString(row.AgentID),
Provider: row.Provider,
Model: row.Model,
InputTokens: row.InputTokens,
OutputTokens: row.OutputTokens,
CacheReadTokens: row.CacheReadTokens,
CacheWriteTokens: row.CacheWriteTokens,
CostUSDTicks: row.CostUsdTicks,
UncostedInputTokens: row.UncostedInputTokens,
UncostedOutputTokens: row.UncostedOutputTokens,
UncostedCacheReadTokens: row.UncostedCacheReadTokens,
UncostedCacheWriteTokens: row.UncostedCacheWriteTokens,
TaskCount: row.TaskCount,
}
}
return resp, nil
}
// DashboardAgentRunTimeResponse is one agent's total terminal-task run time
// over the window. Includes failed tasks so the dashboard can surface how
// much execution time was spent on runs that didn't succeed.
type DashboardAgentRunTimeResponse struct {
AgentID string `json:"agent_id"`
TotalSeconds int64 `json:"total_seconds"`
TaskCount int32 `json:"task_count"`
FailedCount int32 `json:"failed_count"`
}
// GetDashboardAgentRunTime returns per-agent total task run time (seconds)
// and task counts for the workspace, optionally scoped to a project. Only
// terminal tasks (completed or failed) with both started_at and
// completed_at populated contribute, since queued/running tasks have no
// finite duration.
func (h *Handler) GetDashboardAgentRunTime(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
projectID, ok := parseProjectIDParam(w, r)
if !ok {
return
}
restricted, ok := h.dashboardRestrictedAgents(w, r, workspaceID, member.Role)
if !ok {
return
}
// Cutoff in the viewer's tz so the "last N days" window matches the
// per-agent cost card (GetDashboardUsageByAgent).
tz := h.resolveViewingTZ(r)
since := parseSinceParamInTZ(r, 30, tz)
rows, err := h.Queries.ListDashboardAgentRunTime(r.Context(), db.ListDashboardAgentRunTimeParams{
WorkspaceID: parseUUID(workspaceID),
Since: since,
ProjectID: projectID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agent runtime")
return
}
resp := make([]DashboardAgentRunTimeResponse, len(rows))
for i, row := range rows {
resp[i] = DashboardAgentRunTimeResponse{
AgentID: uuidToString(row.AgentID),
TotalSeconds: row.TotalSeconds,
TaskCount: row.TaskCount,
FailedCount: row.FailedCount,
}
}
writeJSON(w, http.StatusOK, foldRestrictedAgentRunTime(resp, restricted))
}
// The run-time row carries no dimension besides the agent, so every restricted
// row merges into a single bucket — hence the empty merge key.
func foldRestrictedAgentRunTime(
rows []DashboardAgentRunTimeResponse,
restricted map[string]struct{},
) []DashboardAgentRunTimeResponse {
return foldRestrictedAgents(
rows,
restricted,
func(row DashboardAgentRunTimeResponse) string { return row.AgentID },
func(row DashboardAgentRunTimeResponse) (DashboardAgentRunTimeResponse, struct{}) {
row.AgentID = restrictedAgentsRowID
return row, struct{}{}
},
func(dst, src DashboardAgentRunTimeResponse) DashboardAgentRunTimeResponse {
dst.TotalSeconds += src.TotalSeconds
dst.TaskCount += src.TaskCount
dst.FailedCount += src.FailedCount
return dst
},
)
}
// DashboardRunTimeDailyResponse is one (date) bucket of terminal-task run
// time and counts. Powers the workspace dashboard's daily Time and Tasks
// charts — same toggle as Tokens / Cost, different metric.
type DashboardRunTimeDailyResponse struct {
Date string `json:"date"`
TotalSeconds int64 `json:"total_seconds"`
TaskCount int32 `json:"task_count"`
FailedCount int32 `json:"failed_count"`
}
// GetDashboardRunTimeDaily returns per-date total task run time and task
// counts for the workspace, optionally scoped to a project. Only terminal
// tasks (completed or failed) with both started_at and completed_at
// populated contribute. Bucketed by completed_at so the day boundaries
// line up with the per-agent run-time card.
func (h *Handler) GetDashboardRunTimeDaily(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
projectID, ok := parseProjectIDParam(w, r)
if !ok {
return
}
// Slice day buckets in the viewer's tz so the Time / Tasks charts cut
// their calendar day identically to the Cost / Tokens charts.
tz := h.resolveViewingTZ(r)
since := parseSinceParamInTZ(r, 30, tz)
rows, err := h.Queries.ListDashboardRunTimeDaily(r.Context(), db.ListDashboardRunTimeDailyParams{
WorkspaceID: parseUUID(workspaceID),
Tz: tz,
Since: since,
ProjectID: projectID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list daily runtime")
return
}
resp := make([]DashboardRunTimeDailyResponse, len(rows))
for i, row := range rows {
resp[i] = DashboardRunTimeDailyResponse{
Date: row.Date.Time.Format("2006-01-02"),
TotalSeconds: row.TotalSeconds,
TaskCount: row.TaskCount,
FailedCount: row.FailedCount,
}
}
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// Failure rollups
//
// Both endpoints return EVERY terminal task, not just the failed ones: the
// row whose FailureReason is "" carries that bucket's succeeded count. The
// client needs that denominator to render an error *rate*, and shipping it
// in the same payload keeps numerator and denominator on identical filters —
// deriving the denominator from the run-time endpoints instead would silently
// disagree, because those require started_at IS NOT NULL and a task that
// expired in the queue never started.
//
// FailureReason values are the canonical taxonomy from server/pkg/taskfailure
// (21 reasons), plus "unclassified" for failed rows with a NULL / empty
// column. The client folds them into a handful of display classes; the raw
// reason stays on the wire so that mapping can change without a backend
// deploy.
// ---------------------------------------------------------------------------
// DashboardFailureDailyResponse is one (date, failure_reason) bucket of
// terminal-task counts. FailureReason == "" is the succeeded bucket.
type DashboardFailureDailyResponse struct {
Date string `json:"date"`
FailureReason string `json:"failure_reason"`
TaskCount int32 `json:"task_count"`
}
// GetDashboardFailuresDaily returns per-(date, failure_reason) terminal-task
// counts for the workspace, optionally scoped to a project. Powers the Usage
// page's Errors trend and errors-by-class breakdown.
func (h *Handler) GetDashboardFailuresDaily(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
projectID, ok := parseProjectIDParam(w, r)
if !ok {
return
}
// Same viewer-tz day boundary as every other daily series so the Errors
// tab lines up with Cost / Tokens / Time / Tasks.
tz := h.resolveViewingTZ(r)
since := parseSinceParamInTZ(r, 30, tz)
rows, err := h.Queries.ListDashboardFailuresDaily(r.Context(), db.ListDashboardFailuresDailyParams{
WorkspaceID: parseUUID(workspaceID),
Tz: tz,
Since: since,
ProjectID: projectID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list daily failures")
return
}
resp := make([]DashboardFailureDailyResponse, len(rows))
for i, row := range rows {
resp[i] = DashboardFailureDailyResponse{
Date: row.Date.Time.Format("2006-01-02"),
FailureReason: row.FailureReason,
TaskCount: row.TaskCount,
}
}
writeJSON(w, http.StatusOK, resp)
}
// DashboardFailureByAgentResponse is one (agent, failure_reason) bucket of
// terminal-task counts. FailureReason == "" is the succeeded bucket.
type DashboardFailureByAgentResponse struct {
AgentID string `json:"agent_id"`
FailureReason string `json:"failure_reason"`
TaskCount int32 `json:"task_count"`
}
// GetDashboardFailuresByAgent returns per-(agent, failure_reason)
// terminal-task counts for the workspace, optionally scoped to a project.
// Powers the Usage page's "top offenders" list.
func (h *Handler) GetDashboardFailuresByAgent(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
projectID, ok := parseProjectIDParam(w, r)
if !ok {
return
}
restricted, ok := h.dashboardRestrictedAgents(w, r, workspaceID, member.Role)
if !ok {
return
}
// No date grouping in the SQL, so the client cannot trim this response the
// way it trims the date-bucketed series. Close the window server-side to
// exactly `days` calendar buckets — the same span the Errors chart renders
// after its `-(days-1)` filter. With the default N+1 cutoff this list
// covered one extra day, so at days=1 the card could report yesterday's
// failures next to a chart showing none.
tz := h.resolveViewingTZ(r)
since := parseExactSinceParamInTZ(r, 30, tz)
rows, err := h.Queries.ListDashboardFailuresByAgent(r.Context(), db.ListDashboardFailuresByAgentParams{
WorkspaceID: parseUUID(workspaceID),
Since: since,
ProjectID: projectID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list failures by agent")
return
}
resp := make([]DashboardFailureByAgentResponse, len(rows))
for i, row := range rows {
resp[i] = DashboardFailureByAgentResponse{
AgentID: uuidToString(row.AgentID),
FailureReason: row.FailureReason,
TaskCount: row.TaskCount,
}
}
writeJSON(w, http.StatusOK, foldRestrictedFailuresByAgent(resp, restricted))
}
// The restricted bucket keeps its failure_reason split: the client derives the
// bucket's failure rate and class mix from these raw rows exactly like any
// other agent's, and the succeeded rows (failure_reason == "") are the
// denominator that keeps the offender list reconciling with the workspace
// failure total above it.
func foldRestrictedFailuresByAgent(
rows []DashboardFailureByAgentResponse,
restricted map[string]struct{},
) []DashboardFailureByAgentResponse {
return foldRestrictedAgents(
rows,
restricted,
func(row DashboardFailureByAgentResponse) string { return row.AgentID },
func(row DashboardFailureByAgentResponse) (DashboardFailureByAgentResponse, string) {
reason := row.FailureReason
row.AgentID = restrictedAgentsRowID
return row, reason
},
func(dst, src DashboardFailureByAgentResponse) DashboardFailureByAgentResponse {
dst.TaskCount += src.TaskCount
return dst
},
)
}