239 lines · 6.3 KB · TypeScript
RawDownload
import { logger } from "./logger.js"; const baseUrl = process.env.INNGEST_BASE_URL ?? "";const signingKey = process.env.INNGEST_SIGNING_KEY ?? ""; const DEFAULT_MAX_EVENTS = 500;const MAX_EVENTS = DEFAULT_MAX_EVENTS; /** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */type InngestEventRow = {	internal_id?: string;	accountID?: string;	environmentID?: string;	source?: string;	sourceID?: string | null;	/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */	receivedAt?: string;	received_at?: string;	id: string;	name: string;	data: Record<string, unknown>;	user?: unknown;	ts: number;	v?: string | null;	metadata?: {		fetchedAt: string;		cachedUntil: string | null;	};}; /** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */type InngestRun = {	run_id: string;	event_id: string;	status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?	run_started_at?: string;	ended_at?: string | null;	output?: unknown;	// dev server / API may use different casing	run_started_at_ms?: number;}; function getEventReceivedAt(ev: InngestEventRow): string | undefined {	return ev.receivedAt ?? ev.received_at;} /** Map Inngest run status to BullMQ-style state for the UI */function runStatusToState(	status: string,): "pending" | "active" | "completed" | "failed" | "cancelled" {	const s = status.toLowerCase();	if (s === "running") return "active";	if (s === "completed") return "completed";	if (s === "failed") return "failed";	if (s === "cancelled") return "cancelled";	if (s === "queued") return "pending";	return "pending";} export const fetchInngestEvents = async () => {	const maxEvents = MAX_EVENTS;	const all: InngestEventRow[] = [];	let cursor: string | undefined; 	do {		const params = new URLSearchParams({ limit: "100" });		if (cursor) {			params.set("cursor", cursor);		} 		const res = await fetch(`${baseUrl}/v1/events?${params}`, {			headers: {				Authorization: `Bearer ${signingKey}`,				"Content-Type": "application/json",			},		}); 		if (!res.ok) {			logger.warn("Inngest API error", {				status: res.status,				body: await res.text(),			});			break;		} 		const body = (await res.json()) as {			data?: InngestEventRow[];			cursor?: string;			nextCursor?: string;		};		const data = Array.isArray(body.data) ? body.data : [];		all.push(...data); 		// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)		const nextCursor =			body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;		const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;		cursor = hasMore ? nextCursor : undefined;	} while (cursor); 	return all.slice(0, maxEvents);}; /** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */export const fetchInngestRunsForEvent = async (	eventId: string,): Promise<InngestRun[]> => {	const res = await fetch(		`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,		{			headers: {				Authorization: `Bearer ${signingKey}`,				"Content-Type": "application/json",			},		},	);	if (!res.ok) {		logger.warn("Inngest runs API error", {			eventId,			status: res.status,			body: await res.text(),		});		return [];	}	const body = (await res.json()) as { data?: InngestRun[] };	return Array.isArray(body.data) ? body.data : [];}; /** One row for the queue UI (BullMQ-compatible shape) */export type DeploymentJobRow = {	id: string;	name: string;	data: Record<string, unknown>;	timestamp: number;	processedOn?: number;	finishedOn?: number;	failedReason?: string;	state: string;}; /** Build queue rows from events + their runs (one row per run, or pending if no run yet) */function buildDeploymentRowsFromRuns(	events: InngestEventRow[],	runsByEventId: Map<string, InngestRun[]>,	serverId: string,): DeploymentJobRow[] {	const requested = events.filter(		(e) =>			e.name === "deployment/requested" &&			(e.data as Record<string, unknown>)?.serverId === serverId,	);	const rows: DeploymentJobRow[] = []; 	for (const ev of requested) {		const data = (ev.data ?? {}) as Record<string, unknown>;		const runs = runsByEventId.get(ev.id) ?? []; 		if (runs.length === 0) {			// Queued: event received but no run yet			rows.push({				id: ev.id,				name: ev.name,				data,				timestamp: ev.ts,				processedOn: ev.ts,				finishedOn: undefined,				failedReason: undefined,				state: "pending",			});			continue;		} 		for (const run of runs) {			const state = runStatusToState(run.status);			const runStartedMs =				run.run_started_at_ms ??				(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);			const endedMs = run.ended_at				? new Date(run.ended_at).getTime()				: undefined;			const failedReason =				state === "failed" &&				run.output &&				typeof run.output === "object" &&				"error" in run.output					? String((run.output as { error?: unknown }).error)					: undefined; 			rows.push({				id: run.run_id,				name: ev.name,				data,				timestamp: runStartedMs,				processedOn: runStartedMs,				finishedOn:					state === "completed" || state === "failed" || state === "cancelled"						? endedMs						: undefined,				failedReason,				state,			});		}	} 	return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));} /** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */export const fetchDeploymentJobs = async (	serverId: string,): Promise<DeploymentJobRow[]> => {	if (!signingKey) {		logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");		return [];	}	if (!baseUrl) {		throw new Error("INNGEST_BASE_URL is required to list deployment jobs");	} 	const events = await fetchInngestEvents(); 	const requestedForServer = events.filter(		(e) =>			e.name === "deployment/requested" &&			(e.data as Record<string, unknown>)?.serverId === serverId,	);	// Limit to avoid too many run fetches	const toFetch = requestedForServer.slice(0, 50);	const runsByEventId = new Map<string, InngestRun[]>(); 	await Promise.all(		toFetch.map(async (ev) => {			const runs = await fetchInngestRunsForEvent(ev.id);			runsByEventId.set(ev.id, runs);		}),	); 	return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);};