Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/tags-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
"@trigger.dev/redis-worker": patch
---

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.
160 changes: 147 additions & 13 deletions apps/webapp/app/routes/api.v1.runs.$runId.tags.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { AddTagsRequestBody } from "@trigger.dev/core/v3";
import { AddTagsRequestBody, RemoveTagsRequestBody } from "@trigger.dev/core/v3";
import type { BufferEntry } from "@trigger.dev/redis-worker";
import { z } from "zod";
import { prisma } from "~/db.server";
import { MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { type AuthenticatedEnvironment, authenticateApiRequest } from "~/services/apiAuth.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { logger } from "~/services/logger.server";
import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server";
Expand All @@ -31,24 +31,60 @@ const ParamsSchema = z.object({
runId: z.string(),
});

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

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

const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json(
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
{ status: 400 }
);
return {
kind: "error",
response: json(
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
{ status: 400 }
),
};
}

return {
kind: "ok",
environment: authenticationResult.environment,
runId: parsedParams.data.runId,
};
}

export async function action({ request, params }: ActionFunctionArgs) {
switch (request.method.toUpperCase()) {
case "POST":
return addRunTags(request, params);
case "DELETE":
return removeRunTags(request, params);
default:
return json({ error: "Method Not Allowed" }, { status: 405 });
}
}

async function addRunTags(request: Request, params: ActionFunctionArgs["params"]) {
const resolved = await resolveRequest(request, params);
if (resolved.kind === "error") {
return resolved.response;
}
const { environment: env, runId } = resolved;

try {
const anyBody = await request.json();
const body = AddTagsRequestBody.safeParse(anyBody);
Expand All @@ -62,9 +98,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ message: "No new tags to add" }, { status: 200 });
}

const env = authenticationResult.environment;
const outcome = await mutateWithFallback<Response>({
runId: parsedParams.data.runId,
runId,
environmentId: env.id,
organizationId: env.organizationId,
bufferPatch: { type: "append_tags", tags: nonEmptyTags, maxTags: MAX_TAGS_PER_RUN },
Expand Down Expand Up @@ -141,3 +176,102 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Something went wrong, please try again." }, { status: 500 });
}
}

// Removes tags from THIS run only. Tags aren't a shared entity — they're strings in
// the run's own `runTags` array — so there is nothing org-wide to delete, and other
// runs carrying the same tag are untouched.
async function removeRunTags(request: Request, params: ActionFunctionArgs["params"]) {
const resolved = await resolveRequest(request, params);
if (resolved.kind === "error") {
return resolved.response;
}
const { environment: env, runId } = resolved;

try {
const anyBody = await request.json();
const body = RemoveTagsRequestBody.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const bodyTags = typeof body.data.tags === "string" ? [body.data.tags] : body.data.tags;
const nonEmptyTags = bodyTags.filter((t) => t.trim().length > 0);

// Nothing asked for. No MAX_TAGS_PER_RUN check applies to a removal — it can
// only ever shrink the list.
if (nonEmptyTags.length === 0) {
return json({ message: "No tags to remove" }, { status: 200 });
}

const outcome = await mutateWithFallback<Response>({
runId,
environmentId: env.id,
organizationId: env.organizationId,
bufferPatch: { type: "remove_tags", tags: nonEmptyTags },
pgMutation: async (taskRun) => {
const existing = taskRun.runTags ?? [];
const doomed = new Set(nonEmptyTags);
const remaining = existing.filter((t) => !doomed.has(t));
const removedCount = existing.length - remaining.length;

// Removing tags the run doesn't have is an idempotent success, not a 404.
// Skip the write entirely so we don't bump `updatedAt`, replicate a row that
// didn't change, or publish a no-op realtime record.
if (removedCount === 0) {
return json({ message: "Successfully removed 0 tags." }, { status: 200 });
}

const updated = await runStore.removeTags(
taskRun.id,
nonEmptyTags,
{ runtimeEnvironmentId: env.id },
prisma
);

// The run vanished (or moved environment) between the read and this write.
if (!updated) {
return json({ error: "Run not found" }, { status: 404 });
}

// Publish a run-changed record with the REMAINING tag set so tag feeds
// reindex (no-op unless enabled). updatedAt is the read-your-writes
// watermark. Note this record no longer carries the removed tag, so a feed
// subscribed to that tag simply stops receiving this run — there is no
// "tag removed" un-subscribe signal.
publishChangeRecord({
runId: taskRun.id,
envId: env.id,
tags: remaining,
batchId: taskRun.batchId,
updatedAtMs: updated.updatedAt.getTime(),
});

return json({ message: `Successfully removed ${removedCount} tags.` }, { status: 200 });
},
// Buffer-applied patch path. The Lua removed the tags from the snapshot
// atomically. Count off the pre-mutation entry (already fetched by
// mutateWithFallback's env-auth pre-check, so no extra Redis read) so the
// message reports the same number the PG path would for the same input.
synthesisedResponse: ({ bufferEntry }) => {
const existing = parseSnapshotTags(bufferEntry);
const removedCount = existing
? existing.filter((t) => nonEmptyTags.includes(t)).length
: nonEmptyTags.length;
return json({ message: `Successfully removed ${removedCount} tags.` }, { status: 200 });
},
// No `rejectedResponse`: a `remove_tags` patch carries no cap, so the buffer
// never reports `limit_exceeded` for it.
abortSignal: getRequestAbortSignal(),
});

if (outcome.kind === "not_found") {
return json({ error: "Run not found" }, { status: 404 });
}
if (outcome.kind === "timed_out") {
return json({ error: "Run materialisation timed out" }, { status: 503 });
}
return outcome.response;
} catch (error) {
logger.error("Failed to remove run tags", { error });
return json({ error: "Something went wrong, please try again." }, { status: 500 });
}
}
3 changes: 3 additions & 0 deletions apps/webapp/test/performTaskRunAlertsStoreRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ class RoutingRunStore implements RunStore {
pushTags(runId: string, ...a: any[]): any {
return (this.#resolveById(runId).pushTags as any)(runId, ...a);
}
removeTags(runId: string, ...a: any[]): any {
return (this.#resolveById(runId).removeTags as any)(runId, ...a);
}
pushRealtimeStream(runId: string, ...a: any[]): any {
return (this.#resolveById(runId).pushRealtimeStream as any)(runId, ...a);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/test/updateMetadataStoreRoutingHetero.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ class RoutingRunStore implements RunStore {
pushTags(runId: string, tags: string[], where: any, _tx?: unknown): any {
return this.#resolveById(runId).pushTags(runId, tags, where);
}
removeTags(runId: string, tags: string[], where: any, _tx?: unknown): any {
return this.#resolveById(runId).removeTags(runId, tags, where);
}
pushRealtimeStream(runId: string, streamId: string, _tx?: unknown): any {
return this.#resolveById(runId).pushRealtimeStream(runId, streamId);
}
Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@
"management/runs/reschedule",
"management/runs/update-metadata",
"management/runs/add-tags",
"management/runs/remove-tags",
"management/runs/retrieve-events",
"management/runs/retrieve-trace",
"management/runs/retrieve-result"
Expand Down
4 changes: 4 additions & 0 deletions docs/management/runs/remove-tags.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: "Remove tags from a run"
openapi: "v3-openapi DELETE /api/v1/runs/{runId}/tags"
---
34 changes: 33 additions & 1 deletion docs/tags.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ We don't enforce prefixes but if you use them you'll find it easier to filter an

## How to add tags

There are two ways to add tags to a run:
You can add tags to a run in two ways:

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

You can also [remove tags](#removing-tags) from a run while it's running.

### 1. Adding tags when triggering the run

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

## Removing tags

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()`:

```ts
import { tags, task } from "@trigger.dev/sdk";

export const myTask = task({
id: "my-task",
run: async (payload: { message: string }) => {
await tags.add(["status_processing", "user_123456"]);

// ...do the work...

// Remove a single tag, or an array of tags
await tags.delete("status_processing");
await tags.add("status_done");
},
});
```

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.

<Note>
Removing a tag the run doesn't have does nothing and won't throw, so you don't need to
check the run's current tags first.
</Note>

Tags can only be removed from inside the `run` function — there's no equivalent option when triggering.

## Filtering runs by tags

You can filter runs by tags in the dashboard and in the SDK.
Expand Down
85 changes: 83 additions & 2 deletions docs/v3-openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,10 @@ paths:
- lang: typescript
label: SDK
source: |-
import { runs } from "@trigger.dev/sdk";
import { tags } from "@trigger.dev/sdk";

await runs.addTags("run_1234", ["tag-1", "tag-2"]);
// Called from inside a run — applies to the current run
await tags.add(["tag-1", "tag-2"]);
- lang: typescript
label: Fetch
source: |-
Expand All @@ -441,6 +442,86 @@ paths:
},
body: JSON.stringify({ tags: ["tag-1", "tag-2"] }),
});
delete:
operationId: remove_run_tags_v1
summary: Remove tags from a run
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.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- tags
properties:
tags:
$ref: "#/components/schemas/RunTags"
responses:
"200":
description: Successful request
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: "Successfully removed 2 tags."
"400":
description: Invalid request
content:
application/json:
schema:
type: object
properties:
error:
type: string
"401":
description: Unauthorized request
content:
application/json:
schema:
type: object
properties:
error:
type: string
enum:
- Invalid or Missing API Key
"404":
description: Run not found
content:
application/json:
schema:
type: object
properties:
error:
type: string
enum:
- Run not found
tags:
- runs
security:
- secretKey: []
x-codeSamples:
- lang: typescript
label: SDK
source: |-
import { tags } from "@trigger.dev/sdk";

// Called from inside a run — applies to the current run
await tags.delete(["tag-1", "tag-2"]);
- lang: typescript
label: Fetch
source: |-
await fetch("https://api.trigger.dev/api/v1/runs/run_1234/tags", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${process.env.TRIGGER_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ tags: ["tag-1", "tag-2"] }),
});

"/api/v1/runs/{runId}/trace":
parameters:
Expand Down
Loading
Loading