Skip to content

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

Open
claude[bot] wants to merge 7 commits into
mainfrom
claude/tags-delete
Open

feat(sdk,webapp): add tags.delete() to remove tags from a run#4424
claude[bot] wants to merge 7 commits 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: 2ad0a98

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/python Patch
@internal/dashboard-agent Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json 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
@internal/tracing Patch
@internal/webhook-engine Patch
@internal/webhook-sources Patch
@internal/cache Patch
@trigger.dev/react-hooks Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/sso Patch
@internal/testcontainers 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

claude added 3 commits August 28, 2026 10:55
…updatedAt off the app clock

- Removal now re-reads the run from the primary when the replica shows none of the
  requested tags, so an add-then-delete in the same run is not silently skipped.
- A DELETE with an unparseable body answers 400 rather than falling through to 500.
- The buffered-run synthesised response no longer reports removals that did not
  happen when the snapshot carries no tags array.
- removeTags binds "updatedAt" from the app clock instead of NOW(), matching
  expireRunsBatch, so the read-your-writes watermark is comparable to Date.now().

Co-Authored-By: Claude <noreply@anthropic.com>
Resolves the two conflicts in the store-routing tests. Upstream (#4805) replaced
each file's hand-rolled test-local RoutingRunStore shim with the real
RoutingRunStore from @internal/run-store, so the shim's removeTags forwarder this
branch had added is obsolete; the real router already carries removeTags.

Co-Authored-By: Claude <noreply@anthropic.com>
main gained DelegatingRunStore and the TaskRunExecutionSnapshotStore decorator
that extends it, plus the RUN_STORE_METHOD_NAMES registry whose compile-time
parity assertions cover every interface member. None of them knew about the
removeTags method this branch adds to RunStore, so the merged tree did not
typecheck. Add the forwarder and the registry entry, both next to pushTags to
match the interface order.

Co-Authored-By: Claude <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@30e6a4e

trigger.dev

npm i https://pkg.pr.new/trigger.dev@30e6a4e

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@30e6a4e

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@30e6a4e

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@30e6a4e

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@30e6a4e

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@30e6a4e

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@30e6a4e

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@30e6a4e

commit: 30e6a4e

placement.proof.test.ts parses the RunStore interface out of types.ts and fails
until every method is catalogued as a read or a write. removeTags was added to
the interface but not the catalog, so the census reported it as uncatalogued and
CI's internal shard 3/12 went red.

It belongs in ROUTES_BY_GIVEN_RUN_ID next to pushTags: runOpsStore.removeTags
routes through #routeForWrite(runId), which is exactly what the catalog's
GIVEN_RUN_ID_ROUTE check asserts.

Co-Authored-By: Claude <noreply@anthropic.com>
@matt-aitken
matt-aitken marked this pull request as ready for review August 28, 2026 16:24
devin-ai-integration[bot]

This comment was marked as resolved.

The changeset is a user-facing release note for `tags.delete()`. The
redis-worker change behind it is internal plumbing, and CONTRIBUTING.md
and AGENTS.md both name `@trigger.dev/redis-worker` as a package to leave
out of changesets, since it is not consumed independently and a version
bump means nothing to a user.

Listing it also copied the `tags.delete()` note verbatim into the
redis-worker changelog. All `@trigger.dev/*` packages are a `fixed`
version group in the changeset config, so redis-worker still gets the
same version bump either way; only the stray changelog entry goes away.

Co-Authored-By: Claude <noreply@anthropic.com>
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

webapp / 🧪 Unit Tests: Webapp (15, 24) is red on 2ad0a98, and the failure does not come from this PR.

Failing test: apps/webapp/test/runsReplicationService.part3.test.ts → "should merge duplicate event+run.id combinations keeping the latest version", failing with expected 'PAUSED' to deeply equal 'COMPLETED_SUCCESSFULLY'.

Why it isn't this PR's:

  • All 24 webapp shards, shard 15 included, were green on the previous head 30e6a4e. That tree differs from 2ad0a98 by a single line in a changeset markdown file.
  • This PR touches no replication code, and the test file is byte-identical to main.
  • The same shard is green on the last seven completed runs of this workflow.

The test looks racy rather than broken by anything here. It builds the service with flushIntervalMs: 100, then issues six sequential writes (not in a transaction, despite the comment above them), then waits for taskRunInserts on the first flushed batch to reach length 2 and asserts the run status from that batch. If the flush timer fires partway through the sequence, that length condition is already satisfied at the third update, which is PAUSED — exactly the observed value. Asserting against the last flushed batch, or waiting for the terminal status, would make it deterministic.

I don't have permission to re-run CI jobs, so getting this to green needs a re-run of that one shard by someone who does.


Generated by Claude Code

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.

2 participants