Merge pull request #165 from multica-ai/forrestchang/feature-audit

feat(runtimes): add usage charts, activity heatmap, and hourly distribution
This commit is contained in:
Jiayuan Zhang 2026-03-29 15:46:39 +08:00 committed by GitHub
commit 42f72371bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 657 additions and 6 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import {
Monitor,
Cloud,
@ -13,8 +13,29 @@ import {
XCircle,
Zap,
} from "lucide-react";
import {
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
PieChart,
Pie,
Cell,
Label,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@/components/ui/chart";
import { useDefaultLayout } from "react-resizable-panels";
import type { AgentRuntime, RuntimeUsage, RuntimePingStatus } from "@/shared/types";
import type { AgentRuntime, RuntimeUsage, RuntimeHourlyActivity, RuntimePingStatus } from "@/shared/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -154,6 +175,529 @@ function RuntimeListItem({
// Usage Section
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Chart configs
// ---------------------------------------------------------------------------
const tokenChartConfig = {
input: { label: "Input", color: "hsl(var(--chart-1))" },
output: { label: "Output", color: "hsl(var(--chart-2))" },
cacheRead: { label: "Cache Read", color: "hsl(var(--chart-3))" },
cacheWrite: { label: "Cache Write", color: "hsl(var(--chart-4))" },
} satisfies ChartConfig;
const costChartConfig = {
cost: { label: "Cost", color: "hsl(var(--chart-1))" },
} satisfies ChartConfig;
const MODEL_COLORS = [
"hsl(var(--chart-1))",
"hsl(var(--chart-2))",
"hsl(var(--chart-3))",
"hsl(var(--chart-4))",
"hsl(var(--chart-5))",
];
// ---------------------------------------------------------------------------
// Data aggregation helpers
// ---------------------------------------------------------------------------
interface DailyTokenData {
date: string;
label: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
interface DailyCostData {
date: string;
label: string;
cost: number;
}
interface ModelDistribution {
model: string;
tokens: number;
cost: number;
}
function aggregateByDate(usage: RuntimeUsage[]): {
dailyTokens: DailyTokenData[];
dailyCost: DailyCostData[];
modelDist: ModelDistribution[];
} {
// Aggregate tokens by date
const dateMap = new Map<string, Omit<DailyTokenData, "label">>();
const costMap = new Map<string, number>();
const modelMap = new Map<string, { tokens: number; cost: number }>();
for (const u of usage) {
const existing = dateMap.get(u.date) ?? {
date: u.date,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
existing.input += u.input_tokens;
existing.output += u.output_tokens;
existing.cacheRead += u.cache_read_tokens;
existing.cacheWrite += u.cache_write_tokens;
dateMap.set(u.date, existing);
const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u);
costMap.set(u.date, dayCost);
const modelName = u.model || u.provider;
const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 };
m.tokens += u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
m.cost += estimateCost(u);
modelMap.set(modelName, m);
}
const formatLabel = (d: string) => {
const date = new Date(d + "T00:00:00");
return `${date.getMonth() + 1}/${date.getDate()}`;
};
const dailyTokens = [...dateMap.values()]
.sort((a, b) => a.date.localeCompare(b.date))
.map((d) => ({ ...d, label: formatLabel(d.date) }));
const dailyCost = [...costMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, cost]) => ({ date, label: formatLabel(date), cost: Math.round(cost * 100) / 100 }));
const modelDist = [...modelMap.entries()]
.map(([model, data]) => ({ model, ...data }))
.sort((a, b) => b.tokens - a.tokens);
return { dailyTokens, dailyCost, modelDist };
}
// ---------------------------------------------------------------------------
// Chart Components
// ---------------------------------------------------------------------------
function DailyTokenChart({ data }: { data: DailyTokenData[] }) {
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Token Usage</h4>
<ChartContainer config={tokenChartConfig} className="aspect-[2.5/1] w-full">
<AreaChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(v: number) => formatTokens(v)}
width={50}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) =>
typeof value === "number" ? formatTokens(value) : String(value)
}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Area
type="monotone"
dataKey="input"
stackId="1"
stroke="var(--color-input)"
fill="var(--color-input)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="output"
stackId="1"
stroke="var(--color-output)"
fill="var(--color-output)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="cacheRead"
stackId="1"
stroke="var(--color-cacheRead)"
fill="var(--color-cacheRead)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="cacheWrite"
stackId="1"
stroke="var(--color-cacheWrite)"
fill="var(--color-cacheWrite)"
fillOpacity={0.4}
/>
</AreaChart>
</ChartContainer>
</div>
);
}
function DailyCostChart({ data }: { data: DailyCostData[] }) {
if (data.every((d) => d.cost === 0)) return null;
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Estimated Cost</h4>
<ChartContainer config={costChartConfig} className="aspect-[2.5/1] w-full">
<BarChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(v: number) => `$${v}`}
width={50}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) =>
typeof value === "number" ? `$${value.toFixed(2)}` : String(value)
}
/>
}
/>
<Bar dataKey="cost" fill="var(--color-cost)" radius={[3, 3, 0, 0]} />
</BarChart>
</ChartContainer>
</div>
);
}
function ModelDistributionChart({ data }: { data: ModelDistribution[] }) {
if (data.length === 0) return null;
const totalTokens = data.reduce((sum, d) => sum + d.tokens, 0);
const chartConfig = Object.fromEntries(
data.map((d, i) => [
d.model,
{ label: d.model, color: MODEL_COLORS[i % MODEL_COLORS.length] },
]),
) satisfies ChartConfig;
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Token Usage by Model</h4>
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[200px]">
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) =>
typeof value === "number" ? formatTokens(value) : String(value)
}
nameKey="model"
/>
}
/>
<Pie
data={data}
dataKey="tokens"
nameKey="model"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
>
{data.map((entry, i) => (
<Cell
key={entry.model}
fill={MODEL_COLORS[i % MODEL_COLORS.length]}
/>
))}
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-lg font-bold">
{formatTokens(totalTokens)}
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 18} className="fill-muted-foreground text-xs">
tokens
</tspan>
</text>
);
}
return null;
}}
/>
</Pie>
</PieChart>
</ChartContainer>
{/* Model legend with cost */}
<div className="mt-3 space-y-1.5">
{data.map((d, i) => (
<div key={d.model} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2 min-w-0">
<div
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: MODEL_COLORS[i % MODEL_COLORS.length] }}
/>
<span className="truncate font-mono">{d.model}</span>
</div>
<div className="flex items-center gap-3 shrink-0 text-muted-foreground tabular-nums">
<span>{formatTokens(d.tokens)}</span>
{d.cost > 0 && <span>${d.cost.toFixed(2)}</span>}
</div>
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Activity Heatmap (GitHub-style)
// ---------------------------------------------------------------------------
const HEATMAP_WEEKS = 13;
const CELL_SIZE = 11;
const CELL_GAP = 2;
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
function getHeatmapColor(level: number): string {
// 5 levels: 0=empty, 1-4=increasing intensity
const colors = [
"var(--color-muted, hsl(var(--muted)))",
"hsl(var(--chart-3) / 0.3)",
"hsl(var(--chart-3) / 0.5)",
"hsl(var(--chart-3) / 0.75)",
"hsl(var(--chart-3) / 1)",
];
return colors[level] ?? colors[0]!;
}
function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) {
const { cells, monthLabels } = useMemo(() => {
// Build a map of date -> total tokens
const dateTokens = new Map<string, number>();
for (const u of usage) {
const total = u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
dateTokens.set(u.date, (dateTokens.get(u.date) ?? 0) + total);
}
// Generate all dates for the last HEATMAP_WEEKS weeks
const today = new Date();
const todayDay = today.getDay(); // 0=Sun
// Start from the beginning of the week, HEATMAP_WEEKS weeks ago
const startOffset = todayDay + (HEATMAP_WEEKS - 1) * 7;
const startDate = new Date(today);
startDate.setDate(today.getDate() - startOffset);
const allCells: { date: string; dayOfWeek: number; week: number; tokens: number }[] = [];
const d = new Date(startDate);
for (let i = 0; i <= startOffset; i++) {
const dateStr = d.toISOString().slice(0, 10);
const dayOfWeek = d.getDay(); // 0=Sun .. 6=Sat
const week = Math.floor(i / 7);
allCells.push({ date: dateStr, dayOfWeek, week, tokens: dateTokens.get(dateStr) ?? 0 });
d.setDate(d.getDate() + 1);
}
// Compute intensity levels (quantiles)
const nonZero = allCells.filter((c) => c.tokens > 0).map((c) => c.tokens);
nonZero.sort((a, b) => a - b);
const getLevel = (tokens: number) => {
if (tokens === 0) return 0;
if (nonZero.length <= 1) return 4;
const p = nonZero.indexOf(tokens) / (nonZero.length - 1);
if (p <= 0.25) return 1;
if (p <= 0.5) return 2;
if (p <= 0.75) return 3;
return 4;
};
const cellsWithLevel = allCells.map((c) => ({ ...c, level: getLevel(c.tokens) }));
// Month labels: find the first day of each month that appears
const months: { label: string; week: number }[] = [];
let lastMonth = -1;
for (const c of cellsWithLevel) {
const month = new Date(c.date + "T00:00:00").getMonth();
if (month !== lastMonth && c.dayOfWeek === 0) {
months.push({
label: new Date(c.date + "T00:00:00").toLocaleString("en", { month: "short" }),
week: c.week,
});
lastMonth = month;
}
}
return { cells: cellsWithLevel, monthLabels: months };
}, [usage]);
const labelWidth = 28;
const svgWidth = labelWidth + HEATMAP_WEEKS * (CELL_SIZE + CELL_GAP);
const svgHeight = 14 + 7 * (CELL_SIZE + CELL_GAP);
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Activity</h4>
<div className="overflow-x-auto">
<svg width={svgWidth} height={svgHeight} className="block">
{/* Month labels */}
{monthLabels.map((m) => (
<text
key={`${m.label}-${m.week}`}
x={labelWidth + m.week * (CELL_SIZE + CELL_GAP)}
y={10}
className="fill-muted-foreground"
fontSize={9}
>
{m.label}
</text>
))}
{/* Day labels */}
{DAY_LABELS.map((label, i) =>
label ? (
<text
key={i}
x={0}
y={14 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1}
className="fill-muted-foreground"
fontSize={9}
>
{label}
</text>
) : null,
)}
{/* Cells */}
{cells.map((c) => (
<rect
key={c.date}
x={labelWidth + c.week * (CELL_SIZE + CELL_GAP)}
y={14 + c.dayOfWeek * (CELL_SIZE + CELL_GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
fill={getHeatmapColor(c.level)}
className="transition-colors"
>
<title>
{c.date}: {c.tokens > 0 ? formatTokens(c.tokens) + " tokens" : "No activity"}
</title>
</rect>
))}
</svg>
</div>
{/* Legend */}
<div className="mt-2 flex items-center justify-end gap-1 text-[10px] text-muted-foreground">
<span>Less</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className="h-[10px] w-[10px] rounded-[2px]"
style={{ backgroundColor: getHeatmapColor(level) }}
/>
))}
<span>More</span>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Hourly Activity Distribution
// ---------------------------------------------------------------------------
const hourlyChartConfig = {
count: { label: "Tasks", color: "hsl(var(--chart-2))" },
} satisfies ChartConfig;
function HourlyActivityChart({ runtimeId }: { runtimeId: string }) {
const [data, setData] = useState<RuntimeHourlyActivity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
api
.getRuntimeTaskActivity(runtimeId)
.then(setData)
.catch(() => setData([]))
.finally(() => setLoading(false));
}, [runtimeId]);
// Fill all 24 hours
const chartData = useMemo(() => {
const map = new Map(data.map((d) => [d.hour, d.count]));
return Array.from({ length: 24 }, (_, i) => ({
hour: i,
label: `${i.toString().padStart(2, "0")}:00`,
count: map.get(i) ?? 0,
}));
}, [data]);
const hasData = chartData.some((d) => d.count > 0);
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Hourly Distribution</h4>
{loading ? (
<div className="flex h-[140px] items-center justify-center text-xs text-muted-foreground">
Loading...
</div>
) : !hasData ? (
<div className="flex h-[140px] flex-col items-center justify-center">
<BarChart3 className="h-5 w-5 text-muted-foreground/40" />
<p className="mt-2 text-xs text-muted-foreground">No task data yet</p>
</div>
) : (
<ChartContainer config={hourlyChartConfig} className="aspect-[2.5/1] w-full">
<BarChart data={chartData} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
interval={2}
fontSize={10}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={30}
allowDecimals={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="count" fill="var(--color-count)" radius={[2, 2, 0, 0]} />
</BarChart>
</ChartContainer>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Usage Section
// ---------------------------------------------------------------------------
function UsageSection({ runtimeId }: { runtimeId: string }) {
const [usage, setUsage] = useState<RuntimeUsage[]>([]);
const [loading, setLoading] = useState(true);
@ -161,7 +705,7 @@ function UsageSection({ runtimeId }: { runtimeId: string }) {
useEffect(() => {
setLoading(true);
api
.getRuntimeUsage(runtimeId, { days: 30 })
.getRuntimeUsage(runtimeId, { days: 90 })
.then(setUsage)
.catch(() => setUsage([]))
.finally(() => setLoading(false));
@ -184,8 +728,14 @@ function UsageSection({ runtimeId }: { runtimeId: string }) {
);
}
// Compute totals
const totals = usage.reduce(
// Filter last 30 days for summary / detail charts
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const cutoff = thirtyDaysAgo.toISOString().slice(0, 10);
const recent = usage.filter((u) => u.date >= cutoff);
// Compute totals (30d)
const totals = recent.reduce(
(acc, u) => ({
input: acc.input + u.input_tokens,
output: acc.output + u.output_tokens,
@ -196,9 +746,11 @@ function UsageSection({ runtimeId }: { runtimeId: string }) {
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
);
const { dailyTokens, dailyCost, modelDist } = aggregateByDate(recent);
// Group by date for the table
const byDate = new Map<string, RuntimeUsage[]>();
for (const u of usage) {
for (const u of recent) {
const existing = byDate.get(u.date) ?? [];
existing.push(u);
byDate.set(u.date, existing);
@ -225,6 +777,20 @@ function UsageSection({ runtimeId }: { runtimeId: string }) {
</div>
)}
{/* Heatmap + Hourly — 2-col on wide screens */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<ActivityHeatmap usage={usage} />
<HourlyActivityChart runtimeId={runtimeId} />
</div>
{/* Token & Cost charts — 2-col on wide screens */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<DailyTokenChart data={dailyTokens} />
<DailyCostChart data={dailyCost} />
</div>
<ModelDistributionChart data={modelDist} />
{/* Daily breakdown table */}
<div className="rounded-lg border">
<div className="grid grid-cols-[100px_1fr_80px_80px_80px_80px] gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">

View file

@ -29,6 +29,7 @@ import type {
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
RuntimeUsage,
RuntimeHourlyActivity,
RuntimePing,
TimelineEntry,
} from "@/shared/types";
@ -278,6 +279,10 @@ export class ApiClient {
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
}
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
}
async pingRuntime(runtimeId: string): Promise<RuntimePing> {
return this.fetch(`/api/runtimes/${runtimeId}/ping`, { method: "POST" });
}

View file

@ -166,3 +166,8 @@ export interface RuntimeUsage {
cache_read_tokens: number;
cache_write_tokens: number;
}
export interface RuntimeHourlyActivity {
hour: number;
count: number;
}

View file

@ -18,6 +18,7 @@ export type {
UpdateSkillRequest,
SetAgentSkillsRequest,
RuntimeUsage,
RuntimeHourlyActivity,
RuntimePing,
RuntimePingStatus,
} from "./agent";

View file

@ -163,6 +163,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Route("/api/runtimes", func(r chi.Router) {
r.Get("/", h.ListAgentRuntimes)
r.Get("/{runtimeId}/usage", h.GetRuntimeUsage)
r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity)
r.Post("/{runtimeId}/ping", h.InitiatePing)
r.Get("/{runtimeId}/ping/{pingId}", h.GetPing)
})

View file

@ -159,6 +159,39 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// GetRuntimeTaskActivity returns hourly task activity distribution for a runtime.
func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return
}
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(rt.WorkspaceID), "runtime not found"); !ok {
return
}
rows, err := h.Queries.GetRuntimeTaskHourlyActivity(r.Context(), parseUUID(runtimeID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get task activity")
return
}
type HourlyActivity struct {
Hour int `json:"hour"`
Count int `json:"count"`
}
resp := make([]HourlyActivity, len(rows))
for i, row := range rows {
resp[i] = HourlyActivity{Hour: int(row.Hour), Count: int(row.Count)}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {

View file

@ -11,6 +11,39 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const getRuntimeTaskHourlyActivity = `-- name: GetRuntimeTaskHourlyActivity :many
SELECT EXTRACT(HOUR FROM started_at)::int AS hour, COUNT(*)::int AS count
FROM agent_task_queue
WHERE runtime_id = $1 AND started_at IS NOT NULL
GROUP BY hour
ORDER BY hour
`
type GetRuntimeTaskHourlyActivityRow struct {
Hour int32 `json:"hour"`
Count int32 `json:"count"`
}
func (q *Queries) GetRuntimeTaskHourlyActivity(ctx context.Context, runtimeID pgtype.UUID) ([]GetRuntimeTaskHourlyActivityRow, error) {
rows, err := q.db.Query(ctx, getRuntimeTaskHourlyActivity, runtimeID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRuntimeTaskHourlyActivityRow{}
for rows.Next() {
var i GetRuntimeTaskHourlyActivityRow
if err := rows.Scan(&i.Hour, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRuntimeUsageSummary = `-- name: GetRuntimeUsageSummary :many
SELECT provider, model,
SUM(input_tokens)::bigint AS total_input_tokens,

View file

@ -25,3 +25,10 @@ FROM runtime_usage
WHERE runtime_id = $1
GROUP BY provider, model
ORDER BY provider, model;
-- name: GetRuntimeTaskHourlyActivity :many
SELECT EXTRACT(HOUR FROM started_at)::int AS hour, COUNT(*)::int AS count
FROM agent_task_queue
WHERE runtime_id = $1 AND started_at IS NOT NULL
GROUP BY hour
ORDER BY hour;