Skip to content

Commit d53ec0b

Browse files
giovaborgognoclaude
andcommitted
feat(events): CLI commands — history, replay, dlq list, dlq retry
Add remaining event CLI commands: - `trigger events history <eventId>` — paginated event publish history - `trigger events replay <eventId>` — replay historical events to subscribers - `trigger events dlq list` — list dead letter queue entries - `trigger events dlq retry <id>` — retry a specific DLQ entry Adds corresponding CliApiClient methods: getEventHistory, replayEvents, listDeadLetterEvents, retryDeadLetterEvent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1be7852 commit d53ec0b

File tree

5 files changed

+602
-0
lines changed

5 files changed

+602
-0
lines changed

packages/cli-v3/src/apiClient.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ import {
4242
RemoteBuildProviderStatusResponseBody,
4343
ListEventsResponseBody,
4444
PublishEventResponseBody,
45+
GetEventHistoryResponseBody,
46+
ReplayEventsResponseBody,
47+
ListDeadLetterEventsResponseBody,
48+
RetryDeadLetterEventResponseBody,
4549
} from "@trigger.dev/core/v3";
4650
import {
4751
WorkloadDebugLogRequestBody,
@@ -604,6 +608,125 @@ export class CliApiClient {
604608
);
605609
}
606610

611+
async getEventHistory(
612+
projectRef: string,
613+
eventId: string,
614+
options?: {
615+
from?: string;
616+
to?: string;
617+
limit?: number;
618+
cursor?: string;
619+
}
620+
) {
621+
if (!this.accessToken) {
622+
throw new Error("getEventHistory: No access token");
623+
}
624+
625+
const encodedEventId = encodeURIComponent(eventId);
626+
const params = new URLSearchParams();
627+
if (options?.from) params.set("from", options.from);
628+
if (options?.to) params.set("to", options.to);
629+
if (options?.limit) params.set("limit", String(options.limit));
630+
if (options?.cursor) params.set("cursor", options.cursor);
631+
const qs = params.toString();
632+
633+
return wrapZodFetch(
634+
GetEventHistoryResponseBody,
635+
`${this.apiURL}/api/v1/events/${encodedEventId}/history${qs ? `?${qs}` : ""}`,
636+
{
637+
method: "GET",
638+
headers: {
639+
...this.getHeaders(),
640+
"x-trigger-project-ref": projectRef,
641+
},
642+
}
643+
);
644+
}
645+
646+
async replayEvents(
647+
projectRef: string,
648+
eventId: string,
649+
body: {
650+
from: string;
651+
to: string;
652+
filter?: unknown;
653+
tasks?: string[];
654+
dryRun?: boolean;
655+
}
656+
) {
657+
if (!this.accessToken) {
658+
throw new Error("replayEvents: No access token");
659+
}
660+
661+
const encodedEventId = encodeURIComponent(eventId);
662+
663+
return wrapZodFetch(
664+
ReplayEventsResponseBody,
665+
`${this.apiURL}/api/v1/events/${encodedEventId}/replay`,
666+
{
667+
method: "POST",
668+
headers: {
669+
...this.getHeaders(),
670+
"x-trigger-project-ref": projectRef,
671+
},
672+
body: JSON.stringify(body),
673+
}
674+
);
675+
}
676+
677+
async listDeadLetterEvents(
678+
projectRef: string,
679+
options?: {
680+
eventType?: string;
681+
status?: string;
682+
limit?: number;
683+
cursor?: string;
684+
}
685+
) {
686+
if (!this.accessToken) {
687+
throw new Error("listDeadLetterEvents: No access token");
688+
}
689+
690+
const params = new URLSearchParams();
691+
if (options?.eventType) params.set("eventType", options.eventType);
692+
if (options?.status) params.set("status", options.status);
693+
if (options?.limit) params.set("limit", String(options.limit));
694+
if (options?.cursor) params.set("cursor", options.cursor);
695+
const qs = params.toString();
696+
697+
return wrapZodFetch(
698+
ListDeadLetterEventsResponseBody,
699+
`${this.apiURL}/api/v1/events/dlq${qs ? `?${qs}` : ""}`,
700+
{
701+
method: "GET",
702+
headers: {
703+
...this.getHeaders(),
704+
"x-trigger-project-ref": projectRef,
705+
},
706+
}
707+
);
708+
}
709+
710+
async retryDeadLetterEvent(projectRef: string, id: string) {
711+
if (!this.accessToken) {
712+
throw new Error("retryDeadLetterEvent: No access token");
713+
}
714+
715+
const encodedId = encodeURIComponent(id);
716+
717+
return wrapZodFetch(
718+
RetryDeadLetterEventResponseBody,
719+
`${this.apiURL}/api/v1/events/dlq/${encodedId}/retry`,
720+
{
721+
method: "POST",
722+
headers: {
723+
...this.getHeaders(),
724+
"x-trigger-project-ref": projectRef,
725+
},
726+
}
727+
);
728+
}
729+
607730
get dev() {
608731
return {
609732
config: this.devConfig.bind(this),
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { Command } from "commander";
2+
import { z } from "zod";
3+
import {
4+
CommonCommandOptions,
5+
commonOptions,
6+
handleTelemetry,
7+
wrapCommandAction,
8+
} from "../../cli/common.js";
9+
import { printInitialBanner } from "../../utilities/initialBanner.js";
10+
import { isLoggedIn } from "../../utilities/session.js";
11+
import { loadConfig } from "../../config.js";
12+
import { resolveLocalEnvVars } from "../../utilities/localEnvVars.js";
13+
import { CliApiClient } from "../../apiClient.js";
14+
import { intro, outro } from "@clack/prompts";
15+
import { spinner } from "../../utilities/windows.js";
16+
import { logger } from "../../utilities/logger.js";
17+
import { tryCatch } from "@trigger.dev/core";
18+
19+
// --- dlq list ---
20+
21+
const DlqListOptions = CommonCommandOptions.extend({
22+
config: z.string().optional(),
23+
projectRef: z.string().optional(),
24+
envFile: z.string().optional(),
25+
eventType: z.string().optional(),
26+
status: z.string().optional(),
27+
limit: z.coerce.number().int().optional(),
28+
cursor: z.string().optional(),
29+
});
30+
31+
type DlqListOptions = z.infer<typeof DlqListOptions>;
32+
33+
function configureDlqListCommand(program: Command) {
34+
return commonOptions(
35+
program
36+
.command("list")
37+
.description("List dead letter queue entries")
38+
.option("-c, --config <config file>", "The name of the config file")
39+
.option("-p, --project-ref <project ref>", "The project ref")
40+
.option("--env-file <env file>", "Path to the .env file")
41+
.option("--event-type <type>", "Filter by event type")
42+
.option("--status <status>", "Filter by status (PENDING, RETRIED, DISCARDED)")
43+
.option("--limit <n>", "Max results (default 50, max 200)")
44+
.option("--cursor <cursor>", "Pagination cursor from previous response")
45+
).action(async (options) => {
46+
await handleTelemetry(async () => {
47+
await printInitialBanner(false, options.profile);
48+
await dlqListCommand(options);
49+
});
50+
});
51+
}
52+
53+
async function dlqListCommand(options: unknown) {
54+
return await wrapCommandAction("dlqListCommand", DlqListOptions, options, async (opts) => {
55+
return await _dlqListCommand(opts);
56+
});
57+
}
58+
59+
async function _dlqListCommand(options: DlqListOptions) {
60+
intro("Dead letter queue");
61+
62+
const envVars = resolveLocalEnvVars(options.envFile);
63+
64+
const authentication = await isLoggedIn(options.profile);
65+
if (!authentication.ok) {
66+
outro(`Not logged in. Use \`trigger login\` first.`);
67+
return;
68+
}
69+
70+
const [configError, resolvedConfig] = await tryCatch(
71+
loadConfig({
72+
overrides: { project: options.projectRef ?? envVars.TRIGGER_PROJECT_REF },
73+
configFile: options.config,
74+
warn: false,
75+
})
76+
);
77+
78+
if (configError || !resolvedConfig?.project) {
79+
outro("Could not resolve project. Use --project-ref or configure trigger.config.ts.");
80+
return;
81+
}
82+
83+
const loadingSpinner = spinner();
84+
loadingSpinner.start("Fetching DLQ entries...");
85+
86+
const apiClient = new CliApiClient(authentication.auth.apiUrl, authentication.auth.accessToken);
87+
const result = await apiClient.listDeadLetterEvents(resolvedConfig.project, {
88+
eventType: options.eventType,
89+
status: options.status,
90+
limit: options.limit,
91+
cursor: options.cursor,
92+
});
93+
94+
if (!result.success) {
95+
loadingSpinner.stop("Failed to fetch DLQ entries");
96+
logger.error(result.error);
97+
return;
98+
}
99+
100+
const { data, pagination } = result.data;
101+
loadingSpinner.stop(`Found ${data.length} DLQ entry/entries`);
102+
103+
if (data.length === 0) {
104+
outro("No dead letter entries found.");
105+
return;
106+
}
107+
108+
logger.table(
109+
data.map((entry) => ({
110+
id: entry.friendlyId,
111+
eventType: entry.eventType,
112+
task: entry.taskSlug,
113+
status: entry.status,
114+
attempts: String(entry.attemptCount),
115+
created: entry.createdAt,
116+
}))
117+
);
118+
119+
if (pagination.hasMore && pagination.cursor) {
120+
logger.info(`\nMore results available. Use --cursor ${pagination.cursor} to see next page.`);
121+
}
122+
}
123+
124+
// --- dlq retry ---
125+
126+
const DlqRetryOptions = CommonCommandOptions.extend({
127+
config: z.string().optional(),
128+
projectRef: z.string().optional(),
129+
envFile: z.string().optional(),
130+
});
131+
132+
type DlqRetryOptions = z.infer<typeof DlqRetryOptions>;
133+
134+
function configureDlqRetryCommand(program: Command) {
135+
return commonOptions(
136+
program
137+
.command("retry <id>")
138+
.description("Retry a dead letter queue entry")
139+
.option("-c, --config <config file>", "The name of the config file")
140+
.option("-p, --project-ref <project ref>", "The project ref")
141+
.option("--env-file <env file>", "Path to the .env file")
142+
).action(async (id: string, options) => {
143+
await handleTelemetry(async () => {
144+
await printInitialBanner(false, options.profile);
145+
await dlqRetryCommand({ ...options, id });
146+
});
147+
});
148+
}
149+
150+
const DlqRetryCommandInput = DlqRetryOptions.extend({
151+
id: z.string(),
152+
});
153+
154+
type DlqRetryCommandInput = z.infer<typeof DlqRetryCommandInput>;
155+
156+
async function dlqRetryCommand(options: unknown) {
157+
return await wrapCommandAction(
158+
"dlqRetryCommand",
159+
DlqRetryCommandInput,
160+
options,
161+
async (opts) => {
162+
return await _dlqRetryCommand(opts);
163+
}
164+
);
165+
}
166+
167+
async function _dlqRetryCommand(options: DlqRetryCommandInput) {
168+
intro(`Retrying DLQ entry "${options.id}"`);
169+
170+
const envVars = resolveLocalEnvVars(options.envFile);
171+
172+
const authentication = await isLoggedIn(options.profile);
173+
if (!authentication.ok) {
174+
outro(`Not logged in. Use \`trigger login\` first.`);
175+
return;
176+
}
177+
178+
const [configError, resolvedConfig] = await tryCatch(
179+
loadConfig({
180+
overrides: { project: options.projectRef ?? envVars.TRIGGER_PROJECT_REF },
181+
configFile: options.config,
182+
warn: false,
183+
})
184+
);
185+
186+
if (configError || !resolvedConfig?.project) {
187+
outro("Could not resolve project. Use --project-ref or configure trigger.config.ts.");
188+
return;
189+
}
190+
191+
const loadingSpinner = spinner();
192+
loadingSpinner.start("Retrying dead letter event...");
193+
194+
const apiClient = new CliApiClient(authentication.auth.apiUrl, authentication.auth.accessToken);
195+
const result = await apiClient.retryDeadLetterEvent(resolvedConfig.project, options.id);
196+
197+
if (!result.success) {
198+
loadingSpinner.stop("Failed to retry DLQ entry");
199+
logger.error(result.error);
200+
return;
201+
}
202+
203+
loadingSpinner.stop("DLQ entry retried successfully");
204+
205+
logger.info(`Status: ${result.data.status}`);
206+
if (result.data.runId) {
207+
logger.info(`New run ID: ${result.data.runId}`);
208+
}
209+
}
210+
211+
// --- Main export ---
212+
213+
export function configureEventsDlqCommand(program: Command) {
214+
const dlq = program.command("dlq").description("Manage the dead letter queue");
215+
216+
configureDlqListCommand(dlq);
217+
configureDlqRetryCommand(dlq);
218+
219+
return dlq;
220+
}

0 commit comments

Comments
 (0)