Skip to content

Commit 8ad4bd6

Browse files
committed
feat(sdk,webapp): add tags.delete() to remove tags from a run
Adds a per-run tag removal path, mirroring the existing tags.add() flow end to end: - `tags.delete(tag | tags)` in the SDK, plus JSDoc on both `add` and `delete` - `DELETE /api/v1/runs/:runId/tags` as a method branch on the existing route - `RemoveTagsRequestBody` and `ApiClient#removeTags` in core - `RunStore#removeTags`, a single-statement UPDATE so there is no read-modify-write window racing a concurrent pushTags - a `remove_tags` snapshot patch for runs still in the buffer Removing a tag only affects the run it's called from. Removing a tag the run doesn't have, or an empty list, is an idempotent success. Also fixes the `add` span name, which was hardcoded to "tags.set()", and an OpenAPI code sample advertising a `runs.addTags(...)` function that doesn't exist. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2f1734c commit 8ad4bd6

17 files changed

Lines changed: 959 additions & 17 deletions

File tree

.changeset/tags-delete.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
"@trigger.dev/redis-worker": patch
5+
---
6+
7+
You can now remove tags from a run while it's running with `tags.delete("my-tag")` (or an array of tags). It only affects that run — every other run keeps the tag, and the tag stays available to filter by.

apps/webapp/app/routes/api.v1.runs.$runId.tags.ts

