Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
c40c246a1f fix(db): add index on agent_task_queue(issue_id) for usage queries
The GetIssueUsageSummary query joins agent_task_queue filtered by
issue_id across all statuses. The existing partial index (migration 022)
only covers queued/dispatched rows, so completed tasks require a
sequential scan. Add a general index to prevent performance degradation
as task volume grows.
2026-04-10 14:34:17 +08:00
Jiang Bohan
b46ee08c40 feat(issues): display token usage per issue in detail sidebar
Add a new "Token usage" section to the issue detail right sidebar that
shows aggregated input/output tokens, cache tokens, and run count across
all tasks for the issue. Backed by a new SQL query and API endpoint.
2026-04-09 16:56:19 +08:00
11 changed files with 134 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ import type {
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
RuntimeUsage,
IssueUsageSummary,
RuntimeHourlyActivity,
RuntimePing,
RuntimeUpdate,
@@ -415,6 +416,10 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/task-runs`);
}
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
return this.fetch(`/api/issues/${issueId}/usage`);
}
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
method: "POST",

View File

@@ -12,6 +12,7 @@ export const issueKeys = {
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
usage: (issueId: string) => ["issues", "usage", issueId] as const,
};
export const CLOSED_PAGE_SIZE = 50;
@@ -79,3 +80,10 @@ export function issueSubscribersOptions(issueId: string) {
queryFn: () => api.listIssueSubscribers(issueId),
});
}
export function issueUsageOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.usage(issueId),
queryFn: () => api.getIssueUsage(issueId),
});
}

View File

@@ -138,6 +138,14 @@ export interface RuntimePing {
updated_at: string;
}
export interface IssueUsageSummary {
total_input_tokens: number;
total_output_tokens: number;
total_cache_read_tokens: number;
total_cache_write_tokens: number;
task_count: number;
}
export interface RuntimeUsage {
runtime_id: string;
date: string;

View File

@@ -20,6 +20,7 @@ export type {
RuntimePingStatus,
RuntimeUpdate,
RuntimeUpdateStatus,
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";

View File

@@ -70,7 +70,7 @@ import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useActorName } from "@multica/core/workspace/hooks";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueListOptions, issueDetailOptions, childIssuesOptions } from "@multica/core/issues/queries";
import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions } from "@multica/core/issues/queries";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useUpdateIssue, useDeleteIssue } from "@multica/core/issues/mutations";
import { useIssueTimeline } from "../hooks/use-issue-timeline";
@@ -193,6 +193,16 @@ function formatActivity(
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
// ---------------------------------------------------------------------------
// Property row
// ---------------------------------------------------------------------------
@@ -285,6 +295,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id);
// Token usage
const { data: usage } = useQuery(issueUsageOptions(id));
// Sub-issue queries
const parentIssueId = issue?.parent_issue_id;
const { data: parentIssue = null } = useQuery({
@@ -1271,6 +1284,34 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>}
</div>
{/* Token usage */}
{usage && usage.task_count > 0 && (
<div>
<div className="text-xs font-medium mb-2 flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rotate-90" />
Token usage
</div>
<div className="space-y-0.5 pl-2">
<PropRow label="Input">
<span className="text-muted-foreground">{formatTokenCount(usage.total_input_tokens)}</span>
</PropRow>
<PropRow label="Output">
<span className="text-muted-foreground">{formatTokenCount(usage.total_output_tokens)}</span>
</PropRow>
{(usage.total_cache_read_tokens > 0 || usage.total_cache_write_tokens > 0) && (
<PropRow label="Cache">
<span className="text-muted-foreground">
{formatTokenCount(usage.total_cache_read_tokens)} read / {formatTokenCount(usage.total_cache_write_tokens)} write
</span>
</PropRow>
)}
<PropRow label="Runs">
<span className="text-muted-foreground">{usage.task_count}</span>
</PropRow>
</div>
</div>
)}
</div>
</div>
</ResizablePanel>

View File

@@ -175,6 +175,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Get("/active-task", h.GetActiveTaskForIssue)
r.Post("/tasks/{taskId}/cancel", h.CancelTask)
r.Get("/task-runs", h.ListTasksByIssue)
r.Get("/usage", h.GetIssueUsage)
r.Post("/reactions", h.AddIssueReaction)
r.Delete("/reactions", h.RemoveIssueReaction)
r.Get("/attachments", h.ListAttachments)

View File

@@ -627,3 +627,22 @@ func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// GetIssueUsage returns aggregated token usage for all tasks belonging to an issue.
func (h *Handler) GetIssueUsage(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
row, err := h.Queries.GetIssueUsageSummary(r.Context(), parseUUID(issueID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get issue usage")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"total_input_tokens": row.TotalInputTokens,
"total_output_tokens": row.TotalOutputTokens,
"total_cache_read_tokens": row.TotalCacheReadTokens,
"total_cache_write_tokens": row.TotalCacheWriteTokens,
"task_count": row.TaskCount,
})
}

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_agent_task_queue_issue_id;

View File

@@ -0,0 +1,5 @@
-- Add a general index on agent_task_queue(issue_id) to support aggregation
-- queries like GetIssueUsageSummary that scan across all task statuses.
-- (Migration 022 only covers queued/dispatched rows via a partial index.)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agent_task_queue_issue_id
ON agent_task_queue (issue_id);

View File

@@ -11,6 +11,39 @@ import (
"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,
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"`
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.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 FROM task_usage
WHERE task_id = $1

View File

@@ -45,3 +45,14 @@ WHERE a.workspace_id = $1
AND atq.created_at >= @since::timestamptz
GROUP BY tu.model
ORDER BY (SUM(tu.input_tokens) + SUM(tu.output_tokens)) DESC;
-- 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,
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;