Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/define-runevent-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Define RunEvent schema and update ApiClient to use it
4 changes: 3 additions & 1 deletion packages/core/src/v3/apiClient/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
EnvironmentVariableResponseBody,
EnvironmentVariableWithSecret,
ListQueueOptions,
ListRunEventsResponse,
ListRunResponseItem,
ListScheduleOptions,
QueueItem,
Expand All @@ -42,6 +43,7 @@ import {
RetrieveQueueParam,
RetrieveRunResponse,
RetrieveRunTraceResponseBody,
RunEvent,
ScheduleObject,
SendInputStreamResponseBody,
StreamBatchItemsResponse,
Expand Down Expand Up @@ -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",
Expand Down
130 changes: 129 additions & 1 deletion packages/core/src/v3/schemas/api-type.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { InitializeDeploymentRequestBody } from "./api.js";
import { InitializeDeploymentRequestBody, RunEvent, ListRunEventsResponse } from "./api.js";
import type { InitializeDeploymentRequestBody as InitializeDeploymentRequestBodyType } from "./api.js";

describe("InitializeDeploymentRequestBody", () => {
Expand Down Expand Up @@ -139,3 +139,131 @@ 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("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("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");
});
});

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);
});
});
51 changes: 51 additions & 0 deletions packages/core/src/v3/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")]);

Expand Down Expand Up @@ -1639,3 +1641,52 @@ export const SendInputStreamResponseBody = z.object({
ok: z.boolean(),
});
export type SendInputStreamResponseBody = z.infer<typeof SendInputStreamResponseBody>;
export const TaskEventLevel = z.enum(["TRACE", "DEBUG", "INFO", "LOG", "WARN", "ERROR"]);
export type TaskEventLevel = z.infer<typeof TaskEventLevel>;
export const NanosecondTimestampSchema = z
.union([z.string(), z.number(), z.bigint()])
.transform((val) => {
// If it's already a Date, return it (though union doesn't include it)
if (typeof val === "object" && val instanceof Date) return val;

const str = val.toString();

// 19 digits is nanoseconds (e.g., 1710374400000000000)
if (str.length === 19) {
return new Date(Number(BigInt(str) / 1000000n));
}

// 13 digits is milliseconds
if (str.length === 13) {
return new Date(Number(val));
}

// Default to standard date coercion
return new Date(val as any);
});
Comment on lines +1646 to +1666
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Zod v3, does .transform()automatically validate that a returnedDateis valid, and will a transform onz.union([z.string(), z.number(), z.bigint()])run when the input is aDate object?

💡 Result:

  • No. In Zod v3, .transform() runs after the input schema has parsed; Zod does not automatically re-validate the returned value (e.g., it won’t automatically reject an Invalid Date). If you need that, you must either add a check inside the transform via ctx (add an issue) or “pipe” into an output schema (e.g. a z.date() schema) to validate the post-transform value. [1], [2]

  • No. A .transform() attached to z.union([z.string(), z.number(), z.bigint()]) will only run if the union successfully parses the input. If the input is a Date object, none of those union branches match, so parsing fails and the transform is not executed. [3], [2]

Sources:
[1] Zod docs – Transforms API (z.transform, using ctx.issues for validation) https://zod.dev/api?id=preprocess
[2] Zod v3 README (npm) – ZodEffects runs transforms post-parsing https://www.npmjs.com/package/zod/v/3.0.0-alpha.19
[3] Zod docs – unions return the first option that validates successfully (i.e., must validate to proceed) https://zod.dev/?id=dates-1


Nanosecond timestamp transform does not validate returned Date and misses literal Date inputs.

In Zod v3, .transform() does not automatically validate its returned value, so invalid timestamps can produce Invalid Date objects that pass through. Additionally, the unreachable Date branch check is correct—since the union excludes z.date(), a literal Date input fails parsing before the transform runs.

🔧 Proposed fix
 export const NanosecondTimestampSchema = z
-  .union([z.string(), z.number(), z.bigint()])
+  .union([z.string(), z.number(), z.bigint(), z.date()])
   .transform((val) => {
-    // If it's already a Date, return it (though union doesn't include it)
-    if (typeof val === "object" && val instanceof Date) return val;
+    if (val instanceof Date) return val;
 
-    const str = val.toString();
+    const str = String(val);
 
-    // 19 digits is nanoseconds (e.g., 1710374400000000000)
-    if (str.length === 19) {
+    // 19 digits => nanoseconds
+    if (/^-?\d{19}$/.test(str)) {
       return new Date(Number(BigInt(str) / 1000000n));
     }
 
-    // 13 digits is milliseconds
-    if (str.length === 13) {
-      return new Date(Number(val));
+    // 13 digits => milliseconds
+    if (/^-?\d{13}$/.test(str)) {
+      return new Date(Number(str));
     }
 
-    // Default to standard date coercion
-    return new Date(val as any);
-  });
+    return new Date(str);
+  })
+  .pipe(z.date());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const NanosecondTimestampSchema = z
.union([z.string(), z.number(), z.bigint()])
.transform((val) => {
// If it's already a Date, return it (though union doesn't include it)
if (typeof val === "object" && val instanceof Date) return val;
const str = val.toString();
// 19 digits is nanoseconds (e.g., 1710374400000000000)
if (str.length === 19) {
return new Date(Number(BigInt(str) / 1000000n));
}
// 13 digits is milliseconds
if (str.length === 13) {
return new Date(Number(val));
}
// Default to standard date coercion
return new Date(val as any);
});
export const NanosecondTimestampSchema = z
.union([z.string(), z.number(), z.bigint(), z.date()])
.transform((val) => {
if (val instanceof Date) return val;
const str = String(val);
// 19 digits => nanoseconds
if (/^-?\d{19}$/.test(str)) {
return new Date(Number(BigInt(str) / 1000000n));
}
// 13 digits => milliseconds
if (/^-?\d{13}$/.test(str)) {
return new Date(Number(str));
}
return new Date(str);
})
.pipe(z.date());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/v3/schemas/api.ts` around lines 1646 - 1666,
NanosecondTimestampSchema's transform can return invalid Dates and never accepts
Date inputs; update the schema to include z.date() in the union so Date values
reach the transform (or handle them via a preprocess) and then validate the
transformed value by adding a refine that checks !isNaN(date.getTime()) with a
clear error message; reference NanosecondTimestampSchema and ensure the
transform still handles 19- and 13-digit strings but add the post-transform
validation (or replace transform with preprocess+parse and then z.date() to
guarantee a validated 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(),
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: ClickHouse getRunEvents hardcodes kind to 'UNSPECIFIED' and omits taskSlug

The ClickHouse #spanSummaryToRunPreparedEvent (apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts:1908-1925) hardcodes kind: "UNSPECIFIED" and does not include taskSlug. The RunEvent schema handles both: kind is z.string() so "UNSPECIFIED" is valid, and taskSlug is .optional(). The Prisma-based getRunEvents (apps/webapp/app/v3/eventRepository/eventRepository.server.ts:723-726) filters out kind === "UNSPECIFIED" events before returning, but the ClickHouse version doesn't—so ClickHouse-backed responses will have all events with kind: "UNSPECIFIED". This is pre-existing behavior and not caused by this PR, but it means the kind field is not very meaningful for ClickHouse-sourced events.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

attemptNumber: z.number().nullish(),
taskSlug: z.string().optional(),
});

export type RunEvent = z.infer<typeof RunEvent>;

export const ListRunEventsResponse = z.object({
events: z.array(RunEvent),
});

export type ListRunEventsResponse = z.infer<typeof ListRunEventsResponse>;
Loading