Lines changed: 147 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2-
import { AddTagsRequestBody } from "@trigger.dev/core/v3";
2+
import { AddTagsRequestBody, RemoveTagsRequestBody } from "@trigger.dev/core/v3";
33
import type { BufferEntry } from "@trigger.dev/redis-worker";
44
import { z } from "zod";
55
import { prisma } from "~/db.server";
66
import { MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server";
7-
import { authenticateApiRequest } from "~/services/apiAuth.server";
7+
import { type AuthenticatedEnvironment, authenticateApiRequest } from "~/services/apiAuth.server";
88
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
99
import { logger } from "~/services/logger.server";
1010
import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server";
@@ -31,24 +31,60 @@ const ParamsSchema = z.object({
3131
runId: z.string(),
3232
});
3333

34-
export async function action({ request, params }: ActionFunctionArgs) {
35-
if (request.method.toUpperCase() !== "POST") {
36-
return { status: 405, body: "Method Not Allowed" };
37-
}
34+
type ResolvedRequest =
35+
| { kind: "ok"; environment: AuthenticatedEnvironment; runId: string }
36+
| { kind: "error"; response: Response };
3837

38+
// Auth + params, shared by both methods. Secret-key auth only, deliberately: adding
39+
// an RBAC `authorization` gate here would change behaviour for narrowly-scoped JWTs.
40+
async function resolveRequest(
41+
request: Request,
42+
params: ActionFunctionArgs["params"]
43+
): Promise<ResolvedRequest> {
3944
const authenticationResult = await authenticateApiRequest(request);
4045
if (!authenticationResult) {
41-
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
46+
return {
47+
kind: "error",
48+
response: json({ error: "Invalid or Missing API Key" }, { status: 401 }),
49+
};
4250
}
4351

4452
const parsedParams = ParamsSchema.safeParse(params);
4553
if (!parsedParams.success) {
46-
return json(
47-
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
48-
{ status: 400 }
49-
);
54+
return {
55+
kind: "error",
56+
response: json(
57+
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
58+
{ status: 400 }
59+
),
60+
};
5061
}
5162

63+
return {
64+
kind: "ok",
65+
environment: authenticationResult.environment,
66+
runId: parsedParams.data.runId,
67+
};
68+
}
69+
70+
export async function action({ request, params }: ActionFunctionArgs) {
71+
switch (request.method.toUpperCase()) {
72+
case "POST":
73+
return addRunTags(request, params);
74+
case "DELETE":
75+
return removeRunTags(request, params);
76+
default:
77+
return json({ error: "Method Not Allowed" }, { status: 405 });
78+
}
79+
}
80+
81+
async function addRunTags(request: Request, params: ActionFunctionArgs["params"]) {
82+
const resolved = await resolveRequest(request, params);
83+
if (resolved.kind === "error") {
84+
return resolved.response;
85+
}
86+
const { environment: env, runId } = resolved;
87+
5288
try {
5389
const anyBody = await request.json();
5490
const body = AddTagsRequestBody.safeParse(anyBody);
@@ -62,9 +98,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
6298
return json({ message: "No new tags to add" }, { status: 200 });
6399
}
64100

65-
const env = authenticationResult.environment;
66101
const outcome = await mutateWithFallback<Response>({
67-
runId: parsedParams.data.runId,
102+
runId,
68103
environmentId: env.id,
69104
organizationId: env.organizationId,
70105
bufferPatch: { type: "append_tags", tags: nonEmptyTags, maxTags: MAX_TAGS_PER_RUN },
@@ -141,3 +176,102 @@ export async function action({ request, params }: ActionFunctionArgs) {
141176
return json({ error: "Something went wrong, please try again." }, { status: 500 });
142177
}
143178
}
179+
180+
// Removes tags from THIS run only. Tags aren't a shared entity — they're strings in
181+
// the run's own `runTags` array — so there is nothing org-wide to delete, and other
182+
// runs carrying the same tag are untouched.
183+
async function removeRunTags(request: Request, params: ActionFunctionArgs["params"]) {
184+
const resolved = await resolveRequest(request, params);
185+
if (resolved.kind === "error") {
186+
return resolved.response;
187+
}
188+
const { environment: env, runId } = resolved;
189+
190+
try {
191+
const anyBody = await request.json();
192+
const body = RemoveTagsRequestBody.safeParse(anyBody);
193+
if (!body.success) {
194+
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
195+
}
196+
const bodyTags = typeof body.data.tags === "string" ? [body.data.tags] : body.data.tags;
197+
const nonEmptyTags = bodyTags.filter((t) => t.trim().length > 0);
198+
199+
// Nothing asked for. No MAX_TAGS_PER_RUN check applies to a removal — it can
200+
// only ever shrink the list.
201+
if (nonEmptyTags.length === 0) {
202+
return json({ message: "No tags to remove" }, { status: 200 });
203+
}
204+
205+
const outcome = await mutateWithFallback<Response>({
206+
runId,
207+
environmentId: env.id,
208+
organizationId: env.organizationId,
209+
bufferPatch: { type: "remove_tags", tags: nonEmptyTags },
210+
pgMutation: async (taskRun) => {
211+
const existing = taskRun.runTags ?? [];
212+
const doomed = new Set(nonEmptyTags);
213+
const remaining = existing.filter((t) => !doomed.has(t));
214+
const removedCount = existing.length - remaining.length;
215+
216+
// Removing tags the run doesn't have is an idempotent success, not a 404.
217+
// Skip the write entirely so we don't bump `updatedAt`, replicate a row that
218+
// didn't change, or publish a no-op realtime record.
219+
if (removedCount === 0) {
220+
return json({ message: "Successfully removed 0 tags." }, { status: 200 });
221+
}
222+
223+
const updated = await runStore.removeTags(
224+
taskRun.id,
225+
nonEmptyTags,
226+
{ runtimeEnvironmentId: env.id },
227+
prisma
228+
);
229+
230+
// The run vanished (or moved environment) between the read and this write.
231+
if (!updated) {
232+
return json({ error: "Run not found" }, { status: 404 });
233+
}
234+
235+
// Publish a run-changed record with the REMAINING tag set so tag feeds
236+
// reindex (no-op unless enabled). updatedAt is the read-your-writes
237+
// watermark. Note this record no longer carries the removed tag, so a feed
238+
// subscribed to that tag simply stops receiving this run — there is no
239+
// "tag removed" un-subscribe signal.
240+
publishChangeRecord({
241+
runId: taskRun.id,
242+
envId: env.id,
243+
tags: remaining,
244+
batchId: taskRun.batchId,
245+
updatedAtMs: updated.updatedAt.getTime(),
246+
});
247+
248+
return json({ message: `Successfully removed ${removedCount} tags.` }, { status: 200 });
249+
},
250+
// Buffer-applied patch path. The Lua removed the tags from the snapshot
251+
// atomically. Count off the pre-mutation entry (already fetched by
252+
// mutateWithFallback's env-auth pre-check, so no extra Redis read) so the
253+
// message reports the same number the PG path would for the same input.
254+
synthesisedResponse: ({ bufferEntry }) => {
255+
const existing = parseSnapshotTags(bufferEntry);
256+
const removedCount = existing
257+
? existing.filter((t) => nonEmptyTags.includes(t)).length
258+
: nonEmptyTags.length;
259+
return json({ message: `Successfully removed ${removedCount} tags.` }, { status: 200 });
260+
},
261+
// No `rejectedResponse`: a `remove_tags` patch carries no cap, so the buffer
262+
// never reports `limit_exceeded` for it.
263+
abortSignal: getRequestAbortSignal(),
264+
});
265+
266+
if (outcome.kind === "not_found") {
267+
return json({ error: "Run not found" }, { status: 404 });
268+
}
269+
if (outcome.kind === "timed_out") {
270+
return json({ error: "Run materialisation timed out" }, { status: 503 });
271+
}
272+
return outcome.response;
273+
} catch (error) {
274+
logger.error("Failed to remove run tags", { error });
275+
return json({ error: "Something went wrong, please try again." }, { status: 500 });
276+
}
277+
}

apps/webapp/test/performTaskRunAlertsStoreRouting.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ class RoutingRunStore implements RunStore {
140140
pushTags(runId: string, ...a: any[]): any {
141141
return (this.#resolveById(runId).pushTags as any)(runId, ...a);
142142
}
143+
removeTags(runId: string, ...a: any[]): any {
144+
return (this.#resolveById(runId).removeTags as any)(runId, ...a);
145+
}
143146
pushRealtimeStream(runId: string, ...a: any[]): any {
144147
return (this.#resolveById(runId).pushRealtimeStream as any)(runId, ...a);
145148
}

apps/webapp/test/updateMetadataStoreRoutingHetero.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ class RoutingRunStore implements RunStore {
211211
pushTags(runId: string, tags: string[], where: any, _tx?: unknown): any {
212212
return this.#resolveById(runId).pushTags(runId, tags, where);
213213
}
214+
removeTags(runId: string, tags: string[], where: any, _tx?: unknown): any {
215+
return this.#resolveById(runId).removeTags(runId, tags, where);
216+
}
214217
pushRealtimeStream(runId: string, streamId: string, _tx?: unknown): any {
215218
return this.#resolveById(runId).pushRealtimeStream(runId, streamId);
216219
}

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@
343343
"management/runs/reschedule",
344344
"management/runs/update-metadata",
345345
"management/runs/add-tags",
346+
"management/runs/remove-tags",
346347
"management/runs/retrieve-events",
347348
"management/runs/retrieve-trace",
348349
"management/runs/retrieve-result"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
title: "Remove tags from a run"
3+
openapi: "v3-openapi DELETE /api/v1/runs/{runId}/tags"
4+
---

docs/tags.mdx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ We don't enforce prefixes but if you use them you'll find it easier to filter an
1818

1919
## How to add tags
2020

21-
There are two ways to add tags to a run:
21+
You can add tags to a run in two ways:
2222

2323
1. When triggering the run.
2424
2. Inside the `run` function, using `tags.add()`.
2525

26+
You can also [remove tags](#removing-tags) from a run while it's running.
27+
2628
### 1. Adding tags when triggering the run
2729

2830
You can add tags when triggering a run using the `tags` option. All the different [trigger](/triggering) methods support this.
@@ -96,6 +98,36 @@ export const myTask = task({
9698
});
9799
```
98100

101+
## Removing tags
102+
103+
Use the `tags.delete()` function to remove tags from inside the `run` function. It takes a single string or an array of strings, just like `tags.add()`:
104+
105+
```ts
106+
import { tags, task } from "@trigger.dev/sdk";
107+
108+
export const myTask = task({
109+
id: "my-task",
110+
run: async (payload: { message: string }) => {
111+
await tags.add(["status_processing", "user_123456"]);
112+
113+
// ...do the work...
114+
115+
// Remove a single tag, or an array of tags
116+
await tags.delete("status_processing");
117+
await tags.add("status_done");
118+
},
119+
});
120+
```
121+
122+
This only affects the run you call it from. Other runs keep the same tag, and the tag stays available to filter by in the dashboard.
123+
124+
<Note>
125+
Removing a tag the run doesn't have does nothing and won't throw, so you don't need to
126+
check the run's current tags first.
127+
</Note>
128+
129+
Tags can only be removed from inside the `run` function — there's no equivalent option when triggering.
130+
99131
## Filtering runs by tags
100132

101133
You can filter runs by tags in the dashboard and in the SDK.

docs/v3-openapi.yaml

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,9 +427,10 @@ paths:
427427
- lang: typescript
428428
label: SDK
429429
source: |-
430-
import { runs } from "@trigger.dev/sdk";
430+
import { tags } from "@trigger.dev/sdk";
431431
432-
await runs.addTags("run_1234", ["tag-1", "tag-2"]);
432+
// Called from inside a run — applies to the current run
433+
await tags.add(["tag-1", "tag-2"]);
433434
- lang: typescript
434435
label: Fetch
435436
source: |-
@@ -441,6 +442,86 @@ paths:
441442
},
442443
body: JSON.stringify({ tags: ["tag-1", "tag-2"] }),
443444
});
445+
delete:
446+
operationId: remove_run_tags_v1
447+
summary: Remove tags from a run
448+
description: Removes one or more tags from a run. Only this run is affected — the tag is left on any other run that has it. Tags the run doesn't have are ignored, so the request is idempotent.
449+
requestBody:
450+
required: true
451+
content:
452+
application/json:
453+
schema:
454+
type: object
455+
required:
456+
- tags
457+
properties:
458+
tags:
459+
$ref: "#/components/schemas/RunTags"
460+
responses:
461+
"200":
462+
description: Successful request
463+
content:
464+
application/json:
465+
schema:
466+
type: object
467+
properties:
468+
message:
469+
type: string
470+
example: "Successfully removed 2 tags."
471+
"400":
472+
description: Invalid request
473+
content:
474+
application/json:
475+
schema:
476+
type: object
477+
properties:
478+
error:
479+
type: string
480+
"401":
481+
description: Unauthorized request
482+
content:
483+
application/json:
484+
schema:
485+
type: object
486+
properties:
487+
error:
488+
type: string
489+
enum:
490+
- Invalid or Missing API Key
491+
"404":
492+
description: Run not found
493+
content:
494+
application/json:
495+
schema:
496+
type: object
497+
properties:
498+
error:
499+
type: string
500+
enum:
501+
- Run not found
502+
tags:
503+
- runs
504+
security:
505+
- secretKey: []
506+
x-codeSamples:
507+
- lang: typescript
508+
label: SDK
509+
source: |-
510+
import { tags } from "@trigger.dev/sdk";
511+
512+
// Called from inside a run — applies to the current run
513+
await tags.delete(["tag-1", "tag-2"]);
514+
- lang: typescript
515+
label: Fetch
516+
source: |-
517+
await fetch("https://api.trigger.dev/api/v1/runs/run_1234/tags", {
518+
method: "DELETE",
519+
headers: {
520+
"Authorization": `Bearer ${process.env.TRIGGER_SECRET_KEY}`,
521+
"Content-Type": "application/json",
522+
},
523+
body: JSON.stringify({ tags: ["tag-1", "tag-2"] }),
524+
});
444525
445526
"/api/v1/runs/{runId}/trace":
446527
parameters:

0 commit comments

Comments
 (0)