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:
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,
|
||||
|
||||
Reference in new issue
Block a user