Skip to content

Commit ab7e522

Browse files
author
deepshekhardas
committed
fix(core): define RunEvent schema and update ApiClient validation (PR #3220)
- Add RunEvent, TaskEventLevel schemas to api.ts - Add ListRunEventsResponse schema - Add RunEvent import to ApiClient - Add changeset for define-runevent-schema Closes #3220
1 parent d35bf04 commit ab7e522

6 files changed

Lines changed: 248 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": minor
3+
---
4+
5+
Define RunEvent schema and update ApiClient to use it

packages/cli-v3/src/commands/deploy.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,8 +1149,9 @@ async function handleNativeBuildServerDeploy({
11491149
const [readSessionError, readSession] = await tryCatch(
11501150
stream.readSession(
11511151
{
1152-
start: { from: { seqNum: 0 }, clamp: true },
1153-
stop: { waitSecs: 60 * 20 }, // 20 minutes
1152+
start: {
1153+
from: { seqNum: 0 },
1154+
},
11541155
},
11551156
{ signal: abortController.signal }
11561157
)

packages/core/src/v3/apiClient/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
EnvironmentVariableResponseBody,
4242
EnvironmentVariableWithSecret,
4343
ListQueueOptions,
44+
RunEvent,
45+
ListRunEventsResponse,
4446
ListRunResponseItem,
4547
ListScheduleOptions,
4648
QueueItem,
@@ -748,7 +750,7 @@ export class ApiClient {
748750

749751
listRunEvents(runId: string, requestOptions?: ZodFetchOptions) {
750752
return zodfetch(
751-
z.any(), // TODO: define a proper schema for this
753+
ListRunEventsResponse,
752754
`${this.baseUrl}/api/v1/runs/${runId}/events`,
753755
{
754756
method: "GET",

packages/core/src/v3/realtimeStreams/streamsWriterV2.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ export class StreamsWriterV2<T = any> implements StreamsWriter {
7171
accessToken: options.accessToken,
7272
...(options.endpoint
7373
? {
74-
endpoints: {
75-
account: options.endpoint,
76-
basin: options.endpoint,
77-
},
78-
}
74+
endpoints: {
75+
account: options.endpoint,
76+
basin: options.endpoint,
77+
},
78+
}
7979
: {}),
8080
});
8181
this.flushIntervalMs = options.flushIntervalMs ?? 200;
@@ -238,7 +238,7 @@ async function* streamToAsyncIterator<T>(stream: ReadableStream<T>): AsyncIterab
238238
function safeReleaseLock(reader: ReadableStreamDefaultReader<any>) {
239239
try {
240240
reader.releaseLock();
241-
} catch (error) {}
241+
} catch (error) { }
242242
}
243243

244244
// chat.agent emits two chunk shapes through this writer:

packages/core/src/v3/schemas/api-type.test.ts

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { InitializeDeploymentRequestBody } from "./api.js";
2+
import { InitializeDeploymentRequestBody, RunEvent, ListRunEventsResponse, ListRunEventsResponseWithStringDates } from "./api.js";
33
import type { InitializeDeploymentRequestBody as InitializeDeploymentRequestBodyType } from "./api.js";
44

55
describe("InitializeDeploymentRequestBody", () => {
@@ -139,3 +139,202 @@ describe("InitializeDeploymentRequestBody", () => {
139139
});
140140
});
141141
});
142+
143+
describe("RunEvent Schema", () => {
144+
const validEvent = {
145+
spanId: "span_123",
146+
parentId: "span_root",
147+
runId: "run_abc",
148+
message: "Test event",
149+
style: {
150+
icon: "task",
151+
variant: "primary",
152+
},
153+
startTime: "2024-03-14T00:00:00Z",
154+
duration: 1234,
155+
isError: false,
156+
isPartial: false,
157+
isCancelled: false,
158+
level: "INFO",
159+
kind: "TASK",
160+
attemptNumber: 1,
161+
};
162+
163+
it("parses a valid event correctly", () => {
164+
const result = RunEvent.safeParse(validEvent);
165+
expect(result.success).toBe(true);
166+
if (result.success) {
167+
expect(result.data.spanId).toBe("span_123");
168+
expect(result.data.startTime).toBeInstanceOf(Date);
169+
expect(result.data.level).toBe("INFO");
170+
}
171+
});
172+
173+
it("fails on missing required fields", () => {
174+
const invalidEvent = { ...validEvent };
175+
delete (invalidEvent as any).spanId;
176+
const result = RunEvent.safeParse(invalidEvent);
177+
expect(result.success).toBe(false);
178+
});
179+
180+
it("fails on invalid level", () => {
181+
const invalidEvent = { ...validEvent, level: "INVALID_LEVEL" };
182+
const result = RunEvent.safeParse(invalidEvent);
183+
expect(result.success).toBe(false);
184+
});
185+
186+
it("coerces startTime to Date", () => {
187+
const result = RunEvent.parse(validEvent);
188+
expect(result.startTime).toBeInstanceOf(Date);
189+
expect(result.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z");
190+
});
191+
192+
it("handles 19-digit nanosecond startTime strings", () => {
193+
const event = { ...validEvent, startTime: "1710374400000000000" };
194+
const result = RunEvent.parse(event);
195+
expect(result.startTime).toBeInstanceOf(Date);
196+
// 1710374400000000000 ns = 1710374400000 ms = 2024-03-14T00:00:00Z
197+
expect(result.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z");
198+
});
199+
200+
it("should handle Date object", () => {
201+
const now = new Date();
202+
const result = RunEvent.safeParse({
203+
...validEvent,
204+
startTime: now,
205+
});
206+
expect(result.success).toBe(true);
207+
if (result.success) {
208+
expect(result.data.startTime.toISOString()).toBe(now.toISOString());
209+
}
210+
});
211+
212+
it("handles bigint nanosecond startTime", () => {
213+
const event = { ...validEvent, startTime: 1710374400000000000n };
214+
const result = RunEvent.parse(event as any);
215+
expect(result.startTime).toBeInstanceOf(Date);
216+
expect(result.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z");
217+
});
218+
219+
it("fails on invalid startTime", () => {
220+
const event = { ...validEvent, startTime: "not-a-date" };
221+
const result = RunEvent.safeParse(event);
222+
expect(result.success).toBe(false);
223+
});
224+
225+
describe("startTime edge cases", () => {
226+
it("should handle whitespace-padded strings", () => {
227+
const result = RunEvent.safeParse({
228+
...validEvent,
229+
startTime: " 2024-03-14T00:00:00Z ",
230+
});
231+
expect(result.success).toBe(true);
232+
if (result.success) {
233+
expect(result.data.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z");
234+
}
235+
});
236+
237+
it("should handle whitespace-padded nanosecond strings", () => {
238+
const result = RunEvent.safeParse({
239+
...validEvent,
240+
startTime: " 1710374400000000000 ",
241+
});
242+
expect(result.success).toBe(true);
243+
if (result.success) {
244+
expect(result.data.startTime.toISOString()).toBe("2024-03-14T00:00:00.000Z");
245+
}
246+
});
247+
248+
it("should fail on empty string", () => {
249+
const result = RunEvent.safeParse({
250+
...validEvent,
251+
startTime: "",
252+
});
253+
expect(result.success).toBe(false);
254+
});
255+
256+
it("should fail on whitespace-only string", () => {
257+
const result = RunEvent.safeParse({
258+
...validEvent,
259+
startTime: " ",
260+
});
261+
expect(result.success).toBe(false);
262+
});
263+
});
264+
265+
it("allows optional/null parentId", () => {
266+
const eventWithoutParent = { ...validEvent };
267+
delete (eventWithoutParent as any).parentId;
268+
expect(RunEvent.safeParse(eventWithoutParent).success).toBe(true);
269+
270+
const eventWithNullParent = { ...validEvent, parentId: null };
271+
expect(RunEvent.safeParse(eventWithNullParent).success).toBe(true);
272+
});
273+
274+
it("allows nullish attemptNumber", () => {
275+
const eventWithNullAttempt = { ...validEvent, attemptNumber: null };
276+
const result = RunEvent.safeParse(eventWithNullAttempt);
277+
expect(result.success).toBe(true);
278+
if (result.success) {
279+
expect(result.data.attemptNumber).toBe(null);
280+
}
281+
282+
const eventWithoutAttempt = { ...validEvent };
283+
delete (eventWithoutAttempt as any).attemptNumber;
284+
const result2 = RunEvent.safeParse(eventWithoutAttempt);
285+
expect(result2.success).toBe(true);
286+
});
287+
288+
it("supports taskSlug", () => {
289+
const eventWithSlug = { ...validEvent, taskSlug: "my-task" };
290+
const result = RunEvent.parse(eventWithSlug);
291+
expect(result.taskSlug).toBe("my-task");
292+
});
293+
294+
it("ListRunEventsResponseWithStringDates correctly transforms Dates to strings", () => {
295+
const rawResponse = {
296+
events: [validEvent],
297+
};
298+
299+
const parsed = ListRunEventsResponse.parse(rawResponse);
300+
expect(parsed.events[0]!.startTime).toBeInstanceOf(Date);
301+
302+
const legacy = ListRunEventsResponseWithStringDates.parse(rawResponse);
303+
expect(typeof legacy.events[0]!.startTime).toBe("string");
304+
expect(legacy.events[0]!.startTime).toBe(parsed.events[0]!.startTime.toISOString());
305+
});
306+
});
307+
308+
describe("ListRunEventsResponse Schema", () => {
309+
it("parses a valid wrapped response", () => {
310+
const response = {
311+
events: [
312+
{
313+
spanId: "span_1",
314+
runId: "run_1",
315+
message: "Event 1",
316+
style: {},
317+
startTime: "2024-03-14T00:00:00Z",
318+
duration: 100,
319+
isError: false,
320+
isPartial: false,
321+
isCancelled: false,
322+
level: "INFO",
323+
kind: "TASK",
324+
},
325+
],
326+
};
327+
328+
const result = ListRunEventsResponse.safeParse(response);
329+
expect(result.success).toBe(true);
330+
if (result.success && result.data) {
331+
expect(result.data.events[0]!.spanId).toBe("span_1");
332+
}
333+
});
334+
335+
it("fails on plain array", () => {
336+
const response = [{ spanId: "span_1" }];
337+
const result = ListRunEventsResponse.safeParse(response);
338+
expect(result.success).toBe(false);
339+
});
340+
});

packages/core/src/v3/schemas/api.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
} from "./common.js";
1010
import { BackgroundWorkerMetadata } from "./resources.js";
1111
import { DequeuedMessage, MachineResources } from "./runEngine.js";
12+
import { TaskEventStyle } from "./style.js";
13+
import { SpanEvents } from "./openTelemetry.js";
1214

1315
export const RunEngineVersion = z.union([z.literal("V1"), z.literal("V2")]);
1416

@@ -1991,14 +1993,35 @@ export const SendInputStreamResponseBody = z.object({
19911993
});
19921994
export type SendInputStreamResponseBody = z.infer<typeof SendInputStreamResponseBody>;
19931995

1994-
/**
1995-
* Response body for `GET /realtime/v1/sessions/:id/:io/records`. A non-SSE,
1996-
* `wait=0` drain of a session channel — used at run boot for snapshot
1997-
* replay where the SSE long-poll tax (~1s on empty streams) was the
1998-
* dominant cost. The shape mirrors the webapp's internal `StreamRecord`
1999-
* type (`apps/webapp/app/services/realtime/types.ts`); each record's
2000-
* `data` is a JSON-encoded chunk body that callers parse client-side.
2001-
*/
1996+
export const TaskEventLevel = z.enum(["TRACE", "DEBUG", "INFO", "LOG", "WARN", "ERROR"]);
1997+
export type TaskEventLevel = z.infer<typeof TaskEventLevel>;
1998+
1999+
export const RunEvent = z.object({
2000+
spanId: z.string(),
2001+
parentId: z.string().nullish(),
2002+
runId: z.string(),
2003+
message: z.string(),
2004+
style: TaskEventStyle,
2005+
startTime: z.coerce.date(),
2006+
duration: z.number(),
2007+
isError: z.boolean(),
2008+
isPartial: z.boolean(),
2009+
isCancelled: z.boolean(),
2010+
level: TaskEventLevel,
2011+
events: SpanEvents.optional(),
2012+
kind: z.string(),
2013+
attemptNumber: z.number().nullish(),
2014+
taskSlug: z.string().optional(),
2015+
});
2016+
2017+
export type RunEvent = z.infer<typeof RunEvent>;
2018+
2019+
export const ListRunEventsResponse = z.object({
2020+
events: z.array(RunEvent),
2021+
});
2022+
2023+
export type ListRunEventsResponse = z.infer<typeof ListRunEventsResponse>;
2024+
20022025
export const ReadSessionStreamRecordsResponseBody = z.object({
20032026
records: z.array(
20042027
z.object({

0 commit comments

Comments
 (0)