feat(schedules): per-check cost and model summary on GET /schedules (INT-3)

agent_check items carry agentRuns (last 100 sessions: last/avg cost, 7-day count and cost,
models, recent 3); plan page and home plan band use it instead of fetching agent runs.
Migration 0052 indexes agent_run(created_by, created_at).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:55:47 +08:00
1 parent 82df5662f0
commit e681fcedf6
9 files changed
+200 -78

No files matched your search

@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
import {
AgentCheckConfig,
type AgentCheckCreate,
type AgentCheckRunStats,
type AgentRunStatus,
type ScheduleBoard,
type ScheduledJob,
type ScheduledJobRun,
@@ -165,6 +167,9 @@ export async function board(db: Kysely<DB>): Promise<ScheduleBoard> {
.orderBy("id")
.execute();
const items = rows.map(jobView);
const checks = items.filter((j) => j.kind === "agent_check").map((j) => j.id);
const stats = await agentCheckRunStats(db, checks, new Date());
for (const j of items) if (j.kind === "agent_check") j.agentRuns = stats.get(j.id) ?? emptyRunStats();
return {
items,
stats: {
@@ -177,6 +182,95 @@ export async function board(db: Kysely<DB>): Promise<ScheduleBoard> {
};
}
/** 巡检费用 / 模型汇总取最近多少次会话 */
export const AGENT_CHECK_STATS_RUNS = 100;
const RECENT_RUNS = 3;
const WEEK_MS = 7 * 86_400_000;
const emptyRunStats = (): AgentCheckRunStats => ({
count: 0,
lastCostUsd: null,
avgCostUsd: null,
weekCount: 0,
weekCostUsd: 0,
lastProvider: null,
lastModel: null,
models: [],
recent: [],
});
/** 各巡检最近 100 次会话(created_by = schedule:<id>,不含子代理)的费用与模型汇总 */
export async function agentCheckRunStats(
db: Kysely<DB>,
jobIds: string[],
now: Date,
): Promise<Map<string, AgentCheckRunStats>> {
const out = new Map<string, AgentCheckRunStats>();
if (!jobIds.length) return out;
const rows = await sql<{
job_id: string;
id: string;
status: AgentRunStatus;
goal: string;
summary: string | null;
provider: string | null;
model: string | null;
cost: number | null;
created_at: Date;
finished_at: Date | null;
}>`
select j.id as job_id, r.*
from unnest(${jobIds}::text[]) as j(id)
cross join lateral (
select id, status, goal, summary, provider, model, (usage->>'costUsd')::float8 as cost, created_at, finished_at
from agent_run
where created_by = 'schedule:' || j.id and parent_run_id is null
order by created_at desc
limit ${AGENT_CHECK_STATS_RUNS}
) r`.execute(db);
const weekFrom = now.getTime() - WEEK_MS;
const costSum = new Map<string, { sum: number; n: number }>();
for (const r of rows.rows) {
const st = out.get(r.job_id) ?? emptyRunStats();
out.set(r.job_id, st);
const cost = typeof r.cost === "number" && Number.isFinite(r.cost) ? r.cost : null;
if (st.count === 0) st.lastCostUsd = cost;
st.count++;
const inWeek = new Date(r.created_at).getTime() >= weekFrom;
if (inWeek) st.weekCount++;
if (cost !== null) {
const c = costSum.get(r.job_id) ?? { sum: 0, n: 0 };
costSum.set(r.job_id, { sum: c.sum + cost, n: c.n + 1 });
if (inWeek) st.weekCostUsd += cost;
}
if (r.model) {
if (!st.lastModel) {
st.lastModel = r.model;
st.lastProvider = r.provider;
}
const m = st.models.find((x) => x.model === r.model && x.provider === r.provider);
if (m) m.count++;
else st.models.push({ provider: r.provider, model: r.model, count: 1 });
}
if (st.recent.length < RECENT_RUNS)
st.recent.push({
id: r.id,
status: r.status,
goal: r.goal,
summary: r.summary,
costUsd: cost,
createdAt: new Date(r.created_at).toISOString(),
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null,
});
}
for (const [id, st] of out) {
const c = costSum.get(id);
st.avgCostUsd = c?.n ? c.sum / c.n : null;
st.models.sort((a, b) => b.count - a.count);
}
return out;
}
export async function updateJob(
db: Kysely<DB>,
id: string,