Skip to content

feat(sdk,webapp): add tags.delete() to remove tags from a run - #4424

Draft
claude[bot] wants to merge 1 commit into
mainfrom
claude/tags-delete
Draft

feat(sdk,webapp): add tags.delete() to remove tags from a run#4424
claude[bot] wants to merge 1 commit into
mainfrom
claude/tags-delete

Conversation

@claude

@claude claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Requested by Matt Aitken · Slack thread

Before / After

Before: tags were add-only. Once a tag was on a run — set at trigger time or via tags.add() — there was no way to take it off. A run that used tags to signal state (status_processing, then status_done) accumulated both forever, and there was no public API or SDK function to remove one.

After: you can remove tags from inside a run:

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

export const myTask = task({
  id: "my-task",
  run: async () => {
    await tags.add("status_processing");
    // ...
    await tags.delete("status_processing"); // or tags.delete(["a", "b"])
    await tags.add("status_done");
  },
});

This removes the tags from that run only. It is not a global or org-wide delete: every other run carrying the same tag keeps it, and the tag stays available to filter by. That holds by construction — a run's tags are just strings in its own runTags array, not rows in a shared table.

Removing a tag the run doesn't have is an idempotent success rather than a 404, so you don't have to read the run's current tags first. An empty or blank-only list is also a no-op success. The 10-tag cap doesn't apply to a removal, since a removal can only shrink the list.

How

  • SDKtags.delete added to the tags namespace in packages/trigger-sdk/src/v3/tags.ts, mirroring addTags' in-a-run guard. Both add and delete now carry JSDoc.
  • CoreRemoveTagsRequestBody in schemas/api.ts (reusing RunTags) and ApiClient#removeTags in apiClient/index.ts.
  • HTTPDELETE /api/v1/runs/:runId/tags with a { tags } body. The existing hand-rolled route (api.v1.runs.$runId.tags.ts) now switches on method instead of hard-rejecting non-POST. The POST path is unchanged: same auth, same status codes, same response shapes. Deliberately not converted to createActionApiRoute and no RBAC authorization gate was added, because either would change behaviour for narrowly-scoped JWTs. The route module still exports only action.
  • StoreRunStore#removeTags (types.ts, PostgresRunStore.ts, runOpsStore.ts). It's a single UPDATE that rebuilds the array with unnest, so there's no read-modify-write window for a concurrent pushTags to be lost inside. unnest rather than EXCEPT because EXCEPT would dedupe and reorder the surviving tags. Raw SQL doesn't fire Prisma's @updatedAt, so "updatedAt" is set explicitly and returned — realtime uses it as the read-your-writes watermark. Both the legacy and dedicated schema variants are handled, following expireRunsBatch's array-binding convention.
  • Buffer — a remove_tags SnapshotPatch and Lua branch in packages/redis-worker/src/mollifier/buffer.ts, for runs not yet materialised into Postgres. The branch has to exist: an unhandled patch type falls into the terminal else that returns busy, which surfaces as a 2s stall then a 503. When the last tag goes, the Lua drops the tags field rather than writing an empty table, because cjson encodes an empty Lua table as {} (a JSON object) — an absent tags is the same shape a run triggered without tags has, and every reader already normalises it to an empty list.

No ClickHouse or replication changes are needed: task_runs_v2 is a ReplacingMergeTree keyed on _version, so the TaskRun UPDATE replicates as a replacement row correctly.

Realtime limitation

On the Postgres path the route publishes a change record with the remaining tag set, the same way the add path does. That means a subscribeToRunsWithTag subscriber to a removed tag simply stops receiving the run — there is no "tag removed" un-subscribe signal. That's an accepted limitation of this PR.

Testing

  • internal-packages/run-store/src/PostgresRunStore.test.ts — 8 postgresTest cases for removeTags: removes one, removes several, tag-not-present is a no-op, removing all leaves [], survivor order preserved, survivor duplicates preserved (proving no EXCEPT dedupe), a run in a different runtimeEnvironmentId is untouched (returns null), and updatedAt is refreshed and matches what was stored.
  • packages/redis-worker/src/mollifier/buffer.test.ts — 6 redisTest cases for remove_tags, mirroring the append_tags set: survivor order, unknown-tag no-op, no-tags-field no-op, removing the last tag never writing a JSON object for tags, remove_tags then append_tags rebuilding a dense array, and not_found (not busy) for an unknown run.

Verified locally: pnpm run format, pnpm run lint, pnpm run build --filter @trigger.dev/core, --filter @trigger.dev/sdk, --filter @trigger.dev/redis-worker, pnpm run typecheck --filter webapp, pnpm run typecheck --filter @internal/run-store, and pnpm run build --filter webapp (to confirm no server-only import leaked into the client bundle) all pass.

The two new test files were not executed by CI-equivalent tooling in the authoring environment, because the container images testcontainers needs could not be pulled there. Both layers were instead verified against real servers: the removeTags scenarios above against a locally installed PostgreSQL 16 through the real PostgresRunStore (both the legacy and dedicated schema variants, the latter against the run-ops client), and the remove_tags scenarios against a local Redis 7.0 through the real built MollifierBuffer — all passed, including a control case confirming an unhandled patch type still returns busy — so both files should be re-run in CI.

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>
@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8ad4bd6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/sdk Patch
@trigger.dev/core Patch
@trigger.dev/redis-worker Patch
@trigger.dev/python Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/schema-to-json Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@trigger.dev/sso Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch
@trigger.dev/react-hooks Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant