Files
2026-07-13 13:32:57 +08:00

306 lines
9.4 KiB
TypeScript

import {
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskTriggerSource,
} from "@trigger.dev/database";
import { type ClickHouse } from "@internal/clickhouse";
import { z } from "zod";
import { $replica } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
export type AgentListItem = {
slug: string;
filePath: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
config: unknown;
};
export type AgentActiveState = {
running: number;
suspended: number;
};
export class AgentListPresenter {
constructor(private readonly _replica: PrismaClientOrTransaction) {}
public async call({
organizationId,
projectId,
environmentId,
environmentType,
currentWorker: preloadedCurrentWorker,
}: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
/** Optional: pass the pre-resolved current worker to skip the lookup. Used
* by `UnifiedTaskListPresenter` to share one lookup across both presenters. */
currentWorker?: Awaited<ReturnType<typeof findCurrentWorkerFromEnvironment>>;
}) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
const currentWorker =
preloadedCurrentWorker !== undefined
? preloadedCurrentWorker
: await findCurrentWorkerFromEnvironment(
{
id: environmentId,
type: environmentType,
},
this._replica
);
if (!currentWorker) {
return {
agents: [],
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
costSparklines: Promise.resolve({} as Record<string, number[]>),
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
};
}
const agents = await this._replica.backgroundWorkerTask.findMany({
where: {
workerId: currentWorker.id,
triggerSource: "AGENT",
},
select: {
id: true,
slug: true,
filePath: true,
triggerSource: true,
config: true,
createdAt: true,
},
orderBy: {
slug: "asc",
},
});
const slugs = agents.map((a) => a.slug);
if (slugs.length === 0) {
return {
agents,
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
costSparklines: Promise.resolve({} as Record<string, number[]>),
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
};
}
// All queries are deferred for streaming
const activeStates = this.#getActiveStates(clickhouse, environmentId, slugs);
const conversationSparklines = this.#getConversationSparklines(
clickhouse,
environmentId,
slugs
);
const costSparklines = this.#getCostSparklines(clickhouse, environmentId, slugs);
const tokenSparklines = this.#getTokenSparklines(clickhouse, environmentId, slugs);
return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines };
}
/** Count runs currently executing vs suspended per agent */
async #getActiveStates(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, AgentActiveState>> {
const queryFn = clickhouse.reader.query({
name: "agentActiveStates",
query: `SELECT
task_identifier,
countIf(status = 'EXECUTING') AS running,
countIf(status IN ('WAITING_TO_RESUME', 'QUEUED_EXECUTING')) AS suspended
FROM trigger_dev.task_runs_v2
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND task_kind = 'AGENT'
AND status IN ('EXECUTING', 'WAITING_TO_RESUME', 'QUEUED_EXECUTING')
GROUP BY task_identifier`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
running: z.coerce.number(),
suspended: z.coerce.number(),
}),
});
const [error, rows] = await queryFn({ environmentId, slugs });
if (error) {
console.error("Agent active states query failed:", error);
return {};
}
const result: Record<string, AgentActiveState> = {};
for (const row of rows) {
result[row.task_identifier] = { running: row.running, suspended: row.suspended };
}
return result;
}
/** 24h hourly sparkline of conversation (run) count per agent */
async #getConversationSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = clickhouse.reader.query({
name: "agentConversationSparklines",
query: `SELECT
task_identifier,
toStartOfHour(created_at) AS bucket,
count() AS val
FROM trigger_dev.task_runs_v2
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND task_kind = 'AGENT'
AND created_at >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** 24h hourly sparkline of LLM cost per agent */
async #getCostSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = clickhouse.reader.query({
name: "agentCostSparklines",
query: `SELECT
task_identifier,
toStartOfHour(start_time) AS bucket,
sum(total_cost) AS val
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND start_time >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** 24h hourly sparkline of total tokens per agent */
async #getTokenSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = clickhouse.reader.query({
name: "agentTokenSparklines",
query: `SELECT
task_identifier,
toStartOfHour(start_time) AS bucket,
sum(total_tokens) AS val
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND start_time >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** Convert ClickHouse query result to sparkline map with zero-filled 24 hourly buckets */
#buildSparklineMap(
queryResult: [Error, null] | [null, { task_identifier: string; bucket: string; val: number }[]],
slugs: string[]
): Record<string, number[]> {
const [error, rows] = queryResult;
if (error) {
console.error("Agent sparkline query failed:", error);
return {};
}
return this.#buildSparklineFromRows(rows, slugs);
}
#buildSparklineFromRows(
rows: { task_identifier: string; bucket: string; val: number }[],
slugs: string[]
): Record<string, number[]> {
const now = new Date();
const startHour = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours() - 23,
0,
0,
0
)
);
const bucketKeys: string[] = [];
for (let i = 0; i < 24; i++) {
const h = new Date(startHour.getTime() + i * 3600_000);
bucketKeys.push(h.toISOString().slice(0, 13).replace("T", " ") + ":00:00");
}
const rowMap = new Map<string, number>();
for (const row of rows) {
rowMap.set(`${row.task_identifier}|${row.bucket}`, row.val);
}
const result: Record<string, number[]> = {};
for (const slug of slugs) {
result[slug] = bucketKeys.map((key) => rowMap.get(`${slug}|${key}`) ?? 0);
}
return result;
}
}
export const agentListPresenter = singleton("agentListPresenter", setupAgentListPresenter);
function setupAgentListPresenter() {
return new AgentListPresenter($replica);
}