diff --git a/.changeset/afraid-gorillas-jump.md b/.changeset/afraid-gorillas-jump.md new file mode 100644 index 0000000000..1734f05349 --- /dev/null +++ b/.changeset/afraid-gorillas-jump.md @@ -0,0 +1,34 @@ +--- +"@trigger.dev/sdk": minor +--- + +Added `query.execute()` which lets you query your Trigger.dev data using TRQL (Trigger Query Language) and returns results as typed JSON rows or CSV. It supports configurable scope (environment, project, or organization), time filtering via `period` or `from`/`to` ranges, and a `format` option for JSON or CSV output. + +```typescript +import { query } from "@trigger.dev/sdk"; +import type { QueryTable } from "@trigger.dev/sdk"; + +// Basic untyped query +const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10"); + +// Type-safe query using QueryTable to pick specific columns +const typedResult = await query.execute>( + "SELECT run_id, status, triggered_at FROM runs LIMIT 10" +); +typedResult.results.forEach(row => { + console.log(row.run_id, row.status); // Fully typed +}); + +// Aggregation query with inline types +const stats = await query.execute<{ status: string; count: number }>( + "SELECT status, COUNT(*) as count FROM runs GROUP BY status", + { scope: "project", period: "30d" } +); + +// CSV export +const csv = await query.execute( + "SELECT run_id, status FROM runs", + { format: "csv", period: "7d" } +); +console.log(csv.results); // Raw CSV string +``` diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index 615def59cd..846d7cae0a 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -4,6 +4,7 @@ import { InformationCircleIcon, RectangleStackIcon, Squares2X2Icon, + TableCellsIcon, TagIcon, } from "@heroicons/react/20/solid"; import { AttemptIcon } from "~/assets/icons/AttemptIcon"; @@ -47,8 +48,6 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { ) { return ; } - - ; } if (!name) return ; @@ -81,6 +80,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { return ; case "function": return ; + case "query": + return ; //log levels case "debug": case "log": @@ -110,7 +111,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { case "task-hook-catchError": return ; case "streams": - return ; + return ; } return ; diff --git a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx index fd39ce15d5..73363e0daa 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx @@ -12,6 +12,7 @@ import { XCircleIcon, } from "@heroicons/react/20/solid"; import type { TaskRunStatus } from "@trigger.dev/database"; +import { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3"; import assertNever from "assert-never"; import { HourglassIcon } from "lucide-react"; import { TimedOutIcon } from "~/assets/icons/TimedOutIcon"; @@ -248,26 +249,9 @@ export function runStatusFromFriendlyTitle(friendly: RunFriendlyStatus): TaskRun return result[0] as TaskRunStatus; } -export const runFriendlyStatus = [ - "Delayed", - "Queued", - "Pending version", - "Dequeued", - "Executing", - "Waiting", - "Reattempting", - "Paused", - "Canceled", - "Interrupted", - "Completed", - "Failed", - "System failure", - "Crashed", - "Expired", - "Timed out", -] as const; - -export type RunFriendlyStatus = (typeof runFriendlyStatus)[number]; +// runFriendlyStatus and RunFriendlyStatus are imported from @trigger.dev/core/v3 +// and re-exported here for backward compatibility. +export { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3"; /** * Check if a value is a valid TaskRunStatus diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts new file mode 100644 index 0000000000..f2a792ce4f --- /dev/null +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -0,0 +1,77 @@ +import { json } from "@remix-run/server-runtime"; +import { QueryError } from "@internal/clickhouse"; +import { z } from "zod"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { executeQuery, type QueryScope } from "~/services/queryService.server"; +import { logger } from "~/services/logger.server"; +import { rowsToCSV } from "~/utils/dataExport"; + +const BodySchema = z.object({ + query: z.string(), + scope: z.enum(["organization", "project", "environment"]).default("environment"), + period: z.string().nullish(), + from: z.string().nullish(), + to: z.string().nullish(), + format: z.enum(["json", "csv"]).default("json"), +}); + +const { action, loader } = createActionApiRoute( + { + body: BodySchema, + corsStrategy: "all", + }, + async ({ body, authentication }) => { + const { query, scope, period, from, to, format } = body; + const env = authentication.environment; + + const queryResult = await executeQuery({ + name: "api-query", + query, + scope: scope as QueryScope, + organizationId: env.organization.id, + projectId: env.project.id, + environmentId: env.id, + period, + from, + to, + history: { + source: "API", + }, + }); + + if (!queryResult.success) { + const message = + queryResult.error instanceof QueryError + ? queryResult.error.message + : "An unexpected error occurred while executing the query."; + + logger.error("Query API error", { + error: queryResult.error, + query, + }); + + return json( + { error: message }, + { status: queryResult.error instanceof QueryError ? 400 : 500 } + ); + } + + const { result, periodClipped, maxQueryPeriod } = queryResult; + + if (format === "csv") { + const csv = rowsToCSV(result.rows, result.columns); + + return json({ + format: "csv", + results: csv, + }); + } + + return json({ + format: "json", + results: result.rows, + }); + } +); + +export { action, loader }; diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 8efbc762ab..428493b71e 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -30,6 +30,9 @@ import { ListScheduleOptions, QueueItem, QueueTypeName, + QueryExecuteRequestBody, + QueryExecuteResponseBody, + QueryExecuteCSVResponseBody, ReplayRunResponse, RescheduleRunRequestBody, ResetIdempotencyKeyResponse, @@ -1406,6 +1409,38 @@ export class ApiClient { ); } + async executeQuery( + query: string, + options?: { + scope?: "environment" | "project" | "organization"; + period?: string; + from?: string; + to?: string; + format?: "json" | "csv"; + }, + requestOptions?: ZodFetchOptions + ): Promise { + const body = { + query, + scope: options?.scope ?? "environment", + period: options?.period, + from: options?.from, + to: options?.to, + format: options?.format ?? "json", + }; + + return zodfetch( + QueryExecuteResponseBody, + `${this.baseUrl}/api/v1/query`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + #getHeaders(spanParentAsLink: boolean, additionalHeaders?: Record) { const headers: Record = { "Content-Type": "application/json", diff --git a/packages/core/src/v3/schemas/index.ts b/packages/core/src/v3/schemas/index.ts index c2b17a72b6..11857d6197 100644 --- a/packages/core/src/v3/schemas/index.ts +++ b/packages/core/src/v3/schemas/index.ts @@ -15,3 +15,4 @@ export * from "./webhooks.js"; export * from "./checkpoints.js"; export * from "./warmStart.js"; export * from "./queues.js"; +export * from "./query.js"; diff --git a/packages/core/src/v3/schemas/query.ts b/packages/core/src/v3/schemas/query.ts new file mode 100644 index 0000000000..44b771e5f1 --- /dev/null +++ b/packages/core/src/v3/schemas/query.ts @@ -0,0 +1,209 @@ +import { TypeOf, z } from "zod"; +import type { MachinePresetName } from "./common.js"; +import type { RuntimeEnvironmentType } from "./common.js"; +import type { IdempotencyKeyScope } from "../idempotency-key-catalog/catalog.js"; + +/** + * Request body schema for executing a query + */ +export const QueryExecuteRequestBody = z.object({ + query: z.string(), + scope: z.enum(["organization", "project", "environment"]).default("environment"), + period: z.string().nullish(), + from: z.string().nullish(), + to: z.string().nullish(), + format: z.enum(["json", "csv"]).default("json"), +}); + +export type QueryExecuteRequestBody = z.infer; + +/** + * Response body schema for JSON format queries + */ +export const QueryExecuteJSONResponseBody = z.object({ + format: z.literal("json"), + results: z.array(z.record(z.any())), +}); + +export type QueryExecuteJSONResponseBody = z.infer; + +/** + * Response body type for CSV format queries + */ +export const QueryExecuteCSVResponseBody = z.object({ + format: z.literal("csv"), + results: z.string(), +}); + +export type QueryExecuteCSVResponseBody = z.infer; + +export const QueryExecuteResponseBody = z.discriminatedUnion("format", [ + QueryExecuteJSONResponseBody, + QueryExecuteCSVResponseBody, +]); +export type QueryExecuteResponseBody = z.infer; + +// --------------------------------------------------------------------------- +// Query table row types +// --------------------------------------------------------------------------- + +/** + * User-facing friendly run status values returned by the query system. + */ +export const runFriendlyStatus = [ + "Delayed", + "Queued", + "Pending version", + "Dequeued", + "Executing", + "Waiting", + "Reattempting", + "Paused", + "Canceled", + "Interrupted", + "Completed", + "Failed", + "System failure", + "Crashed", + "Expired", + "Timed out", +] as const; + +export type RunFriendlyStatus = (typeof runFriendlyStatus)[number]; + +/** + * Full row type for the `runs` query table. + * + * Each property corresponds to a column available in TSQL queries against the + * `runs` table. Types are mapped from the underlying ClickHouse column types: + * + * - `String` → `string` + * - `UInt8` / `UInt32` / `Int64` / `Float64` → `number` + * - `DateTime64` → `string` + * - `Nullable(X)` → `X | null` + * - `Array(String)` → `string[]` + * - `JSON` → `Record` + * - `LowCardinality(String)` with constrained values → narrow union type + */ +export interface RunsTableRow { + /** Unique run ID (e.g. `run_cm1a2b3c4d5e6f7g8h9i`) */ + run_id: string; + /** Environment slug */ + environment: string; + /** Project reference (e.g. `proj_howcnaxbfxdmwmxazktx`) */ + project: string; + /** Environment type */ + environment_type: RuntimeEnvironmentType; + /** Number of attempts (starts at 1) */ + attempt_count: number; + /** Run status (friendly name) */ + status: RunFriendlyStatus; + /** Whether the run is finished (0 or 1) */ + is_finished: number; + /** Task identifier/slug */ + task_identifier: string; + /** Queue name */ + queue: string; + /** Batch ID (if part of a batch), or `null` */ + batch_id: string | null; + /** Root run ID (for child runs), or `null` */ + root_run_id: string | null; + /** Parent run ID (for child runs), or `null` */ + parent_run_id: string | null; + /** Nesting depth (0 for root runs) */ + depth: number; + /** Whether this is a root run (0 or 1) */ + is_root_run: number; + /** Whether this is a child run (0 or 1) */ + is_child_run: number; + /** Idempotency key */ + idempotency_key: string; + /** Idempotency key scope (empty string means no idempotency key is set) */ + idempotency_key_scope: IdempotencyKeyScope | ""; + /** Region, or `null` */ + region: string | null; + /** When the run was triggered (ISO 8601) */ + triggered_at: string; + /** When the run was queued, or `null` */ + queued_at: string | null; + /** When the run was dequeued, or `null` */ + dequeued_at: string | null; + /** When execution began, or `null` */ + executed_at: string | null; + /** When the run completed, or `null` */ + completed_at: string | null; + /** Delayed execution until this time, or `null` */ + delay_until: string | null; + /** Whether the run had a delay (0 or 1) */ + has_delay: number; + /** When the run expired, or `null` */ + expired_at: string | null; + /** TTL string for expiration (e.g. `"10m"`) */ + ttl: string; + /** Time from execution start to completion in ms, or `null` */ + execution_duration: number | null; + /** Time from trigger to completion in ms, or `null` */ + total_duration: number | null; + /** Time from queued to dequeued in ms, or `null` */ + queued_duration: number | null; + /** Compute usage duration in ms */ + usage_duration: number; + /** Compute cost in dollars */ + compute_cost: number; + /** Invocation cost in dollars */ + invocation_cost: number; + /** Total cost in dollars (compute + invocation) */ + total_cost: number; + /** The data returned from the task */ + output: Record; + /** Error data if the run failed */ + error: Record; + /** Tags added to the run */ + tags: string[]; + /** Code version in reverse date format (e.g. `"20240115.1"`) */ + task_version: string; + /** SDK package version */ + sdk_version: string; + /** CLI package version */ + cli_version: string; + /** Machine preset the run executed on */ + machine: MachinePresetName; + /** Whether this is a test run (0 or 1) */ + is_test: number; + /** Concurrency key passed when triggering */ + concurrency_key: string; + /** Max allowed compute duration in seconds, or `null` */ + max_duration: number | null; + /** Bulk action group IDs that operated on this run */ + bulk_action_group_ids: string[]; +} + +/** @internal Map of query table names to their full row types */ +type QueryTableMap = { + runs: RunsTableRow; +}; + +/** + * Type helper for Query results. + * + * @example + * ```typescript + * // All columns from the runs table + * type AllRuns = QueryTable<"runs">; + * + * // Only specific columns + * type MyResult = QueryTable<"runs", "status" | "run_id">; + * + * // Access a single field type + * type Status = QueryTable<"runs">["status"]; // RunFriendlyStatus + * + * // Use with query.execute + * const result = await query.execute>( + * "SELECT status, run_id FROM runs" + * ); + * ``` + */ +export type QueryTable< + TTable extends keyof QueryTableMap, + TColumns extends keyof QueryTableMap[TTable] = keyof QueryTableMap[TTable], +> = Pick; diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index b2d6247699..43ee41e6e5 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -17,6 +17,7 @@ export * from "./otel.js"; export * from "./schemas.js"; export * from "./heartbeats.js"; export * from "./streams.js"; +export * from "./query.js"; export type { Context }; import type { Context } from "./shared.js"; diff --git a/packages/trigger-sdk/src/v3/query.ts b/packages/trigger-sdk/src/v3/query.ts new file mode 100644 index 0000000000..8d86416b27 --- /dev/null +++ b/packages/trigger-sdk/src/v3/query.ts @@ -0,0 +1,184 @@ +import type { + ApiRequestOptions, + Prettify, + QueryExecuteResponseBody, + QueryExecuteCSVResponseBody, +} from "@trigger.dev/core/v3"; +import { apiClientManager, mergeRequestOptions } from "@trigger.dev/core/v3"; +import { tracer } from "./tracer.js"; + +export type { QueryTable, RunsTableRow, RunFriendlyStatus } from "@trigger.dev/core/v3"; + +export type QueryScope = "environment" | "project" | "organization"; +export type QueryFormat = "json" | "csv"; + +/** + * Options for executing a TRQL query + */ +export type QueryOptions = { + /** + * The scope of the query - determines what data is accessible + * - "environment": Current environment only (default) + * - "project": All environments in the project + * - "organization": All projects in the organization + * + * @default "environment" + */ + scope?: QueryScope; + + /** + * Time period to query (e.g., "7d", "30d", "1h") + * Cannot be used with `from` or `to` + */ + period?: string; + + /** + * Start of time range as a Date object or Unix timestamp in milliseconds. + * Must be used with `to`. + */ + from?: Date | number; + + /** + * End of time range as a Date object or Unix timestamp in milliseconds. + * Must be used with `from`. + */ + to?: Date | number; + + /** + * Response format + * - "json": Returns structured data (default) + * - "csv": Returns CSV string + * + * @default "json" + */ + format?: QueryFormat; +}; + +/** + * Execute a TRQL query and get the results as a CSV string. + * + * @param {string} query - The TRQL query string to execute + * @param {QueryOptions & { format: "csv" }} options - Query options with `format: "csv"` + * @param {ApiRequestOptions} [requestOptions] - Optional API request configuration + * @returns A promise resolving to `{ format: "csv", results: string }` where `results` is the raw CSV text + * + * @example + * ```typescript + * const csvResult = await query.execute( + * "SELECT run_id, status, triggered_at FROM runs", + * { format: "csv", period: "7d" } + * ); + * const lines = csvResult.results.split('\n'); + * ``` + */ +function execute( + query: string, + options: QueryOptions & { format: "csv" }, + requestOptions?: ApiRequestOptions +): Promise<{ format: "csv"; results: string }>; + +/** + * Execute a TRQL query and return typed JSON rows. + * + * @template TRow - The shape of each row in the result set. Use {@link QueryTable} for type-safe column access (e.g. `QueryTable<"runs", "status" | "run_id">`) + * @param {string} query - The TRQL query string to execute + * @param {QueryOptions} [options] - Optional query configuration + * @param {ApiRequestOptions} [requestOptions] - Optional API request configuration + * @returns A promise resolving to `{ format: "json", results: Array }` + * + * @example + * ```typescript + * // Basic query with defaults (environment scope, json format) + * const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10"); + * console.log(result.results); // Array> + * + * // Type-safe query using QueryTable with specific columns + * const typedResult = await query.execute>( + * "SELECT run_id, status, triggered_at FROM runs LIMIT 10" + * ); + * typedResult.results.forEach(row => { + * console.log(row.run_id, row.status); // Fully typed! + * }); + * + * // Inline type for aggregation queries + * const stats = await query.execute<{ status: string; count: number }>( + * "SELECT status, COUNT(*) as count FROM runs GROUP BY status" + * ); + * stats.results.forEach(row => { + * console.log(row.status, row.count); // Fully type-safe + * }); + * + * // Query with a custom time period + * const recent = await query.execute( + * "SELECT COUNT(*) as count FROM runs", + * { period: "3d" } + * ); + * console.log(recent.results[0].count); + * ``` + */ +function execute = Record>( + query: string, + options?: Omit | (QueryOptions & { format?: "json" }), + requestOptions?: ApiRequestOptions +): Promise<{ format: "json"; results: Array> }>; + +// Implementation +function execute = Record>( + query: string, + options?: QueryOptions, + requestOptions?: ApiRequestOptions +): Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }> { + const apiClient = apiClientManager.clientOrThrow(); + + const from = dateToISOString(options?.from); + const to = dateToISOString(options?.to); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "query.execute()", + icon: "query", + attributes: { + scope: options?.scope ?? "environment", + format: options?.format ?? "json", + query, + period: options?.period, + from, + to, + }, + }, + requestOptions + ); + + return apiClient + .executeQuery( + query, + { + scope: options?.scope, + period: options?.period, + from, + to, + format: options?.format, + }, + $requestOptions + ) + .then((response) => { + return response; + }) as Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }>; +} + +function dateToISOString(date: Date | number | undefined): string | undefined { + if (date === undefined) { + return undefined; + } + + if (date instanceof Date) { + return date.toISOString(); + } + + return new Date(date).toISOString(); +} + +export const query = { + execute, +}; diff --git a/references/hello-world/src/trigger/query.ts b/references/hello-world/src/trigger/query.ts new file mode 100644 index 0000000000..bad268470c --- /dev/null +++ b/references/hello-world/src/trigger/query.ts @@ -0,0 +1,183 @@ +import { logger, query, task } from "@trigger.dev/sdk"; +import type { QueryTable } from "@trigger.dev/sdk"; + +// Simple query example - tests different from/to formats +export const simpleQueryTask = task({ + id: "simple-query", + run: async () => { + logger.info("Running simple query example"); + + // 1. Default: no from/to, uses default period + const defaultResult = await query.execute("SELECT * FROM runs LIMIT 5"); + logger.info("Default (no from/to)", { + rowCount: defaultResult.results.length, + firstRow: defaultResult.results[0], + }); + + // 2. Using Date objects for from/to + const withDates = await query.execute< + QueryTable<"runs", "run_id" | "status" | "triggered_at"> + >("SELECT run_id, status, triggered_at FROM runs LIMIT 5", { + from: new Date("2025-01-01T00:00:00Z"), + to: new Date(), + }); + logger.info("With Date objects", { + rowCount: withDates.results.length, + firstRow: withDates.results[0], + }); + + // 3. Using Unix timestamps in milliseconds (Date.now() returns ms) + const now = Date.now(); + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; + const withTimestamps = await query.execute< + QueryTable<"runs", "run_id" | "status" | "triggered_at"> + >("SELECT run_id, status, triggered_at FROM runs LIMIT 5", { + from: sevenDaysAgo, + to: now, + }); + logger.info("With Unix timestamps (ms)", { + rowCount: withTimestamps.results.length, + firstRow: withTimestamps.results[0], + }); + + // 4. Mixing Date and number + const mixed = await query.execute< + QueryTable<"runs", "run_id" | "status" | "triggered_at"> + >("SELECT run_id, status, triggered_at FROM runs LIMIT 5", { + from: new Date("2025-01-01"), + to: Date.now(), + }); + logger.info("Mixed Date + timestamp", { + rowCount: mixed.results.length, + firstRow: mixed.results[0], + }); + + return { + defaultRows: defaultResult.results.length, + dateRows: withDates.results.length, + timestampRows: withTimestamps.results.length, + mixedRows: mixed.results.length, + }; + }, +}); + +// JSON query with all options - aggregation queries use inline types +export const fullJsonQueryTask = task({ + id: "full-json-query", + run: async () => { + logger.info("Running full JSON query example with all options"); + + // For aggregation queries, use inline types since the result shape + // doesn't match a table row. For non-aggregated queries, use QueryTable. + const result = await query.execute<{ + status: string; + count: number; + avg_duration: number; + }>( + `SELECT + status, + COUNT(*) as count, + AVG(total_duration) as avg_duration + FROM runs + WHERE status IN ('Completed', 'Failed') + GROUP BY status`, + { + scope: "environment", // Query current environment only + period: "30d", // Last 30 days of data + // format defaults to "json" + } + ); + + logger.info("Query completed", { + format: result.format, + rowCount: result.results.length, + }); + + // Log the aggregated results - now fully type-safe! + result.results.forEach((row) => { + logger.info("Status breakdown", { + status: row.status, // string + count: row.count, // number + averageDuration: row.avg_duration, // number + }); + }); + + return { + summary: result.results, + }; + }, +}); + +// CSV export example +export const csvQueryTask = task({ + id: "csv-query", + run: async () => { + logger.info("Running CSV query example"); + + // Query with CSV format - automatically typed as discriminated union! + const result = await query.execute( + "SELECT run_id, status, triggered_at, total_duration FROM runs LIMIT 10", + { + scope: "project", // Query all environments in the project + period: "7d", // Last 7 days + format: "csv", // CSV format + } + ); + + // result.format is "csv" and result.results is automatically typed as string! + logger.info("CSV query completed", { + format: result.format, + dataLength: result.results.length, + results: result.results, + }); + + return { + format: result.format, + csv: result.results, + }; + }, +}); + +// Organization-wide query with QueryTable for full row access +export const orgQueryTask = task({ + id: "org-query", + run: async () => { + logger.info("Running organization-wide query"); + + // Use QueryTable to get typed rows for specific columns + const result = await query.execute< + QueryTable<"runs", "run_id" | "project" | "environment" | "status" | "task_identifier" | "machine"> + >( + `SELECT run_id, project, environment, status, task_identifier, machine + FROM runs + ORDER BY triggered_at DESC + LIMIT 50`, + { + scope: "organization", // Query across all projects + from: new Date("2025-02-01T00:00:00Z"), // Custom date range + to: new Date("2025-02-11T23:59:59Z"), + } + ); + + logger.info("Organization query completed", { + format: result.format, + runCount: result.results.length, + }); + + // Fully typed - status is RunFriendlyStatus, machine is MachinePresetName + result.results.forEach((row) => { + logger.info("Run info", { + runId: row.run_id, // string + project: row.project, // string + environment: row.environment, // string + status: row.status, // "Completed" | "Failed" | "Executing" | ... + task: row.task_identifier, // string + machine: row.machine, // "micro" | "small-1x" | "small-2x" | ... + }); + }); + + return { + runs: result.results, + }; + }, +});