diff --git a/.changeset/define-runevent-schema.md b/.changeset/define-runevent-schema.md new file mode 100644 index 00000000000..531ca78f234 --- /dev/null +++ b/.changeset/define-runevent-schema.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": minor +--- + +Define RunEvent schema and update ApiClient to use it diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 1ac161d3e4a..0b88ba7cd69 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -501,9 +501,8 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const version = deployment.version; const rawDeploymentLink = `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`; - const rawTestLink = `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`; + const rawTestLink = `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project + }/test?environment=${options.env === "prod" ? "prod" : "stg"}`; const deploymentLink = cliLink("View deployment", rawDeploymentLink); const testLink = cliLink("Test tasks", rawTestLink); @@ -720,8 +719,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } } else { outro( - `Version ${version} deployed with ${taskCount} detected task${taskCount === 1 ? "" : "s"} ${ - isLinksSupported ? `| ${deploymentLink} | ${testLink}` : "" + `Version ${version} deployed with ${taskCount} detected task${taskCount === 1 ? "" : "s"} ${isLinksSupported ? `| ${deploymentLink} | ${testLink}` : "" }` ); @@ -745,18 +743,16 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { TRIGGER_VERSION: version, TRIGGER_DEPLOYMENT_SHORT_CODE: deployment.shortCode, TRIGGER_DEPLOYMENT_URL: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - TRIGGER_TEST_URL: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, + TRIGGER_TEST_URL: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project + }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, }, outputs: { deploymentVersion: version, workerVersion: version, deploymentShortCode: deployment.shortCode, deploymentUrl: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - testUrl: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, + testUrl: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project + }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, needsPromotion: options.skipPromotion ? "true" : "false", }, }); @@ -799,8 +795,7 @@ async function failDeploy( checkLogsForErrors(logs); outro( - `${chalkError(`${prefix}:`)} ${ - error.message + `${chalkError(`${prefix}:`)} ${error.message }. Full build logs have been saved to ${logPath}` ); @@ -1100,9 +1095,8 @@ async function handleNativeBuildServerDeploy({ const deployment = initializeDeploymentResult.data; const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`; - const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${ - options.env === "prod" ? "prod" : "stg" - }`; + const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${options.env === "prod" ? "prod" : "stg" + }`; const exposedDeploymentLink = isLinksSupported ? cliLink(chalk.bold(rawDeploymentLink), rawDeploymentLink) @@ -1155,8 +1149,9 @@ async function handleNativeBuildServerDeploy({ const [readSessionError, readSession] = await tryCatch( stream.readSession( { - start: { from: { seqNum: 0 }, clamp: true }, - stop: { waitSecs: 60 * 20 }, // 20 minutes + start: { + from: { seqNum: 0 }, + }, }, { signal: abortController.signal } ) @@ -1167,8 +1162,7 @@ async function handleNativeBuildServerDeploy({ log.warn(`Failed streaming build logs, open the deployment in the dashboard to view the logs`); outro( - `Version ${deployment.version} is being deployed ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} is being deployed ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); @@ -1214,10 +1208,10 @@ async function handleNativeBuildServerDeploy({ level === "error" ? chalk.bold(chalkError(message)) : level === "warn" - ? chalkWarning(message) - : level === "debug" - ? chalkGrey(message) - : message; + ? chalkWarning(message) + : level === "debug" + ? chalkGrey(message) + : message; // We use console.log here instead of clack's logger as the current version does not support changing the line spacing. // And the logs look verbose with the default spacing. @@ -1250,8 +1244,7 @@ async function handleNativeBuildServerDeploy({ log.error("Failed dequeueing build, please try again shortly"); throw new OutroCommandError( - `Version ${deployment.version} ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1266,8 +1259,7 @@ async function handleNativeBuildServerDeploy({ } throw new OutroCommandError( - `Version ${deployment.version} ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1293,13 +1285,12 @@ async function handleNativeBuildServerDeploy({ } outro( - `Version ${deployment.version} was deployed ${ - isLinksSupported - ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( - "View deployment", - rawDeploymentLink - )}` - : "" + `Version ${deployment.version} was deployed ${isLinksSupported + ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( + "View deployment", + rawDeploymentLink + )}` + : "" }` ); return process.exit(0); @@ -1313,14 +1304,13 @@ async function handleNativeBuildServerDeploy({ chalk.bold( chalkError( "Deployment failed" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") ) ) ); throw new OutroCommandError( - `Version ${deployment.version} deployment failed ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} deployment failed ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1333,14 +1323,13 @@ async function handleNativeBuildServerDeploy({ chalk.bold( chalkError( "Deployment timed out" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") ) ) ); throw new OutroCommandError( - `Version ${deployment.version} deployment timed out ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} deployment timed out ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1353,14 +1342,13 @@ async function handleNativeBuildServerDeploy({ chalk.bold( chalkError( "Deployment was canceled" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") ) ) ); throw new OutroCommandError( - `Version ${deployment.version} deployment canceled ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} deployment canceled ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1379,13 +1367,12 @@ async function handleNativeBuildServerDeploy({ } outro( - `Version ${deployment.version} ${ - isLinksSupported - ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( - "View deployment", - rawDeploymentLink - )}` - : "" + `Version ${deployment.version} ${isLinksSupported + ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( + "View deployment", + rawDeploymentLink + )}` + : "" }` ); return process.exit(0); diff --git a/packages/cli-v3/tsc_output.txt b/packages/cli-v3/tsc_output.txt new file mode 100644 index 00000000000..c6eacfa2191 Binary files /dev/null and b/packages/cli-v3/tsc_output.txt differ diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 7de6e275fc4..ace3f8e8210 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -28,6 +28,7 @@ import { EnvironmentVariableResponseBody, EnvironmentVariableWithSecret, ListQueueOptions, + ListRunEventsResponse, ListRunResponseItem, ListScheduleOptions, QueueItem, @@ -42,6 +43,7 @@ import { RetrieveQueueParam, RetrieveRunResponse, RetrieveRunTraceResponseBody, + RunEvent, ScheduleObject, SendInputStreamResponseBody, StreamBatchItemsResponse, @@ -700,7 +702,7 @@ export class ApiClient { listRunEvents(runId: string, requestOptions?: ZodFetchOptions) { return zodfetch( - z.any(), // TODO: define a proper schema for this + ListRunEventsResponse, `${this.baseUrl}/api/v1/runs/${runId}/events`, { method: "GET", diff --git a/packages/core/src/v3/realtimeStreams/streamsWriterV2.ts b/packages/core/src/v3/realtimeStreams/streamsWriterV2.ts index 91713630dbe..4dbccb383ff 100644 --- a/packages/core/src/v3/realtimeStreams/streamsWriterV2.ts +++ b/packages/core/src/v3/realtimeStreams/streamsWriterV2.ts @@ -61,11 +61,11 @@ export class StreamsWriterV2 implements StreamsWriter { accessToken: options.accessToken, ...(options.endpoint ? { - endpoints: { - account: options.endpoint, - basin: options.endpoint, - }, - } + endpoints: { + account: options.endpoint, + basin: options.endpoint, + }, + } : {}), }); this.flushIntervalMs = options.flushIntervalMs ?? 200; @@ -152,7 +152,9 @@ export class StreamsWriterV2 implements StreamsWriter { return; } // Convert each chunk to JSON string and wrap in AppendRecord - controller.enqueue(AppendRecord.string({ body: JSON.stringify({ data: chunk, id: nanoid(7) }) })); + controller.enqueue( + AppendRecord.string({ body: JSON.stringify({ data: chunk, id: nanoid(7) }) }) + ); }, }) ) @@ -223,5 +225,5 @@ async function* streamToAsyncIterator(stream: ReadableStream): AsyncIterab function safeReleaseLock(reader: ReadableStreamDefaultReader) { try { reader.releaseLock(); - } catch (error) {} + } catch (error) { } } diff --git a/packages/core/src/v3/schemas/api-type.test.ts b/packages/core/src/v3/schemas/api-type.test.ts index c936b3c769d..2c9f3a421d3 100644 --- a/packages/core/src/v3/schemas/api-type.test.ts +++ b/packages/core/src/v3/schemas/api-type.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { InitializeDeploymentRequestBody } from "./api.js"; +import { InitializeDeploymentRequestBody, RunEvent, ListRunEventsResponse, ListRunEventsResponseWithStringDates } from "./api.js"; import type { InitializeDeploymentRequestBody as InitializeDeploymentRequestBodyType } from "./api.js"; describe("InitializeDeploymentRequestBody", () => { @@ -139,3 +139,202 @@ describe("InitializeDeploymentRequestBody", () => { }); }); }); + +describe("RunEvent Schema", () => { + const validEvent = { + spanId: "span_123", + parentId: "span_root", + runId: "run_abc", + message: "Test event", + style: { + icon: "task", + variant: "primary", + }, + startTime: "2024-03-14T00:00:00Z", + duration: 1234, + isError: false, + isPartial: false, + isCancelled: false, + level: "INFO", + kind: "TASK", + attemptNumber: 1, + }; + + it("parses a valid event correctly", () => { + const result = RunEvent.safeParse(validEvent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.spanId).toBe("span_123"); + expect(result.data.startTime).toBeInstanceOf(Date); + expect(result.data.level).toBe("INFO"); + } + }); + + it("fails on missing required fields", () => { + const invalidEvent = { ...validEvent }; + delete (invalidEvent as any).spanId; + const result = RunEvent.safeParse(invalidEvent); + expect(result.success).toBe(false); + }); + + it("fails on invalid level", () => { + const invalidEvent = { ...validEvent, level: "INVALID_LEVEL" }; + const result = RunEvent.safeParse(invalidEvent); + expect(result.success).toBe(false); + }); + + it("coerces startTime to Date", () => { + const result = RunEvent.parse(validEvent); + expect(result.startTime).toBeInstanceOf(Date); + expect(result.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z"); + }); + + it("handles 19-digit nanosecond startTime strings", () => { + const event = { ...validEvent, startTime: "1710374400000000000" }; + const result = RunEvent.parse(event); + expect(result.startTime).toBeInstanceOf(Date); + // 1710374400000000000 ns = 1710374400000 ms = 2024-03-14T00:00:00Z + expect(result.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z"); + }); + + it("should handle Date object", () => { + const now = new Date(); + const result = RunEvent.safeParse({ + ...validEvent, + startTime: now, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.startTime.toISOString()).toBe(now.toISOString()); + } + }); + + it("handles bigint nanosecond startTime", () => { + const event = { ...validEvent, startTime: 1710374400000000000n }; + const result = RunEvent.parse(event as any); + expect(result.startTime).toBeInstanceOf(Date); + expect(result.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z"); + }); + + it("fails on invalid startTime", () => { + const event = { ...validEvent, startTime: "not-a-date" }; + const result = RunEvent.safeParse(event); + expect(result.success).toBe(false); + }); + + describe("startTime edge cases", () => { + it("should handle whitespace-padded strings", () => { + const result = RunEvent.safeParse({ + ...validEvent, + startTime: " 2024-03-14T00:00:00Z ", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z"); + } + }); + + it("should handle whitespace-padded nanosecond strings", () => { + const result = RunEvent.safeParse({ + ...validEvent, + startTime: " 1710374400000000000 ", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z"); + } + }); + + it("should fail on empty string", () => { + const result = RunEvent.safeParse({ + ...validEvent, + startTime: "", + }); + expect(result.success).toBe(false); + }); + + it("should fail on whitespace-only string", () => { + const result = RunEvent.safeParse({ + ...validEvent, + startTime: " ", + }); + expect(result.success).toBe(false); + }); + }); + + it("allows optional/null parentId", () => { + const eventWithoutParent = { ...validEvent }; + delete (eventWithoutParent as any).parentId; + expect(RunEvent.safeParse(eventWithoutParent).success).toBe(true); + + const eventWithNullParent = { ...validEvent, parentId: null }; + expect(RunEvent.safeParse(eventWithNullParent).success).toBe(true); + }); + + it("allows nullish attemptNumber", () => { + const eventWithNullAttempt = { ...validEvent, attemptNumber: null }; + const result = RunEvent.safeParse(eventWithNullAttempt); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.attemptNumber).toBe(null); + } + + const eventWithoutAttempt = { ...validEvent }; + delete (eventWithoutAttempt as any).attemptNumber; + const result2 = RunEvent.safeParse(eventWithoutAttempt); + expect(result2.success).toBe(true); + }); + + it("supports taskSlug", () => { + const eventWithSlug = { ...validEvent, taskSlug: "my-task" }; + const result = RunEvent.parse(eventWithSlug); + expect(result.taskSlug).toBe("my-task"); + }); + + it("ListRunEventsResponseWithStringDates correctly transforms Dates to strings", () => { + const rawResponse = { + events: [validEvent], + }; + + const parsed = ListRunEventsResponse.parse(rawResponse); + expect(parsed.events[0]!.startTime).toBeInstanceOf(Date); + + const legacy = ListRunEventsResponseWithStringDates.parse(rawResponse); + expect(typeof legacy.events[0]!.startTime).toBe("string"); + expect(legacy.events[0]!.startTime).toBe(parsed.events[0]!.startTime.toISOString()); + }); +}); + +describe("ListRunEventsResponse Schema", () => { + it("parses a valid wrapped response", () => { + const response = { + events: [ + { + spanId: "span_1", + runId: "run_1", + message: "Event 1", + style: {}, + startTime: "2024-03-14T00:00:00Z", + duration: 100, + isError: false, + isPartial: false, + isCancelled: false, + level: "INFO", + kind: "TASK", + }, + ], + }; + + const result = ListRunEventsResponse.safeParse(response); + expect(result.success).toBe(true); + if (result.success && result.data) { + expect(result.data.events[0]!.spanId).toBe("span_1"); + } + }); + + it("fails on plain array", () => { + const response = [{ spanId: "span_1" }]; + const result = ListRunEventsResponse.safeParse(response); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index dc72f155bdc..e854f67352e 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -9,6 +9,8 @@ import { } from "./common.js"; import { BackgroundWorkerMetadata } from "./resources.js"; import { DequeuedMessage, MachineResources } from "./runEngine.js"; +import { TaskEventStyle } from "./style.js"; +import { SpanEvents } from "./openTelemetry.js"; export const RunEngineVersion = z.union([z.literal("V1"), z.literal("V2")]); @@ -1639,3 +1641,77 @@ export const SendInputStreamResponseBody = z.object({ ok: z.boolean(), }); export type SendInputStreamResponseBody = z.infer; +export const TaskEventLevel = z.enum(["TRACE", "DEBUG", "INFO", "LOG", "WARN", "ERROR"]); +export type TaskEventLevel = z.infer; +export const NanosecondTimestampSchema = z + .union([z.string(), z.number(), z.bigint(), z.date()]) + .transform((val) => { + if (val instanceof Date) return val; + + const str = typeof val === "string" ? val.trim() : val.toString(); + + // 19-digit nanoseconds -> milliseconds + if (str.length === 19 && /^\d+$/.test(str)) { + return new Date(Number(BigInt(str) / 1_000_000n)); + } + + // 13-digit milliseconds + if (str.length === 13 && /^\d+$/.test(str)) { + return new Date(Number(str)); + } + + // Fallback: parse ISO or other date string + return new Date(str); + }) + .refine((date) => !isNaN(date.getTime()), { + message: "Invalid date", + }); + +export const RunEvent = z.object({ + spanId: z.string(), + parentId: z.string().nullish(), + runId: z.string(), + message: z.string(), + style: TaskEventStyle, + startTime: NanosecondTimestampSchema, + duration: z.number(), + isError: z.boolean(), + isPartial: z.boolean(), + isCancelled: z.boolean(), + level: TaskEventLevel, + events: SpanEvents.optional(), + kind: z.string(), + attemptNumber: z.number().nullish(), + taskSlug: z.string().optional(), +}); + +export type RunEvent = z.infer; + +/** + * A legacy-focused version of RunEvent where startTime is guaranteed to be an ISO string. + * Useful for consumers who haven't yet migrated to handling Date objects. + */ +export type RunEventWithStringDates = Omit & { + startTime: string; +}; + +export const ListRunEventsResponse = z.object({ + events: z.array(RunEvent), +}); + +export type ListRunEventsResponse = z.infer; + +/** + * A legacy-focused version of the response where events have startTime as an ISO string. + * This can be used as a transform on the original response for backward compatibility. + */ +export const ListRunEventsResponseWithStringDates = ListRunEventsResponse.transform((resp) => ({ + events: resp.events.map((e) => ({ + ...e, + startTime: e.startTime.toISOString(), + })), +})); + +export type ListRunEventsResponseWithStringDates = z.infer< + typeof ListRunEventsResponseWithStringDates +>;