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

276 lines
8.9 KiB
TypeScript

import { type ClickHouse } from "@internal/clickhouse";
import {
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskRunStatus,
type TaskTriggerSource,
} from "@trigger.dev/database";
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";
import { agentListPresenter, type AgentActiveState } from "./AgentListPresenter.server";
import { taskListPresenter, type TaskListItem } from "./TaskListPresenter.server";
export type UnifiedTaskKind = "STANDARD" | "SCHEDULED" | "AGENT";
export type UnifiedTaskListItem = {
kind: UnifiedTaskKind;
slug: string;
filePath: string;
triggerSource: TaskTriggerSource;
createdAt: Date;
/** Agent-only: parsed `config.type` used for the Type badge. */
agentType?: string;
};
export type UnifiedRunningState =
| { kind: "task"; running: number }
| { kind: "agent"; running: number; suspended: number };
export type UnifiedRunningStates = Record<string, UnifiedRunningState>;
/** One hour bucket: the bucket start date, a total count for axis scaling,
* and per-status counts (sparse — only statuses that occurred are present). */
export type HourlyTaskActivityBucket = {
date: Date;
total: number;
} & Partial<Record<TaskRunStatus, number>>;
/** 24h hourly stacked-by-status series keyed by task slug. */
export type HourlyTaskActivity = Record<string, HourlyTaskActivityBucket[]>;
export class UnifiedTaskListPresenter {
constructor(private readonly _replica: PrismaClientOrTransaction) {}
public async call(args: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
}): Promise<{
items: UnifiedTaskListItem[];
hourlyActivity: Promise<HourlyTaskActivity>;
runningStates: Promise<UnifiedRunningStates>;
}> {
// Share the current-worker lookup across both inner presenters — without
// this they'd each do an independent Postgres round-trip for the same row.
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: args.environmentId, type: args.environmentType },
this._replica
);
const [taskResult, agentResult] = await Promise.all([
taskListPresenter.call({ ...args, currentWorker }),
agentListPresenter.call({ ...args, currentWorker }),
]);
const items = toUnifiedItems(taskResult.tasks, agentResult.agents);
const allSlugs = items.map((item) => item.slug);
const hourlyActivity: Promise<HourlyTaskActivity> =
allSlugs.length === 0
? Promise.resolve({})
: (async () => {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
args.organizationId,
"standard"
);
return getHourlyTaskActivity(clickhouse, {
organizationId: args.organizationId,
projectId: args.projectId,
environmentId: args.environmentId,
slugs: allSlugs,
});
})();
const runningStates: Promise<UnifiedRunningStates> = Promise.all([
taskResult.runningStats,
agentResult.activeStates,
]).then(([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates));
return { items, hourlyActivity, runningStates };
}
}
/** Query trigger_dev.task_runs_v2 for run counts per (hour, status) over the
* past 24h, grouped by task slug.
*
* Uses FINAL + _is_deleted = 0 because task_runs_v2 is a ReplacingMergeTree;
* org/project filters engage the sort-key prefix for partition pruning. */
async function getHourlyTaskActivity(
clickhouse: ClickHouse,
args: {
organizationId: string;
projectId: string;
environmentId: string;
slugs: string[];
}
): Promise<HourlyTaskActivity> {
// Align the lower bound to the start of the hour 23h ago so the query
// returns exactly 24 distinct hour buckets, matching the JS-side key array.
// `now() - INTERVAL 24 HOUR` would let a 25th (oldest) bucket slip in for
// any request made past the top of an hour, and those runs would be
// silently dropped from the chart.
const queryFn = clickhouse.reader.query({
name: "unifiedTaskListHourlyActivity",
query: `SELECT
task_identifier,
toStartOfHour(created_at) AS bucket,
status,
count() AS val
FROM trigger_dev.task_runs_v2 FINAL
WHERE organization_id = {organizationId: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND created_at >= toStartOfHour(now() - INTERVAL 23 HOUR)
AND _is_deleted = 0
GROUP BY task_identifier, bucket, status
ORDER BY task_identifier, bucket, status`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
status: z.string(),
val: z.coerce.number(),
}),
});
const [error, rows] = await queryFn(args);
if (error) {
console.error("Unified task list hourly activity query failed:", error);
return {};
}
// 24 hourly buckets ending at the current hour. Keys match ClickHouse's
// `toStartOfHour(created_at)` formatting ("YYYY-MM-DD HH:00:00").
const now = new Date();
const startHour = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours() - 23,
0,
0,
0
)
);
const buckets: { key: string; date: Date }[] = [];
for (let i = 0; i < 24; i++) {
const date = new Date(startHour.getTime() + i * 3_600_000);
const key = date.toISOString().slice(0, 13).replace("T", " ") + ":00:00";
buckets.push({ key, date });
}
// slug → bucketKey → bucket payload (per-status counts + total)
const slugBuckets = new Map<string, Map<string, HourlyTaskActivityBucket>>();
for (const row of rows ?? []) {
let perSlug = slugBuckets.get(row.task_identifier);
if (!perSlug) {
perSlug = new Map();
slugBuckets.set(row.task_identifier, perSlug);
}
let bucket = perSlug.get(row.bucket);
if (!bucket) {
bucket = { date: new Date(0), total: 0 };
perSlug.set(row.bucket, bucket);
}
const status = row.status as TaskRunStatus;
bucket[status] = (bucket[status] ?? 0) + row.val;
bucket.total += row.val;
}
const result: HourlyTaskActivity = {};
for (const slug of args.slugs) {
const perSlug = slugBuckets.get(slug);
result[slug] = buckets.map(({ key, date }) => {
const existing = perSlug?.get(key);
if (!existing) return { date, total: 0 };
return { ...existing, date };
});
}
return result;
}
function toUnifiedItems(
tasks: TaskListItem[],
agents: Array<{
slug: string;
filePath: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
config: unknown;
}>
): UnifiedTaskListItem[] {
const items: UnifiedTaskListItem[] = [];
for (const task of tasks) {
items.push({
kind: task.triggerSource === "SCHEDULED" ? "SCHEDULED" : "STANDARD",
slug: task.slug,
filePath: task.filePath,
triggerSource: task.triggerSource,
createdAt: task.createdAt,
});
}
for (const agent of agents) {
items.push({
kind: "AGENT",
slug: agent.slug,
filePath: agent.filePath,
triggerSource: agent.triggerSource,
createdAt: agent.createdAt,
agentType: (agent.config as { type?: string } | null)?.type,
});
}
items.sort((a, b) => a.slug.localeCompare(b.slug));
return items;
}
function mergeRunningStates(
runningStats: Record<string, { running: number; queued: number }>,
activeStates: Record<string, AgentActiveState>
): UnifiedRunningStates {
const out: UnifiedRunningStates = {};
for (const [slug, stats] of Object.entries(runningStats)) {
out[slug] = { kind: "task", running: stats?.running ?? 0 };
}
for (const [slug, state] of Object.entries(activeStates)) {
// Guard against slug collisions: a single BackgroundWorker isn't expected
// to have both a STANDARD/SCHEDULED task and an AGENT task with the same
// slug, but nothing in the schema enforces it. If it ever happened, the
// standard-task running count would be silently dropped and the row would
// get relabelled as an agent.
if (slug in out) continue;
out[slug] = {
kind: "agent",
running: state?.running ?? 0,
suspended: state?.suspended ?? 0,
};
}
return out;
}
function setupUnifiedTaskListPresenter() {
return new UnifiedTaskListPresenter($replica);
}
export const unifiedTaskListPresenter = singleton(
"unifiedTaskListPresenter",
setupUnifiedTaskListPresenter
);