Files
multica/server/pkg/db/generated/task_usage.sql.go
Jiayuan Zhang ba129b1963 feat(issues): show per-run token usage on the execution log (MUL-5762) (#6440)
* feat(issues): show per-run token usage on the execution log (MUL-5762)

The execution log already lists every agent run on an issue; it just
never said what any of them cost. task_usage has held per-task token
counts since migration 032 — nothing surfaced them per run.

Three placements, one data source:

- Execution-log header carries the issue total ("2.1M · $4.92") and
  opens the breakdown.
- Each row carries its own token figure. This takes the slot the
  relative timestamp held: the sidebar is 288px and a third column
  would come out of the trigger text, which is what people scan. The
  list is sorted newest-first, so ordinal recency is already free; the
  timestamp moves into the row tooltip alongside duration and model,
  neither of which was surfaced there before.
- The transcript dialog gets the same figure in its header, with the
  input/output/cache split in the run-info popover.

Backend: ListIssueTaskUsage returns per-(task, provider, model) rows in
one query, joined onto the existing task-runs response. The model
dimension stays on the wire because cost is priced client-side per
model — a row that collapsed two models cannot be priced at all.

Cost reuses estimateCost from the runtime usage page, so the issue and
the workspace never disagree; the new summarizeTaskUsage helpers live
next to it rather than starting a second cost formula.

No usage recorded stays distinguishable from zero end to end — omitted
on the wire, undefined in the schema, null from the summarizer, an em
dash in the UI. A run from before usage reporting was not free.

Removes the standalone "Token usage" sidebar section: it showed the
same issue totals minus the cost and minus any way to attribute them,
and every field it had is in the dialog. The /api/issues/:id/usage
endpoint it read stays — the CLI's `issue usage` still uses it.

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

* fix(issues): address review on per-run token usage (MUL-5762)

Three findings from @Emacs, all confirmed against the code:

1. Drop the token figure from active rows. The daemon reports usage
   once, after `runner.run` returns (internal/daemon/daemon.go), and
   ReportTaskUsage publishes no realtime event — so a running task has
   no usage to show and would not learn of it mid-run if it did. The
   branch was only ever exercised by a hand-written fixture, which is a
   test asserting a scenario production cannot produce. The row keeps
   its timer; restore the figure in the same change that adds
   incremental reporting + cache invalidation.

2. Subscribe the usage surfaces to the custom-pricing store. estimateCost
   reads custom rates imperatively via getCustomPricing(), so nothing
   re-rendered these after a saved rate change — the header total, the
   dialog's totals and per-run costs, the cost-by-agent split, and the
   "unmapped model" notice all kept quoting the old price until the task
   list happened to refetch. Same subscription the runtime usage page
   already carries, plus the snapshot in every memo that prices usage.
   Regression test pinned: it fails without the subscription.

3. Give the dialog's status glyph an sr-only label. TaskStatusIcon is
   aria-hidden, so a screen reader could not tell a failed run from a
   completed one — the execution log rows already pair the two.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-05 19:14:33 +08:00

696 lines
25 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: task_usage.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getIssueUsageSummary = `-- name: GetIssueUsageSummary :one
SELECT
COALESCE(SUM(tu.input_tokens), 0)::bigint AS total_input_tokens,
COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens,
COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens,
COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS total_cost_usd_ticks,
COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens,
COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens,
COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens,
COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens,
COUNT(DISTINCT tu.task_id)::int AS task_count
FROM task_usage tu
JOIN agent_task_queue atq ON atq.id = tu.task_id
WHERE atq.issue_id = $1
`
type GetIssueUsageSummaryRow struct {
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalCacheWriteTokens int64 `json:"total_cache_write_tokens"`
TotalCostUsdTicks int64 `json:"total_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"`
}
func (q *Queries) GetIssueUsageSummary(ctx context.Context, issueID pgtype.UUID) (GetIssueUsageSummaryRow, error) {
row := q.db.QueryRow(ctx, getIssueUsageSummary, issueID)
var i GetIssueUsageSummaryRow
err := row.Scan(
&i.TotalInputTokens,
&i.TotalOutputTokens,
&i.TotalCacheReadTokens,
&i.TotalCacheWriteTokens,
&i.TotalCostUsdTicks,
&i.UncostedInputTokens,
&i.UncostedOutputTokens,
&i.UncostedCacheReadTokens,
&i.UncostedCacheWriteTokens,
&i.TaskCount,
)
return i, err
}
const getTaskUsage = `-- name: GetTaskUsage :many
SELECT id, task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at, cost_usd_ticks FROM task_usage
WHERE task_id = $1
ORDER BY model
`
func (q *Queries) GetTaskUsage(ctx context.Context, taskID pgtype.UUID) ([]TaskUsage, error) {
rows, err := q.db.Query(ctx, getTaskUsage, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TaskUsage{}
for rows.Next() {
var i TaskUsage
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Provider,
&i.Model,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadTokens,
&i.CacheWriteTokens,
&i.CreatedAt,
&i.UpdatedAt,
&i.CostUsdTicks,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDashboardAgentRunTime = `-- name: ListDashboardAgentRunTime :many
SELECT
atq.agent_id,
COALESCE(
SUM(EXTRACT(EPOCH FROM (atq.completed_at - atq.started_at)))::bigint,
0
)::bigint AS total_seconds,
COUNT(*)::int AS task_count,
COUNT(*) FILTER (WHERE atq.status = 'failed')::int AS failed_count
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
LEFT JOIN issue i ON i.id = atq.issue_id
WHERE a.workspace_id = $1
AND atq.status IN ('completed', 'failed')
AND atq.started_at IS NOT NULL
AND atq.completed_at IS NOT NULL
AND atq.completed_at >= $2::timestamptz
AND ($3::uuid IS NULL OR i.project_id = $3)
GROUP BY atq.agent_id
ORDER BY total_seconds DESC
`
type ListDashboardAgentRunTimeParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Since pgtype.Timestamptz `json:"since"`
ProjectID pgtype.UUID `json:"project_id"`
}
type ListDashboardAgentRunTimeRow struct {
AgentID pgtype.UUID `json:"agent_id"`
TotalSeconds int64 `json:"total_seconds"`
TaskCount int32 `json:"task_count"`
FailedCount int32 `json:"failed_count"`
}
// Per-agent total task run time and task count for the workspace, optionally
// scoped to a single project. Counts only terminal runs (completed or failed)
// with both started_at and completed_at populated — queued/running tasks have
// no finite duration. Anchored on completed_at so the window matches the
// token cost window (which is anchored on tu.created_at, ~= completion time).
//
// No date bucketing, so no @tz — but @since is the viewer's local
// start-of-day for the EXACT N-day window (parseExactSinceParamInTZ), so the
// "last N days" window lines up with the per-agent cost card and the daily
// charts the client trims to the same span; passed straight through without
// re-truncation.
func (q *Queries) ListDashboardAgentRunTime(ctx context.Context, arg ListDashboardAgentRunTimeParams) ([]ListDashboardAgentRunTimeRow, error) {
rows, err := q.db.Query(ctx, listDashboardAgentRunTime, arg.WorkspaceID, arg.Since, arg.ProjectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDashboardAgentRunTimeRow{}
for rows.Next() {
var i ListDashboardAgentRunTimeRow
if err := rows.Scan(
&i.AgentID,
&i.TotalSeconds,
&i.TaskCount,
&i.FailedCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDashboardFailuresByAgent = `-- name: ListDashboardFailuresByAgent :many
SELECT
atq.agent_id,
CASE
WHEN atq.status = 'failed'
THEN COALESCE(NULLIF(atq.failure_reason, ''), 'unclassified')
ELSE ''
END AS failure_reason,
COUNT(*)::int AS task_count
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
LEFT JOIN issue i ON i.id = atq.issue_id
WHERE a.workspace_id = $1
AND atq.status IN ('completed', 'failed')
AND atq.completed_at IS NOT NULL
AND atq.completed_at >= $2::timestamptz
AND ($3::uuid IS NULL OR i.project_id = $3)
GROUP BY atq.agent_id, 2
ORDER BY atq.agent_id, 2
`
type ListDashboardFailuresByAgentParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Since pgtype.Timestamptz `json:"since"`
ProjectID pgtype.UUID `json:"project_id"`
}
type ListDashboardFailuresByAgentRow struct {
AgentID pgtype.UUID `json:"agent_id"`
FailureReason string `json:"failure_reason"`
TaskCount int32 `json:"task_count"`
}
// Per-(agent, failure_reason) terminal-task counts — the "top offenders"
// half of the dashboard's errors breakdown. Same `failure_reason = ”`
// succeeded-bucket convention as ListDashboardFailuresDaily, so the client
// can rank agents by failure rate rather than raw count.
//
// No date bucketing, so no @tz — @since is the viewer's local
// start-of-day-(N) so the window lines up with the per-agent run-time card.
func (q *Queries) ListDashboardFailuresByAgent(ctx context.Context, arg ListDashboardFailuresByAgentParams) ([]ListDashboardFailuresByAgentRow, error) {
rows, err := q.db.Query(ctx, listDashboardFailuresByAgent, arg.WorkspaceID, arg.Since, arg.ProjectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDashboardFailuresByAgentRow{}
for rows.Next() {
var i ListDashboardFailuresByAgentRow
if err := rows.Scan(&i.AgentID, &i.FailureReason, &i.TaskCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDashboardFailuresDaily = `-- name: ListDashboardFailuresDaily :many
SELECT
DATE(atq.completed_at AT TIME ZONE $2::text) AS date,
CASE
WHEN atq.status = 'failed'
THEN COALESCE(NULLIF(atq.failure_reason, ''), 'unclassified')
ELSE ''
END AS failure_reason,
COUNT(*)::int AS task_count
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
LEFT JOIN issue i ON i.id = atq.issue_id
WHERE a.workspace_id = $1
AND atq.status IN ('completed', 'failed')
AND atq.completed_at IS NOT NULL
AND atq.completed_at >= $3::timestamptz
AND ($4::uuid IS NULL OR i.project_id = $4)
GROUP BY 1, 2
ORDER BY 1 DESC, 2
`
type ListDashboardFailuresDailyParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Tz string `json:"tz"`
Since pgtype.Timestamptz `json:"since"`
ProjectID pgtype.UUID `json:"project_id"`
}
type ListDashboardFailuresDailyRow struct {
Date pgtype.Date `json:"date"`
FailureReason string `json:"failure_reason"`
TaskCount int32 `json:"task_count"`
}
// Daily per-(date, failure_reason) terminal-task counts for the workspace,
// optionally scoped to a single project. Powers the workspace dashboard's
// "Errors" trend and the errors-by-class breakdown.
//
// Shape note: this returns EVERY terminal task, not just the failures. The
// `failure_reason = ”` row of each date carries that date's succeeded
// count, which is the denominator the client needs for an error rate. A
// failed row whose failure_reason column is NULL or empty (pre-MUL-1949
// rows, or a failure path that forgot to classify) collapses into the
// 'unclassified' bucket so it stays countable instead of masquerading as a
// success. Cardinality is bounded by days x (21 reasons + 2), so the whole
// window fits in one small payload.
//
// Unlike ListDashboardRunTimeDaily this does NOT require started_at — a task
// that expired in the queue (failure_reason='queued_expired') never started
// but is unambiguously a failure, and dropping it would under-report exactly
// the outage the Errors chart exists to surface. Every failure path sets
// completed_at, so bucketing on it covers all of them.
//
// @since is already the viewer's local start-of-day-(N) (parseSinceParamInTZ)
// — passed straight through, NOT re-truncated; see ListDashboardUsageDaily.
func (q *Queries) ListDashboardFailuresDaily(ctx context.Context, arg ListDashboardFailuresDailyParams) ([]ListDashboardFailuresDailyRow, error) {
rows, err := q.db.Query(ctx, listDashboardFailuresDaily,
arg.WorkspaceID,
arg.Tz,
arg.Since,
arg.ProjectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDashboardFailuresDailyRow{}
for rows.Next() {
var i ListDashboardFailuresDailyRow
if err := rows.Scan(&i.Date, &i.FailureReason, &i.TaskCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDashboardRunTimeDaily = `-- name: ListDashboardRunTimeDaily :many
SELECT
DATE(atq.completed_at AT TIME ZONE $2::text) AS date,
COALESCE(
SUM(EXTRACT(EPOCH FROM (atq.completed_at - atq.started_at)))::bigint,
0
)::bigint AS total_seconds,
COUNT(*)::int AS task_count,
COUNT(*) FILTER (WHERE atq.status = 'failed')::int AS failed_count
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
LEFT JOIN issue i ON i.id = atq.issue_id
WHERE a.workspace_id = $1
AND atq.status IN ('completed', 'failed')
AND atq.started_at IS NOT NULL
AND atq.completed_at IS NOT NULL
AND atq.completed_at >= $3::timestamptz
AND ($4::uuid IS NULL OR i.project_id = $4)
GROUP BY DATE(atq.completed_at AT TIME ZONE $2::text)
ORDER BY DATE(atq.completed_at AT TIME ZONE $2::text) DESC
`
type ListDashboardRunTimeDailyParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Tz string `json:"tz"`
Since pgtype.Timestamptz `json:"since"`
ProjectID pgtype.UUID `json:"project_id"`
}
type ListDashboardRunTimeDailyRow struct {
Date pgtype.Date `json:"date"`
TotalSeconds int64 `json:"total_seconds"`
TaskCount int32 `json:"task_count"`
FailedCount int32 `json:"failed_count"`
}
// Daily per-date run time + task counts for the workspace, optionally
// scoped to a single project. Powers the workspace dashboard's "Time"
// and "Tasks" metrics on the same toggle as Tokens / Cost. Bucketed by
// completed_at (terminal time) sliced into calendar days under the
// caller-supplied @tz — same Viewing-tz treatment as ListDashboardUsageDaily
// so the Time / Tasks tabs cut their day boundary identically to the
// Cost / Tokens tabs (a viewer east of UTC would otherwise see the four
// tabs disagree on a "1d" window). Only terminal tasks (completed or
// failed) with both started_at and completed_at populated contribute.
//
// @since is already the viewer's local start-of-day-(N) (parseSinceParamInTZ)
// — passed straight through, NOT re-truncated; see ListDashboardUsageDaily.
func (q *Queries) ListDashboardRunTimeDaily(ctx context.Context, arg ListDashboardRunTimeDailyParams) ([]ListDashboardRunTimeDailyRow, error) {
rows, err := q.db.Query(ctx, listDashboardRunTimeDaily,
arg.WorkspaceID,
arg.Tz,
arg.Since,
arg.ProjectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDashboardRunTimeDailyRow{}
for rows.Next() {
var i ListDashboardRunTimeDailyRow
if err := rows.Scan(
&i.Date,
&i.TotalSeconds,
&i.TaskCount,
&i.FailedCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDashboardUsageByAgent = `-- name: ListDashboardUsageByAgent :many
SELECT
agent_id,
LOWER(provider) AS provider,
model,
SUM(input_tokens)::bigint AS input_tokens,
SUM(output_tokens)::bigint AS output_tokens,
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
SUM(cost_usd_ticks)::bigint AS cost_usd_ticks,
SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens,
SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens,
SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens,
SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens,
SUM(task_count)::int AS task_count
FROM task_usage_hourly
WHERE workspace_id = $1
AND bucket_hour >= $2::timestamptz
AND ($3::uuid IS NULL OR project_id = $3)
GROUP BY agent_id, LOWER(provider), model
ORDER BY agent_id, LOWER(provider), model
`
type ListDashboardUsageByAgentParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Since pgtype.Timestamptz `json:"since"`
ProjectID pgtype.UUID `json:"project_id"`
}
type ListDashboardUsageByAgentRow struct {
AgentID pgtype.UUID `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"`
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"`
}
// Per-(agent, provider, model) token aggregates from `task_usage_hourly`. No
// date grouping in the result, so this query takes no `@tz` — the
// @since cutoff is a raw timestamptz the Go layer has already computed
// in the viewer's tz. Model dimension is preserved so the client can
// compute cost from its per-model pricing table; the client folds rows
// by agent for the "by agent" list on the dashboard.
//
// task_count is summed across hourly buckets — one task that spans
// multiple hours lands in multiple buckets, so this over-counts by
// hour the same way the daily version over-counted by day. The
// frontend prefers `ListDashboardAgentRunTime` for the user-facing
// "tasks" column, so this stays informational only.
// provider is LOWER()-normalized so mixed-case historical rows merge with
// new rows (see ListDashboardUsageDaily).
func (q *Queries) ListDashboardUsageByAgent(ctx context.Context, arg ListDashboardUsageByAgentParams) ([]ListDashboardUsageByAgentRow, error) {
rows, err := q.db.Query(ctx, listDashboardUsageByAgent, arg.WorkspaceID, arg.Since, arg.ProjectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDashboardUsageByAgentRow{}
for rows.Next() {
var i ListDashboardUsageByAgentRow
if err := rows.Scan(
&i.AgentID,
&i.Provider,
&i.Model,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadTokens,
&i.CacheWriteTokens,
&i.CostUsdTicks,
&i.UncostedInputTokens,
&i.UncostedOutputTokens,
&i.UncostedCacheReadTokens,
&i.UncostedCacheWriteTokens,
&i.TaskCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDashboardUsageDaily = `-- name: ListDashboardUsageDaily :many
SELECT
DATE(bucket_hour AT TIME ZONE $2::text) AS date,
LOWER(provider) AS provider,
model,
SUM(input_tokens)::bigint AS input_tokens,
SUM(output_tokens)::bigint AS output_tokens,
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
SUM(cost_usd_ticks)::bigint AS cost_usd_ticks,
SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens,
SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens,
SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens,
SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens,
SUM(task_count)::int AS task_count
FROM task_usage_hourly
WHERE workspace_id = $1
AND bucket_hour >= $3::timestamptz
AND ($4::uuid IS NULL OR project_id = $4)
GROUP BY DATE(bucket_hour AT TIME ZONE $2::text), LOWER(provider), model
ORDER BY DATE(bucket_hour AT TIME ZONE $2::text) DESC, LOWER(provider), model
`
type ListDashboardUsageDailyParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Tz string `json:"tz"`
Since pgtype.Timestamptz `json:"since"`
ProjectID pgtype.UUID `json:"project_id"`
}
type ListDashboardUsageDailyRow struct {
Date pgtype.Date `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"`
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"`
}
// Daily per-(date, provider, model) token aggregates for the workspace, served
// from the UTC-bucketed `task_usage_hourly` table and
// sliced to calendar days under the caller-supplied @tz. Optionally
// scoped to a single project via sqlc.narg('project_id'). Powers the
// workspace dashboard's daily cost chart.
// The viewer's tz is applied here at query time, so a viewer in
// Asia/Shanghai gets their "today" cut at +08 and one in
// America/Los_Angeles gets theirs at -08 against the same UTC rows.
//
// @since is already the viewer's local start-of-day-(N) as a UTC
// instant (computed by parseSinceParamInTZ). It must NOT be re-truncated
// with DATE_TRUNC here — DATE_TRUNC operates in the session tz and would
// snap the cutoff back to UTC midnight, dragging in an extra partial
// local day for any non-UTC viewer.
// provider is LOWER()-normalized so mixed-case historical rows (written
// before the handler lowercased provider on write) merge with new rows
// instead of forming a separate case-variant bucket.
func (q *Queries) ListDashboardUsageDaily(ctx context.Context, arg ListDashboardUsageDailyParams) ([]ListDashboardUsageDailyRow, error) {
rows, err := q.db.Query(ctx, listDashboardUsageDaily,
arg.WorkspaceID,
arg.Tz,
arg.Since,
arg.ProjectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDashboardUsageDailyRow{}
for rows.Next() {
var i ListDashboardUsageDailyRow
if err := rows.Scan(
&i.Date,
&i.Provider,
&i.Model,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadTokens,
&i.CacheWriteTokens,
&i.CostUsdTicks,
&i.UncostedInputTokens,
&i.UncostedOutputTokens,
&i.UncostedCacheReadTokens,
&i.UncostedCacheWriteTokens,
&i.TaskCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listIssueTaskUsage = `-- name: ListIssueTaskUsage :many
SELECT
tu.task_id,
tu.provider,
tu.model,
tu.input_tokens,
tu.output_tokens,
tu.cache_read_tokens,
tu.cache_write_tokens,
tu.cost_usd_ticks
FROM task_usage tu
JOIN agent_task_queue atq ON atq.id = tu.task_id
WHERE atq.issue_id = $1
ORDER BY tu.task_id, tu.model
`
type ListIssueTaskUsageRow struct {
TaskID pgtype.UUID `json:"task_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"`
CostUsdTicks pgtype.Int8 `json:"cost_usd_ticks"`
}
// Per-(task, provider, model) usage rows for every task on one issue — the
// per-run half of GetIssueUsageSummary's issue-wide total.
//
// The model dimension stays on the wire for the same reason the runtime and
// dashboard usage rows keep it: cost is priced client-side from a per-model
// rate table, and a row that has collapsed two models into one sum can no
// longer be priced at all. The execution log sums the rows per task; the usage
// panel shows them split.
//
// Ordering is by task then model so the client can group by task_id in one
// pass. Uses idx_agent_task_queue_issue_id (migration 035) + the task_usage
// task_id index (migration 032).
func (q *Queries) ListIssueTaskUsage(ctx context.Context, issueID pgtype.UUID) ([]ListIssueTaskUsageRow, error) {
rows, err := q.db.Query(ctx, listIssueTaskUsage, issueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIssueTaskUsageRow{}
for rows.Next() {
var i ListIssueTaskUsageRow
if err := rows.Scan(
&i.TaskID,
&i.Provider,
&i.Model,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadTokens,
&i.CacheWriteTokens,
&i.CostUsdTicks,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertTaskUsage = `-- name: UpsertTaskUsage :exec
INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd_ticks, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (task_id, provider, model)
DO UPDATE SET
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
cache_read_tokens = EXCLUDED.cache_read_tokens,
cache_write_tokens = EXCLUDED.cache_write_tokens,
cost_usd_ticks = EXCLUDED.cost_usd_ticks,
updated_at = now()
`
type UpsertTaskUsageParams struct {
TaskID pgtype.UUID `json:"task_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"`
CostUsdTicks pgtype.Int8 `json:"cost_usd_ticks"`
}
// Bumps `updated_at` on INSERT and on conflict so the hourly-rollup worker
// detects the row as dirty and re-aggregates its bucket.
// Without the conflict-side bump, a correction to historical token counts
// would never propagate to the rollup.
// cost_usd_ticks is the provider's own price for this usage (1e-10 USD), NULL
// when it reports none. It is overwritten like the token counters so a
// corrected report replaces the previous figure rather than accumulating.
func (q *Queries) UpsertTaskUsage(ctx context.Context, arg UpsertTaskUsageParams) error {
_, err := q.db.Exec(ctx, upsertTaskUsage,
arg.TaskID,
arg.Provider,
arg.Model,
arg.InputTokens,
arg.OutputTokens,
arg.CacheReadTokens,
arg.CacheWriteTokens,
arg.CostUsdTicks,
)
return err
}