Commit Graph

8248 Commits

Author SHA1 Message Date
Katia Bulatova aa55227b84 fix(cli): say that a capless mint-token is read-only 2026-08-08 09:22:49 +00:00
Katia Bulatova 4c422300c8 fix(webapp): stop treating a ClickHouse unknown identifier as a rollout gap 2026-08-08 09:22:48 +00:00
Katia Bulatova d0be659457 test(webapp): assert the queue depth trend fills its bucket grid 2026-08-08 09:22:47 +00:00
Katia Bulatova 0db1cf0d23 fix(webapp): say why a report's numbers can't be trusted
Absent telemetry and an unmeasured flow were both labelled stale data, so every
snapshot-based report claimed staleness it could not have measured. Choose the
badge and caveat from the reason instead.
2026-08-08 08:29:47 +00:00
Katia Bulatova 1ee6704d53 fix(webapp): describe how far a report metric fell
A fall's multiplier rounds to 0 or 1, so every drop rendered as "flat" — a metric
that collapsed from 100 to 5 read as unchanged. Measure the fall against the
baseline instead, and show a bare arrow when it collapsed to nothing.
2026-08-08 08:29:46 +00:00
Katia Bulatova 05505d5741 fix(webapp): carry a queue's depth forward across empty buckets
The per-queue metrics route mapped ClickHouse rows straight to an array, so a
bucket with no sample shortened the trend and shifted every later point in time.
Fill a fixed-width grid the way the two sibling callers do.
2026-08-08 08:29:46 +00:00
Katia Bulatova 2d23ddec17 fix(webapp): keep the retired chats.messages column, and allow Google SSO avatars
Dropping the column was irreversible and blocked on a production row count; retiring it is not. The avatar host is an exact origin the app already knows, like the GitHub one.
2026-08-08 08:29:43 +00:00
Katia Bulatova 5126bae044 fix(webapp): gate a run's commit metadata on reading deployments
The route served a deployment's git blob — commit message, author, branch,
PR title — with no ability check, while the deployments list serves the same
blob behind read on deployments. Apply that check here too.
2026-08-07 23:50:04 +00:00
Katia Bulatova 983055aef0 fix(dashboard-agent): keep a withheld object's key names from the eval judge
The shape descriptor for a redacted object copied its key names verbatim, so
a payload keyed by an email address sent that address to the judge model and
into the chat_turn_evals row. Emit a count instead, in both the shape and the
depth-cap descriptor, and stop allow-listing "keys" so a tool's own field of
that name is redacted like any other.
2026-08-07 23:50:04 +00:00
Katia Bulatova 33a3f6b882 fix(dashboard-agent): require both halves of the support endpoint config
ask_support defaulted its URL to localhost and gated only on the secret, so
with the secret set and the URL unset the user's question and the bearer
secret went to a local port inside the agent's container.
2026-08-07 23:50:03 +00:00
Katia Bulatova 0f2814e87b fix(dashboard-agent): escape model-supplied ids in tool request paths
list_environments, get_run, get_run_trace and get_error interpolated a bare
z.string() straight into the request path, so a model-chosen id could steer
the call at another same-origin route.
2026-08-07 23:50:02 +00:00
Katia Bulatova cb0bcc92b9 fix(webapp): refuse an environment-scoped token on a route that names nothing
assertUserActorScope returned early whenever the passed scope carried no
org, project or environment, and the route builder passes {} for any route
that declares no context — so the guard was a no-op there. api.v1.orgs's
action is such a route and has no authorization block either, letting a
read-only agent token create an organization.

