306 lines
9.4 KiB
TypeScript
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);
|
|
}
|