Fail closed instead, with an explicit identityOnly opt-in for the two
contextless loaders that answer with the caller's own identity, and give
org creation the gate its siblings have.
2026-08-07 23:50:02 +00:00
Katia Bulatova f89158477c fix(webapp): stub what the env JWT act-claim test's route actually calls
The test mocked the old preamble, so the route hit real rbac and a logger without
`info`, failing with a 403 and an uncaught type error.
2026-08-07 23:31:56 +00:00
Katia Bulatova bd357fc837 revert(webapp): put the JWT exchange back to how main had it
The scope ceiling rewrite landed here and was reverted two PRs up, leaving the
stack asserting both directions. The exchange intersects requested scopes with the
token's cap again, a capless token passes through like a PAT, and the route keeps
only the environment claim check and the acting client.
2026-08-07 23:31:55 +00:00
Katia Bulatova df2227e1a3 fix(webapp): resolve a test's route paths from the test, not the repo root
The queue-JWT test read its route sources through repo-root-relative paths, so it
never resolved from the webapp's own working directory.
2026-08-07 23:31:55 +00:00
Katia Bulatova 8adf2b2523 Merge remote-tracking branch 'origin/main' into feat/dashboard-agent-flows 2026-08-07 23:03:15 +00:00
Katia Bulatova c23660197f feat(webapp): let an environment JWT read a queue, as it already reads its metrics
The dashboard agent asks for a queue's live row — paused, depth, limit — through the environment JWT it exchanges for. The metrics route has accepted that JWT all along; the retrieve route answered 401, so the agent saw no queue at all.
2026-08-07 22:27:07 +00:00
Katia Bulatova 711b79ee8a fix(dashboard-agent): apply the token's cap as a second ceiling, and finalise only this turn's messages
Review of #4418: the cloud path builds the ability from the user's role, so a read-only delegated token could exchange it for a write JWT; and the finalisable set was the whole replayed transcript rather than what the turn produced.
2026-08-07 19:32:35 +00:00
Eric Allam c526528d8f feat(webapp,database): bound Prisma list filter arity (#4480)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
## Summary

Prisma expands `in` / `notIn` into one bind parameter per element, so
every distinct list
length is a separate prepared statement. Where the length tracks data
volume (a batch size,
a run-graph fan-out, a prior query's id set) one call site can mint
hundreds of them. Each
is used about once, but inserting it evicts an entry that was being
reused, so the cost
lands on unrelated queries sharing the pooler's statement cache. An
unbounded list also
risks the 65535 bind-parameter ceiling.

`boundedIn()` pads a filter list to the next power of two by repeating
its last element.
`IN` and `NOT IN` ignore duplicates, so results are unchanged, and a
call site drops from
one statement per length to at most `log2(cap)`. Applied to all existing
sites.

## Enforcement

Two oxlint rules require the helper: a list filter must be an inline
array literal or a
`boundedIn()` call.

- The first covers filters reached through `where` / `having` /
`cursor`, and deliberately
never descends into `data`, `create`, `update`, `set` or `equals`. A key
named `in` in
those positions is user data, not a predicate, and rewriting it would
corrupt what gets
  stored or compared.
- The second covers bare filter objects passed to where-building
helpers, which the first
cannot see. It found five sites in the run-graph batch loaders that were
otherwise
  invisible.

Both rules follow filters through the shapes they are actually written
in: conditional
expressions, logical-and objects, spread-conditional properties,
computed keys, and call
arguments. An array literal only counts as fixed-arity when nothing
spreads into it, since
`[...new Set(ids)]` has a runtime length. Twelve sites were hidden
behind those shapes
until the rules handled them.

Scoped to `in` and `notIn`. The scalar-list filters `hasSome` and
`hasEvery` compile to
`&& $1` and `@> $1`, passing the whole array as a single bind parameter,
so their arity never
reaches the statement text and there is nothing to bound.

Both rules are `error`, so new call sites fail CI. That ratchet has
already caught four
sites added by other PRs while this one was in review.

## Notes

`boundedIn` pads by repeating rather than with null: `x NOT IN (a, b,
NULL)` is never true,
so null-padding a `notIn` filter would silently return no rows. Lists
above 32768 are
returned unchanged so padding can never push a query past the parameter
limit.

Route modules reach the helper through `~/db.server` rather than
importing the database
barrel directly, since a value import of that barrel into a module that
also exports a React
component is only safe while dead-code elimination prunes it.

Measured on a local rig: 300 distinct list lengths produce 300 prepared
statements
unpadded, 10 padded. Verified end-to-end against a local stack with the
full task-suite
sweep, which surfaced no regressions.
re2-test-supervisor-main-c526528 re2-prod-supervisor-heatwave-dualwrite
2026-08-07 16:39:58 +01:00
Eric Allam 63176a6d69 fix(webapp): stop api inheriting inbound sampled traceparents so trace sampling applies (#4532)
## What

The internal tracing `ParentBasedSampler` in `tracer.server.ts` left
`remoteParentSampled` at its default of `AlwaysOn`. Any request arriving
with a `traceparent` whose sampled flag was set got recorded in full,
bypassing `INTERNAL_OTEL_TRACE_SAMPLING_RATE` entirely. Because the SDK
propagates its (always-sampled) trace context on calls back to the
platform from inside running tasks, the large majority of API server
spans inherited a sampled parent and ignored the divisor. The sampling
knob was effectively inert on the busiest service.

This registers a custom propagator
(`NonInheritingTraceContextPropagator`) that stops adopting the inbound
trace as the parent:

- `inject` still delegates to the standard W3C trace + baggage
propagators, so outbound propagation is unchanged.
- `extract` drops the parent span (`trace.deleteSpan`) while preserving
baggage, so every incoming request roots its own trace and the ratio
sampler applies uniformly.

`remoteParentSampled` is also set to the ratio sampler as a
belt-and-suspenders fallback, in case an inbound sampled parent ever
reaches the sampler another way.

Two effects: the divisor becomes effective on the API server, and the
API no longer stitches onto (and inflates) the propagated task-run
traces, which is where the very large, un-thinnable trace chains came
from. Rooting each request removes those chains rather than only
diluting them.

Only the internal APM trace pipeline
(`INTERNAL_OTEL_TRACE_EXPORTER_URL`) is affected. The user-facing
run-trace pipeline (`otel.v1.traces` -> ClickHouse) is a separate path
and is untouched. The only consumer of the global propagator's `extract`
is the OTel HTTP/Express auto-instrumentation, so the blast radius is
inbound-request trace shape.

## Evidence (local full-stack red/green, divisor 10)

A local OTLP/JSON sink counting spans; a driver fires N requests at a
real endpoint, each carrying a distinct sampled `traceparent`, then
counts how many spans/traces carry that run's marker.

| run | code | sent | kept traces | kept fraction |
| --- | --- | --- | --- | --- |
| before | unmodified | 500 | 500 | 1.00 |
| after | this PR | 500 | 67 | 0.134 |
| after | this PR | 2000 | 213 | 0.1065 |

Before: 100% of inherited-sampled requests kept, divisor ignored. After:
~10% kept (the divisor), converging on it at larger N. In every
after-run each kept request is a single self-rooted trace (kept spans ==
kept distinct traces), confirming the inherited chains are gone, not
just thinned. `typecheck` passes.

## Rollout / rollback

No flag. Behavior stays governed by the existing
`INTERNAL_OTEL_TRACE_SAMPLING_RATE`. Rollback is a straight revert with
no data migration.

## Notes

Internal dashboards that count raw span or request volume from this
pipeline will read lower once this ships. That is expected: those counts
were inflated by the bypass, not a real drop in traffic.
Latency/percentile monitors retain plenty of samples at the current
divisor.

refs TRI-13031
2026-08-07 15:13:42 +01:00
Katia Bulatova 2ff7ec008b fix(dashboard-agent): name a head-started chat instead of leaving it New chat
The gate counted transcript length, and a warm first turn arrives with the model's opening step already in it — so the very first exchange looked like a later one.
2026-08-07 14:06:24 +00:00
Iss 98cdf89c4f chore: vouch NERLOE (#4531)
Adds [NERLOE](https://github.com/NERLOE ) to the list of vouched outside
contributors so their PRs aren't auto-closed by the vouch check.
2026-08-07 13:54:16 +00:00
Katia Bulatova c025bbfcb4 fix(dashboard-agent): keep the finished answer in the transcript, not the mid-flight one
A turn stores its messages before the model finishes, so the completed bodies arrived against ids that already existed and were skipped. Reopening a chat then replayed a tool call that never ends.
2026-08-07 13:46:12 +00:00
github-actions[bot] 72f50c2dad chore: release v4.5.10 (#4440)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-supervisor (push) Has been skipped
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
helm-v4.5.10 v.docker.4.5.10 v4.5.10
2026-08-07 14:06:43 +01:00
Katia Bulatova 798fdf94b7 refactor(webapp): split the dashboard agent's UI out of the first PR
The system — contracts, storage, auth, the agent package and its webapp routes — lands first; the panel, the page-context marks and the entry points follow in their own PR.
2026-08-07 12:34:06 +00:00
Eric Allam 7246f677db fix(webapp): strip null bytes from idempotency and debounce keys at trigger (#4527)
## What

A trigger request carrying a Unicode NUL (`U+0000`) in the **idempotency
key** or **debounce key** reached `prisma.taskRun.create()` and failed
the insert, so the caller got an opaque 500 and the run was never
created.

These two keys are stored in `jsonb` columns (`idempotencyKeyOptions`,
`debounce`), and Postgres rejects a NUL inside a `jsonb` value with
`SQLSTATE 22P05` ("unsupported Unicode escape sequence ... cannot be
converted to text"). This fix strips the NUL from both keys at the
single trigger-input chokepoint (`#buildEngineTriggerInput`), which
every trigger path flows through (single, batch item, mollified, and
drainer replay).

Stripping matches the existing precedent for run errors and task events.
It does not change dedup behaviour: the idempotency **dedup identity**
is the hashed key (a clean 64-char digest), computed independently of
the raw key we clean, so dedup keeps working exactly as before. For
debounce the key is used directly, so the cleaned key also becomes the
grouping key, an acceptable change for input that is already malformed.

## Why not payload / metadata / tags

Those are `text` columns fed by `JSON.stringify`, which escapes a NUL to
a safe escape sequence, so they do not hit this failure on the normal
JSON path. (A raw NUL in a `text` column throws a different code,
`22021`, and is not what triggers this issue.) The observed failures are
the `jsonb` `22P05` variant, which is only reachable via the two key
fields.

## Evidence

Red then green (containerTest, real Postgres): with the fix reverted,
triggering through the real service with a NUL in
`idempotencyKeyOptions.key` / `debounce.key` fails with the exact
`22P05` signature; with the fix, the run is created and the stored key
has the NUL removed.

Full-stack e2e (isolated stack, real HTTP): `POST
/api/v1/tasks/:taskId/trigger` with a NUL inside
`idempotencyKeyOptions.key` (`"acme<NUL>inc"`) and, separately,
`debounce.key` (`"grp<NUL>1"`):

- both returned `HTTP 200` with a created run (previously `500`)
- stored `idempotencyKeyOptions` = `{ "key": "acmeinc", "scope": "run"
}` (7 chars, NUL removed)
- stored `debounce.key` = `"grp1"` (4 chars, NUL removed)
- both runs render in the dashboard

Unit tests cover the helper (strip, no-op fast path, object-reference
reuse, null/undefined pass-through).

## Rollout / rollback

Server-only webapp change, no flag. Zero behaviour change for clean
input; only affects inputs that previously 500'd. Rollback is a straight
revert, no data migration.

## Known limitation

A raw NUL in a plain-string idempotency key (not created via
`idempotencyKeys.create()`) lands in a `text` column and throws `22021`
instead. That variant is not addressed here because stripping it would
change the dedup identity, so it warrants a separate decision. Not
observed in practice.

refs TRI-13030
2026-08-07 13:28:52 +01:00
claude[bot] dc529414df feat(webapp): add /_/* redirect route (#4523) 2026-08-07 13:21:07 +01:00
Katia Bulatova 49051f51df fix(dashboard-agent-db): stop rewriting the published migration history
0000 and 0001 already shipped, so the agent's new tables land in a third migration instead of a squashed first one.
2026-08-07 12:03:55 +00:00
Katia Bulatova dfcee8bf33 fix(webapp): settle the API keys route after merging main 2026-08-07 11:54:59 +00:00
Katia Bulatova db9f9a2502 Merge remote-tracking branch 'origin/main' into feat/dashboard-agent-flows
# Conflicts:
#	apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx
2026-08-07 11:50:37 +00:00
Katia Bulatova 9c6f14872f chore(webapp): keep Ask AI in the tree, deprecated and unmounted
Its old keystroke now opens Ask Trigger instead of nothing.
2026-08-07 11:40:29 +00:00
Chris Arderne 0a44b88b39 fix: security release 2026-07-21 (#4528) 2026-08-07 12:25:40 +01:00
Katia Bulatova 0d3d21659f feat(webapp): drop the agent button from the deploy blank states 2026-08-07 10:47:00 +00:00
Katia Bulatova c88a4483da refactor(webapp): rename the ask-ai button variant to ask-trigger 2026-08-07 10:43:34 +00:00
Katia Bulatova 5edfd78f80 feat(webapp): name the dashboard agent Ask Trigger everywhere
The launcher spelled it out while every other surface read it from the shared label.
2026-08-07 10:36:11 +00:00
Katia Bulatova c37bdf81cb test(webapp): pin the delegated token's scope ceiling on the RBAC fallback
Also collapses the environment guard's per-function docs into one file-level note.
2026-08-07 10:30:26 +00:00
Katia Bulatova 9fad66554e test(webapp): cover the dashboard agent's eval-policy gate, and require its token's environment
The mint's environment is what every environment-bound endpoint reads off the token, so it is now a required argument rather than an optional one.
2026-08-07 10:17:19 +00:00
Eric Allam db67a856fe perf(webapp,database): index the newest-task-version lookup (#4518)
📦 Preview packages (pkg.pr.new) / Build and publish previews (push) Has been cancelled
📚 Publish docs / publish (push) Has been cancelled
Implementing PlanetScale Insights improvement.

## Summary

Validating a schedule (creating or updating one through the API or the
dashboard, and deploying a project that declares schedules) looks up the
newest version of a task by slug. That lookup reads *every* version of
the task and sorts them to return one. A project gains a row per task on
every deploy, so the work grows with the project's age: the oldest
projects pay the most, and dev-mode redeploys make it worse. This was
picked because it was the largest single consumer of database time on
the schedules path, and the fix is a sort key with no index behind it.

## Fix

`BackgroundWorkerTask` is indexed on `(projectId, slug)`, which serves
the equality but not the `ORDER BY createdAt DESC`. Postgres seeks the
index, then bitmap-scans and top-N sorts the whole group to produce a
single row. Adding `createdAt` to the index lets it scan backward and
stop at the first row.

The same call site also selected all 21 columns, including five JSON
blobs, to read one field (`triggerSource`), so it now selects that field
alone.

## Benchmark

Local Postgres 17, 997,000 seeded rows / 748 MB, group sizes chosen to
match the distribution seen in production.

| Group size | Before | After |
| --- | --- | --- |
| 15,000 versions of one task | 11.118 ms, 1,510 buffers, 15,000 rows
scanned | 0.027 ms, 4 buffers, 1 row |
| 2,000 versions of one task | 2.081 ms, 1,455 buffers, 2,000 rows
scanned | 0.022 ms, 4 buffers, 1 row |

```
before:  Limit -> Sort (top-N heapsort) -> Bitmap Heap Scan
after:   Limit -> Index Scan Backward using BackgroundWorkerTask_projectId_slug_createdAt_idx
```

An ascending index scanned backward is enough here, so no descending
index is needed.

## Impact and risk

Real-world gain lands between the two rows above and scales with how
many deploys a project has accumulated. Projects with few deploys will
see little change, since there is barely anything to sort.

The new index costs noticeably more than the existing two-column one: 43
MB against 7.3 MB on the benchmark rig. Adding `createdAt` makes every
key unique, which defeats btree deduplication, so this is a real disk
and write cost rather than a rounding error. Writes to this table happen
at deploy time, not on the run path, so the write amplification is
acceptable. The existing `(projectId, slug)` index is now a redundant
prefix and could be dropped, but this PR keeps it so index usage can be
observed before removing it.

Behavior is unchanged: same predicate, same ordering, same row returned.
The narrowed select is the only code change, and the field it keeps is
the only one the caller read.

Deploy note: the migration is
`20260806100000_add_background_worker_task_project_id_slug_created_at_index`
and uses `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, so it can be
pre-applied by hand before the deploy.
docs-release-2026-08-07
2026-08-07 11:17:10 +01:00
Eric Allam 6c6e58e6ff perf(webapp): batch declarative schedule cleanup queries (#4522)
## Summary

`syncDeclarativeSchedules` runs on every background-worker creation
(every deploy, and every file save during `trigger dev`). It issued one
instance-delete per declarative schedule the current worker no longer
declares, in a loop, and the overwhelming majority of those deletes
matched zero rows. This collapses the loop into at most two set-based
statements and skips the instance delete entirely when the current
environment owns no instance of the schedule.

## Why so many, and mostly no-op

The loop runs once per entry in `missingSchedules`, which starts as
every DECLARATIVE schedule for the whole project across all its
environments (the query filters only by `projectId`). A schedule leaves
that set only when a declared task matches it by `taskIdentifier`
**and** the schedule already has an instance in the current environment.

That last clause is the amplifier. When a task's schedule has no
instance in the current environment, the create branch inserts a
brand-new `TaskSchedule` row with an instance for this environment
rather than adding an instance to the existing row. So the same
scheduled task, once it has run in dev and been deployed to prod, exists
as two separate schedule rows: one carrying a dev instance, one carrying
a prod instance.

On a dev worker sync of that project:

- the dev-instance row matches the declared task and is removed from the
set
- the prod-instance row has the same `taskIdentifier` but no dev
instance, so it stays in the set and gets `deleteMany(taskScheduleId =
prodRow, environmentId = dev)`, which matches zero rows

So every declarative task that has been synced in another environment
contributes one guaranteed no-op delete per sync, and the count scales
with (declarative tasks x environments), plus any leftover rows from
renamed or removed tasks. A project does not need to have dropped a
schedule to generate these; it just needs the same declarative tasks
present in more than one environment, which is the normal
develop-in-dev, deploy-to-prod case.

## Fix

The candidate schedules are already loaded with their instances, so the
branch is decided in memory:

- schedules with no instances (or only current-environment instances)
are removed in a single `taskSchedule.deleteMany`
- schedules that still have another environment's instance have only the
current environment's instance detached, in a single
`taskScheduleInstance.deleteMany`, and only when such an instance
actually exists

Behavior is unchanged (cascade delete still removes the instances of a
deleted schedule); the difference is statement count. A zero-row delete
writes no WAL and creates no dead tuples, so the removed work was pure
query and commit overhead.

Verified with a testcontainer test (red before, green after) counting
the emitted deletes across the no-op, batched-detach, and
schedule-delete cases, and end to end through `trigger dev`: three
declarative schedules created, surviving a re-sync, then two removed in
a single batched delete with the third preserved.
2026-08-07 10:27:39 +01:00
Matt Aitken 04f9c4e1a5 fix(webapp,run-engine,core): drop the hidden debounce ceiling, fail fast on an unusable maxDelay (#4521)
Debouncing with a `delay` longer than an hour did nothing at all.

The engine applied a server-side ceiling on how long a debounced run
could be pushed back, measured from the run's `createdAt` and defaulting
to one hour. A run is only pushed back while its new execution time
stays inside that ceiling, so a `delay` at or above it could never push
anything: the waiting run was released, the trigger started its own run,
and the next trigger repeated it. A `delay: "12h"` produced one run per
trigger, each correctly delayed by 12h, with no error raised and nothing
on the run to show the debounce key had been ignored.

The ceiling is now unset by default. A debounce key with no `maxDelay`
keeps collapsing triggers for as long as they keep arriving, which is
what the docs have always described. Self-hosters who want a bound can
still set `RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS`.

That has a consequence worth stating plainly, so the docs now carry a
warning for it: with no `maxDelay`, a continuously triggered key never
executes. Set `maxDelay` when the work has to happen eventually.

**Failing fast on an unusable `maxDelay`.** A caller who sets `maxDelay`
no longer than their `delay` hits exactly the dead end described above,
so that pair is now rejected at trigger time instead of silently
behaving as if no debounce were set:

```
debounce.maxDelay (1h) must be longer than debounce.delay (12h). A debounced run is only
pushed back while it stays inside maxDelay, so with these values every trigger would create
its own run.
```

An unparseable `maxDelay` is rejected too, rather than quietly falling
back to no bound at all, and so is a `delay` given as a date rather than
a duration, which could never work because the value is re-applied on
every push.

The same check runs against a configured server ceiling, so a
self-hosted deployment that sets
`RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS` gets the error rather than the
silent failure this PR is about. With no `maxDelay` and no configured
ceiling, which is the default, there is nothing to conflict with and
nothing is rejected.

The docs, the `TriggerOptions` JSDoc and the engine option all now state
that the room available to push is the gap between `delay` and
`maxDelay`. The run engine suite gains the case that motivated this:
four triggers on one key with a 12h delay now collapse to a single run.
2026-08-07 07:55:35 +00:00
Katia Bulatova bb036f92e8 feat(webapp): split Watch out of the dashboard agent's first PR
The agent ships Chat and Investigate here; Watch — telling the user later —
follows in its own PR. The whole user-facing feature leaves: the watch card,
chips, wake banner and toast, the unread-wake badge and its poll, the watch
routes, checks, batches and sweeps, the watch alert email and its channel type,
and the watchMaintenance cron.

The agent no longer promises it either: schedule_watch and the alert tools are
gone from the tool set, and the Watches section is out of the system prompt.
Leaving that text in would have had the agent refuse to poll for something it
could no longer offer.

The datastore's watch tables stay. The migrations ship in this PR, so the drizzle
schema that describes them has to ship too — a schema that no longer matched the
migrated tables would make the next generate emit a drop.
2026-08-06 18:35:38 +00:00
Katia Bulatova f4337f7fcf style(dashboard-agent-db): format the generated migration snapshot 2026-08-06 18:10:22 +00:00
Katia Bulatova e3fcc208b8 refactor(dashboard-agent-db): collapse the agent's migrations into one
The datastore's 21 migrations recorded the development history of a feature
that has never shipped, including a column added in 0019 and dropped in 0020.
Regenerated from the schema with drizzle-kit, so the single file is exactly
what `src/schema.ts` declares.

Replaying from an empty database gives the same 8 tables, 118 columns, 26
indexes and 9 constraints as the 21 files did; only the physical column order
differs, because a column added by ALTER lands at the end.
2026-08-06 17:38:23 +00:00
Katia Bulatova 3d3c96296b fix(webapp): keep a malformed message's own text out of the error, and pin a finalisation to its body id
The malformed-message error carried 200 characters of the payload, which can be user text or tool output. It now names the shape only. A finalisation also verifies that the body's id is the row it targets, so the stored key and the payload cannot name different messages. The legacy-column guard missed a schema-qualified update, and now self-tests both spellings.
2026-08-06 17:19:20 +00:00
Katia Bulatova ceaa38d7fb fix(webapp): scope the watch transcript's appends to the caller's organization
appendChatMessageOnce already verifies organizationId, but left it optional
because these call sites never threaded one — so the wake and the consented
investigation wrote durable user-facing messages with the organization check
skipped. Both paths now pass it on every append, the retry repair included, and
the chat id and the organization have to agree for a row to land.
2026-08-06 16:33:55 +00:00
Katia Bulatova f159c69c46 test(webapp): guard against a raw-SQL reference to the dropped chats.messages
The transcript moved out of `chats.messages` into `chat_messages`. TypeScript
already rejects a reference through the Drizzle schema, but a raw-SQL reference
compiles fine and only fails at runtime, and the earlier guard test went away
with the column. Zero hits today is the point: the test exists so a
reintroduction is caught rather than deployed.
2026-08-06 16:29:24 +00:00
Katia Bulatova d8fda075da fix(webapp): stop an ordinary transcript write from rewriting a stored message
`storeChatMessages` ended in `onConflictDoUpdate`, so `persistMessages` and
`persistTurn` — which are handed a whole snapshot — treated any differing body
under an existing message id as a deliberate finalisation. A stale snapshot
carrying `wake:watch_1:fired`, the watch consent record, the deterministic
confirmation or an investigation settlement card with a different body would
overwrite the durable row that was already recorded. The proxy caps body size
and metadata but does not rewrite message ids, so this was not an
internal-bug-only exposure. The same clause updated only the `message` JSONB and
never the `role` column, so `chat_messages.role` could end up disagreeing with
`message.role` — and the UI reads one while the quota query reads the other.

Ordinary transcript writes are now insert-only. Changing a stored message is its
own operation, `finalizeChatMessage`, guarded on chat id, message id and role.
`role` is verified rather than updated, and verified on both sides: the stored
column must match `expectedRole` and so must the incoming body's own `role`, so
the two cannot drift. A finalisation that matches nothing returns false; one
whose body contradicts `expectedRole` throws.

No production caller depended on the implicit finalisation. Every existing
finalisation-shaped path already writes through an insert-only append:
`settleInvestigationAndCloseCard`, `settleInvestigationStateAndCloseCard` and
the watch request/confirmation/refusal records all use
`appendChatMessageOnce(ByChatId)`.

Also: re-sending a snapshot no longer reserves positions for messages that are
already stored. The chat row is held, the missing ids are read under that lock,
and only those get slots. A 40-message chat grown one turn at a time used to
burn 1+2+…+40 = 820 slots for its 40 rows; it now burns 40. Deltas would be the
proper fix, but that reaches into the agent's turn hooks and is a larger change
than this pass.

Two smaller repairs in the same file: `messageIdOf`/`messageRoleOf` now fail
fast and name the chat and the offending message instead of casting unchecked
and surfacing a `NOT NULL` violation from the driver; and a batch carrying the
same message id twice throws instead of silently keeping the first, since that
is an impossible state and a silent pick is how the upstream bug would stay
invisible.

The comment on `reserveMessagePositions` claiming the row lock is "released with
the statement" was wrong — Postgres holds it to commit — and now says what is
true.
2026-08-06 16:29:24 +00:00
Katia Bulatova aad84988c7 fix(webapp): converge the watch transcript when a retried delivery finds its message already streamed
The wake and the consented investigation both stream their message before they
append the display copy, and the streamed copy is durable on session.out from
that moment. An append that failed therefore left the retry booting with the
message already in its history, taking the dedupe branch and never writing the
row: the model saw the message, the History panel didn't.

Both dedupe branches now re-append the message they found. The append is
id-deduped, so repairing when nothing is broken writes nothing.
2026-08-06 16:26:43 +00:00
Matt Aitken c084fa6e29 fix(sdk,react-hooks): forward debounce when batch triggering with an array (#4520)
Passing `debounce` in the per-item options of a batch trigger did
nothing when the items were an array. The option was accepted by the
types and by the API, then dropped before the request went out, so every
item created its own run instead of collapsing onto the debounce key.

Four public entry points were affected: `task.batchTrigger`,
`task.batchTriggerAndWait`, `tasks.batchTrigger`, and
`tasks.batchTriggerAndWait`. The streaming (async iterable) forms of the
same calls were already correct, as were `batch.trigger`,
`batch.triggerAndWait`, `batch.triggerByTask`, and
`batch.triggerByTaskAndWait`.

`useTaskTrigger` in `@trigger.dev/react-hooks` had the same silent drop
on the single-trigger path, so that is fixed here too. It also drops
`machine`, `priority`, `region`, `idempotencyKeyTTL`, and
`idempotencyKeyOptions`; those are left alone, since forwarding them is
a behaviour change beyond this bug.

Each batch item builder constructs its options field by field, which is
why one of them could fall behind without anything catching it.
TypeScript did not help: the literal is returned from a `.map` callback
inside `Promise.all`, so excess-property checking never fired against
the `BatchItemNDJSON[]` annotation, and the server's schema silently
strips unknown keys. A misspelled option name therefore reproduced this
bug with no compile error and no server error. Every builder now ends in
`satisfies BatchItemNDJSON`, which does catch it:

```
error TS2561: Object literal may only specify known properties, but 'debounceTYPO'
does not exist in type '{ ... debounce?: {...} | undefined; }'.
Did you mean to write 'debounce'?
```

The new test drives all six public batch surfaces in both array and
async-iterable form and asserts on the NDJSON that actually reaches the
wire. Each item carries a distinct debounce key so the test catches a
wrong item-to-option pairing, not just a wholesale drop.

Fixes #3304
2026-08-06 16:54:24 +01:00
Matt Aitken f8e1c910f7 docs(ai-chat): guide for migrating an AI SDK route handler to chat.agent (#4519)
## Summary

Adds a docs page for developers who already have a working Vercel AI SDK
chat app (`useChat` on the client, an `app/api/chat/route.ts` calling
`streamText`) and want to move it to `chat.agent`. There was no page
covering that path. `ai-chat/upgrade-guide` reads like it should be the
one, but it covers moving prerelease `chat.agent` code to the Sessions
release, which is a different reader.

The page is structured around what stays, what goes, and what is new,
because the reassuring part of this migration is how much is untouched:
the `streamText` call, model config, tool definitions, `useChat`, and
all message rendering carry over as-is. What gets deleted is the route
handler, the persistence glue wired into it, and any resumable-stream
setup. What is new is the agent task, two server actions, and
`useTriggerChatTransport`.

Covers moving tools onto the agent config so `toModelOutput` survives
past turn one, where existing database persistence goes
(`hydrateMessages` plus the turn hooks), a short section on what
durability you get once you are across, a note that
Hono/SvelteKit/Express follow the same shape, and a gotchas list built
from the mistakes this specific migration produces.

## Head Start

The one thing this migration makes worse is the opening response of a
new chat. The route handler answered out of a warm process; the agent
run has to be dequeued and booted first. That is the complaint the page
has to answer head on, so Head Start gets a full section rather than a
closing aside, plus a callout up top next to the "what changes" table so
nobody plans the migration without knowing it exists.

The section walks the four steps: splitting tool schemas away from tool
executes (the bundle-isolation constraint the whole feature rests on),
building the handler, mounting it back at `app/api/chat/route.ts` with
the original auth check wrapped around it, and the transport option.
Both server actions stay, because Head Start only owns the first turn.
Three gotchas go with it: a slow first turn without Head Start, Head
Start on but the route bundle still heavy, and the route timing out
because the handler holds the SSE response open for the whole turn
rather than just step 1.

The coding-agent prompt names Head Start as explicitly out of scope, so
an agent handed the migration does not attempt the tool split
unprompted.

Also fixes the `chat.headStart` example on `ai-chat/fast-starts`, which
set `stopWhen: stepCountIs(15)` after the spread.
`toStreamTextOptions()` pins `stopWhen` to `stepCountIs(1)`, so
overriding it makes the warm handler run steps the agent is supposed to
own (and `stepCountIs` was never imported in that snippet either).

## Migration prompt

The page also ships a copy-pasteable prompt for handing the migration to
a coding agent. It tells the agent to run `npx trigger.dev@latest
skills` first, so it picks up guidance version-pinned to the SDK
actually installed in the project, then read `quick-start.md`,
`frontend.md`, and `reference.md` (with `llms.txt` as the index) before
editing anything. The instructions are explicit about preserving the
existing model, prompt, and tool schemas rather than rewriting them.

Registered in `docs.json` under Agents, directly after Quick Start, so
it is picked up by the generated `llms.txt` and the per-page `.md`
variants.
2026-08-06 17:37:23 +02:00