Skip to content

Update trigger - #4

Open
tylerc-govsignals wants to merge 1699 commits into
GovSignals:ConProgramming/two-phase-deployfrom
triggerdotdev:main
Open

Update trigger#4
tylerc-govsignals wants to merge 1699 commits into
GovSignals:ConProgramming/two-phase-deployfrom
triggerdotdev:main

Conversation

@tylerc-govsignals

Copy link
Copy Markdown
Collaborator

Closes #

✅ Checklist

  • I have followed every step in the contributing guide
  • The PR title follows the convention.
  • I ran and tested the code works

Testing

[Describe the steps you took to test this change]


Changelog

[Short description of what has changed]


Screenshots

[Screenshots]

💯

nicktrn and others added 30 commits August 18, 2026 18:40
Adds KUBERNETES_RUNNER_SECURITY_CONTEXT (off | baseline | restricted), selecting how constrained the run container is.

baseline drops the capability bounding set and blocks privilege escalation. restricted additionally pins the container to a non-root uid, chosen by runtime so bun images get their own.

Default is off, so this is inert on merge.
<!-- ccr-slack-attribution -->
_Requested by **Matt Aitken** · [Slack
thread](https://triggerdotdev.slack.com/archives/C0BKB98B84W/p1787045331358929)_

Copy-only reword of the banner shown to org admins who have not set a
billing limit yet.

**Before** — the banner read "Protect your organization from unexpected
usage spikes." with a button labelled "Configure billing limit".

**After** — it reads "Add a billing limit to your account to prevent
overspending" with a button labelled "Billing limit settings".

The new wording names the action up front and matches the destination it
sends you to, so the banner reads as a settings link rather than a
one-off setup step.

## ✅ Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Formatting and linting pass (`oxfmt --check`, `oxlint`). No tests or
snapshots assert this copy. The change is two string literals in one
component, with no behaviour attached.

---

## Changelog

Reworded the billing-limit banner for organizations without a limit
configured, and relabelled its button to "Billing limit settings".

---

## How

Both strings live in `NoLimitConfiguredBanner` in
`apps/webapp/app/components/billing/OrgBanner.tsx`: the heading is the
`canManageBillingLimits` branch of the banner's children, and the label
is the `<span>` inside the `LinkButton`. Only those two literals
changed. The button still points at `v3BillingLimitsPath(organization)`
(`/orgs/{slug}/settings/billing-limits`), so routing, permissions and
the non-admin variant of the message are untouched.

Co-authored-by: Claude <noreply@anthropic.com>
## What

Runs that carry no external trace context (schedules, task-to-task
triggers) fall back to a trace id generated once in the [`TracingSDK`
constructor](https://github.com/triggerdotdev/trigger.dev/blob/main/packages/core/src/v3/otel/tracingSDK.ts#L165).
With `experimental_processKeepAlive` the SDK outlives the run, so every
run on a warm process is exported to the external OTLP endpoint under
that one id.

Across our production traces, 80.3% contained spans from more than one
run, worst case 25. Per-run cost and latency attribution is unusable as
a result. This is the same warm-start hazard c043c4a fixed for the
external-context path, which left the fallback captured at construction.

## How

`FallbackExternalTraceIds` hands out one id per internal trace, shared
by the span and log wrappers so a run's spans and logs agree.

The id is keyed off the record's own internal trace id rather than
ambient state at export time, because batch processors drain
asynchronously and a run's records routinely export after the next run
has started. The map is bounded and evicts least-recently-used, so a run
that is still exporting can't lose its id.

Granularity follows the internal trace, so a run and the runs it
triggers stay on one trace.

**Risk:** the wrappers only exist when `exporters` / `logExporters` are
configured, so deployments that don't export externally are untouched.
Nothing outside `tracingSDK.ts` changes.

**Known gap (pre-existing):** sampling and id selection still branch on
ambient `getExternalTraceContext()`, so records draining across a run
boundary in mixed mode are misplaced in both directions. It can't use
the approach here — the external id comes from the run's incoming
`traceparent`, which isn't carried on the record — so closing it means
capturing `internalTraceId -> external context` in a span processor.
Happy to follow up separately.

---

## Testing

`packages/core` suite passes. `pnpm run format` and `pnpm run lint:fix`
produce no diff.

Six cases in `externalSpanExporterWrapper.test.ts`, each
mutation-checked rather than just observed passing: one id per run,
stability within a run, correct id when records drain after the next run
started (spans and logs together), external export stays off when
unconfigured, retention of a run still exporting while the map churns,
and the bound itself.

**CI:** the five failing `webapp` shards are the ones containing
`containerTest` suites. Fork PRs receive no repository secrets, so
`unit-tests-webapp.yml` skips the DockerHub login and the image pre-pull
(both gated on `env.DOCKERHUB_USERNAME`) and the container tests time
out at 60s. Same five shards across five runs, every failure a 60s
timeout, and those shards pass on internal PRs. Happy to be corrected if
you can run them with secrets available.

---

## Changelog

Unrelated runs are no longer merged into a single trace in your external
observability tool when they happen to execute on the same warm worker
process. A run and the runs it triggers still share one trace, so a run
tree stays together.

---

## Screenshots

_n/a_

---

_Supersedes #4526 (auto-closed before I was vouched) and #4533 (opened
ready rather than as a draft). GitHub won't reopen either._

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Iss <74388823+isshaddad@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Adds an optional priority class for run pods.

```
KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME
```

When set, the value is applied as `priorityClassName` on the run pod
spec. When unset, pods are created exactly as before.

Off by default, and inert unless set. It sits beside the existing
`KUBERNETES_SCHEDULER_NAME` option and follows the same conditional
shape:

```ts
...(env.KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME
  ? { priorityClassName: env.KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME }
  : {}),
```

## Verification

`typecheck --filter supervisor`, `format` and `lint` clean. No changeset
or `.server-changes/` note: off by default, no user-visible behaviour
change.
## Summary

Enable additional lint rules that catch unsafe optional-chain
assertions, inherited-property iteration, anonymous symbols, and unsafe
external links.

The existing violations now use explicit values and own-property checks,
so the rules can prevent those patterns from returning.
## Summary

Enable small cleanup rules for redundant boolean expressions, object
ownership checks, assignments, and object construction.

The existing call sites now use the simpler equivalent forms, keeping
future code consistent without changing behavior.

Base: [#4672](#4672)
## Summary

Enable JSX cleanup rules for shorthand fragments and self-closing
components.

The existing JSX is automatically simplified, and future components will
follow the same concise form.

Base: [#4673](#4673)
## Summary

Enable the rule that rejects unnecessary standalone blocks.

The existing empty branches are removed so future control flow remains
purposeful.

Base: [#4674](#4674)
## Summary

Enable lint rules that prefer direct iteration and concise function
callback types.

The existing code now uses direct iteration where no index is needed,
and callback contracts use function types consistently.

Base: [#4675](#4675)
## Summary

Memoize shared context values so provider renders do not unnecessarily
rerender every consumer. Oxlint now enforces this pattern for the rest
of the dashboard.

Base: [#4677](#4677)
## Summary

Enable React correctness rules that catch invalid DOM attributes, unsafe
legacy APIs, and malformed component contracts before they reach users.

Base: [#4678](#4678)
## Summary

Enable accessibility rules that catch invalid ARIA usage, inaccessible
media, and invalid focus behavior before they reach users.

Base: [#4679](#4679)
## Summary

Call dashboard hooks unconditionally so components keep a stable hook
order when their props change.

Base: [#4680](#4680)
## Summary

Keep the chart loading line renderer stable across parent renders so its
animated SVG paths retain their component identity.

Base: [#4681](#4681)
## Summary

Remove redundant React fragments from dashboard components, leaving
their rendered output unchanged while simplifying component trees.

Base: [#4682](#4682)
## What

The `Pre-pull testcontainer images` step is gated on
`env.DOCKERHUB_USERNAME`. Fork PRs receive no repository secrets, so
that variable is empty and the step is skipped along with the DockerHub
login it was grouped with.

## Why

With the pre-pull skipped, testcontainers pulls images lazily — inside
the first test that resolves the fixture, against that test's
`testTimeout`. On PR #4534 that pushed five webapp shards past their 60s
cap across three runs, each failing as `Test timed out in 60000ms` while
42 of 43 files in the shard passed.

Measured cost of the missing pre-pull, comparing the delta from vitest
start to the first container fixture on the same runner class:

| Run | Delta |
| --- | --- |
| internal x2 | +139.9s, +139.4s |
| fork x2 | +149.7s, +149.4s |

A 10.0s penalty, bimodal to within 0.3s.

Note the pulls themselves succeed anonymously — there are no rate-limit
errors in any of the failing logs. Only the login needs credentials, so
the pre-pull can run unconditionally.

## Scope

Removes the `if:` from the pre-pull step in all five workflows that have
one. The DockerHub login stays gated, since it genuinely needs secrets.
## What

Three corrections to the pre-pull lists, each verified against what the
suites actually use.

## Changes

**`ryuk:0.11.0` -> `0.14.0`** in `e2e-webapp.yml` and
`e2e-webapp-auth-full.yml`. The installed testcontainers hardcodes the
image it starts:

```js
// testcontainers@11.14.0 build/reaper/reaper.js
: ImageName.fromString("testcontainers/ryuk:0.14.0").string;
```

So those two lines were pre-pulling an image nothing starts, and the one
actually used was never pre-pulled. The other three workflows already
say 0.14.0.

**`postgres:17` added** to `unit-tests-webapp.yml`. The webapp suite
references `docker.io/postgres:17` across 10 files but only
`postgres:14` was pre-pulled. `unit-tests-internal.yml` already pulls
both.

**Electric pinned to its digest** in `unit-tests-webapp.yml`. The tests
run `electricsql/electric:1.2.4@sha256:20da...` while the pre-pull asked
for the bare tag, so the pre-pull did not necessarily populate the
manifest the tests then request.

## Not changed

The otel collector and s2 images are pulled by other workflows but are
not used by the webapp suite, so they are deliberately not added here.
`postgresAndRedisTest` uses per-test containers by design and needs
nothing pre-pulled.
## What

The one-off worker container boot is billed to whichever test resolves
the fixture first. This moves it into a `beforeAll` with its own
timeout.

## Why

vitest runs the fixture chain *inside* the test timer:

```js
// @vitest/runner 4.1.7
setFn(task, withTimeout(...withFixtures(handler)..., timeout, ...))
```

There is no `fixtureTimeout`. So booting Postgres (plus `CREATE
DATABASE`, schema push, ClickHouse and Redis) lands on the first test
and consumes a budget sized for test work.

That is why losing the image pre-pull on fork PRs was fatal rather than
merely slower: the extra ~10s crossed the 60s cap. Since fork time is
roughly internal + 10s and forks exceed 60s, internal runs were already
clearing that cap by under 10s — a latent flake regardless of forks.

## How

`withWarmup` wraps each fixture family and lazily registers a
`beforeAll` on first touch, with its own generous timeout. Registration
is lazy so only files that actually use a family pay for it —
`@internal/testcontainers` is imported by hundreds of test files, many
of which only need Redis. It registers once per file, since `isolate`
gives each file a fresh module registry.

Eight families are wrapped. `isolatedRedisTest`,
`replicationContainerTest` and `postgresAndRedisTest` are deliberately
untouched: they use per-test containers by design, so there is no
one-off boot to hoist.

No test file or CI changes, and it applies to every package using these
fixtures.

## Verification

Proven by mutation. `src/warmup.test.ts` runs container tests under a
deliberately tight cap:

| | Result |
| --- | --- |
| with the warm-up | passes |
| warm-up neutered | fails, `Test timed out` |

It is kept as a regression test — without it, unwrapping a fixture would
break nothing visibly.

`triggerFailedTask.call.test.ts`, one of the five shard casualties,
passes locally in 20.4s.

## Also here

`@internal/testcontainers` had no `test` script, so `turbo run test
--filter "@internal/*"` skipped the package and its existing
`heteroDedicated.test.ts` never ran in CI. Adding the script (matching
the sibling packages') runs both files; verified green through turbo
exactly as CI invokes it.
## Summary

Allow the logs search schema migration to run on ClickHouse versions
that require text index options to be literals.

## Root cause

The text index declared `lowerUTF8(search_text)` as a preprocessor
option. Some ClickHouse versions reject that column expression while
parsing index settings. The projected `search_text` is already
normalized to lowercase before insertion, so removing the redundant
preprocessor preserves search behavior.

Verified with the task events search integration tests.
… row (#4703)

## Summary

Listing schedules could block the event loop for seconds. A page of 100
timezone-aware schedules spent over two seconds on cron arithmetic
alone, after the database work was already done, which stalls every
other request on that process. The same page now resolves in tens of
milliseconds.

## Root cause and fix

`cron-parser` walks the calendar unit by unit, and under a named
timezone every step goes through luxon. Parsing an expression is cheap
(single-digit microseconds); *stepping* it is not, ranging from a couple
of hundred microseconds for a common expression to several milliseconds
for a sparse one like `0 0 29 2 *`. The presenter did three independent
walks per row, one backwards for "last run" and two forwards (re-parsing
each time) for the next run and the occurrence after it. At 100 rows
that is 300 calendar walks in one uninterrupted tick.

Run times now resolve for the whole page in one pass, in a new
`resolveScheduleTimings` that takes plain values rather than Prisma rows
so it can be tested and benchmarked on its own.

- **Nominal times are cached per `(cron, timezone)`** against a single
`now` pinned for the batch, so cost scales with the number of distinct
expressions instead of the number of rows. Rows in one response also
stop disagreeing about the current time.
- **The backwards walk is opt-in.** It is the most expensive of the
three and only the dashboard renders the column; the public API never
returned it at all.
- **Windowless schedules take one step instead of two.** The second step
only measures the interval to the following occurrence, and that
interval reaches the result solely through `min(intervalMs,
max(MINIMUM_SCHEDULE_RANGE_MS, windowMs))`. With no window `windowMs` is
0, and `CronPattern` rejects expressions with a seconds field, so
occurrences are always at least `MINIMUM_SCHEDULE_RANGE_MS` apart and
that `min` can never bind. It is also the costlier step, since it walks
a whole period rather than the remainder of the current one.
- **`nextScheduledTimestamps` steps one parsed expression** instead of
re-parsing per step, which also helps the single-schedule callers.

Behaviour is unchanged, error semantics included: a malformed expression
still throws for the next run and still degrades to an undefined last
run.

## Verification

Measured inside a real request against a live environment, 100
schedules: sparse expressions went from 2250-2652 ms to 23-30 ms, and
five distinct timezone expressions from 463-500 ms to 9.7-10.6 ms.

The new suite checks the optimized code against an inline copy of the
previous implementation across eleven cron and timezone combinations
plus five DST transitions, so the rewrite is verified as
behaviour-preserving rather than just faster. Separate tests pin the
invariant the single-step path depends on, so if sub-minute crons are
ever allowed they fail loudly instead of the timings quietly going
wrong.

Worth knowing for later: `cron-parser` v5 is a much faster rewrite on
exactly this workload (`prev()` under a timezone drops from roughly 2700
to 60 microseconds), but it is a breaking API change across several call
sites including the schedule engine, so it belongs on its own. The
differential test added here is the tool to de-risk it.
`@grpc/grpc-js` sat at 1.12.6 in the lockfile. `dockerode` is the only
consumer and already declares `^1.11.1`, so a scoped override is enough:

```json
"@grpc/grpc-js@>=1.12.0 <1.12.7": "1.12.7"
```

Pinned exactly to stay on the 1.12 line; a caret would pull 1.14.x.
## Summary

Enforce stable React hook ordering in the dashboard and React hooks
package.

Conditional hook calls now keep a consistent order, and overloaded
realtime stream arguments are resolved before entering the shared hook
implementation.

Base: `main`
## Summary

Keep component and renderer identities stable across dashboard renders.

Inline icon components, chart renderers, table cells, and select render
callbacks now use module-level implementations. Oxlint enforces the
pattern across the dashboard.

Base: [#4688](#4688)
## Summary

Remove redundant React fragments from the dashboard and enforce
`react/jsx-no-useless-fragment`.

The cleanup returns existing nodes, arrays, and empty states directly
without adding wrapper elements.

Base: [#4689](#4689)
## Summary

Add explicit types to native dashboard buttons and enforce
`react/button-has-type`.

This prevents action buttons from accidentally submitting a surrounding
form. Shared button primitives retain their caller-selected submit and
reset semantics with documented lint exceptions.

Base: [#4691](#4691)
## Summary

Enable foundational JSX accessibility checks for image text alternatives
and valid ARIA roles.

The avatar color picker now has an explicit accessible name and
decorative image alternative. Dashboard chat styling props no longer
reuse the reserved DOM `role` name.

Base: [#4692](#4692)
## Summary

Associate internal model administration labels with their form controls.

The model editor, creator, and tester now use explicit `htmlFor` and
`id` pairs. Section titles that do not label controls now use headings
instead of label elements.

Base: [#4693](#4693)
ericallam and others added 3 commits September 9, 2026 15:43
## Summary

When a chat.agent run boots to continue a session (a version handover,
or a retry after a crash), it replays the unacknowledged user messages
off `session.in` and dispatches them itself. A previous change stopped
the live tail from re-answering those same messages by folding them into
the resume cursor in one step. That cursor is what the next boot reads
to know where to resume, and folding in every recovered message at once
let it advance past a message the run had not answered yet. So if the
run answered the first recovered message, wrote its turn boundary, then
crashed before dispatching the rest, the next boot resumed past those
messages and they were never answered.

## Fix

A recovered message is now claimed on the session-stream router instead
of folded into the cursor. A claim does two independent things:

- it drops the message however late the live tail re-delivers it, so a
recovered message is never answered twice;
- it holds the resume cursor behind that message until the boot has
dispatched it, so a turn boundary never publishes a cursor past a
message still waiting for a turn.

The boot settles each claim as it dispatches the message, or right away
for a message it folds into the seed chain or deliberately skips, so the
cursor only advances over messages that have actually been handled. A
claimed record whose route re-read never arrives over the tail degrades
to being answered twice on the next boot, never to being dropped.

Covered by router-level unit tests for the claim/settle floor and a
chat.agent boot test asserting the cursor published after the first
recovered turn stays behind the still-unanswered ones.
## Summary

The bundled `trigger-chat-agent-advanced` skill still told agents to
answer from an action by returning a value, an API #4816 removed. Code
generated from it fails at runtime with the `chat.turn()` error.

Before, the skill said:

```ts
onAction: async ({ action, streamText }) => {
  if (action.type === "regenerate") {
    chat.history.slice(0, -1);
    return streamText({ model, messages });
  }
},
```

After:

```ts
onAction: async ({ action }) => {
  if (action.type === "undo") chat.history.slice(0, -2); // edit only
  if (action.type === "regenerate") {
    chat.history.slice(0, -1);
    return chat.turn(); // answer the edited history
  }
},
```

The section now covers edit-only actions, `chat.turn()` and the
`action-turn` trigger, persistence for both the platform-managed and
`hydrateMessages` models, and sending actions through `useChat`
(`body.action` or `useChatActions`) so the answer renders, with
`transport.sendAction` noted as the raw-stream path.

Docs-only change to an SDK-bundled skill; no changeset, since the
`chat.turn()` release note from #4816 already covers the behavior.

Raised by Devin on #4884 after merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericallam <534+ericallam@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
…mantics

No user-facing change. The new decorator remains uninstalled and cannot
be selected by production configuration in this PR.

Mono-RevId: 8637f7ed56d1fc641d7c7038bae2ac4f4c36a4f2
myftija and others added 26 commits September 10, 2026 09:17
Adds GET /api/v1/deployments/:id/artifact-url, which returns a
short-lived presigned URL for the deployment's artifact after verifying
the key belongs to the caller's environment and the object still exists.

Mono-RevId: a8cc8df3b0d9e6ea86247562cee63593879cac71
Document the authorization trust boundary for self-hosted installs.

Organization membership is the trust boundary on a self-hosted install:
everyone in an organization is someone you invited to your own
deployment. Separating what an Admin may do from what a Member may do
comes from an RBAC plugin that is not part of this distribution, so the
permission layer falls back to a permissive ability by design.

SECURITY.md now states this explicitly and scopes vulnerability reports
against it. Reaching data or actions belonging to an organization you
are not a member of, or bypassing authentication, is in scope on every
deployment. A member performing a privileged action inside their own
organization is out of scope for self-hosted.

The webapp contributor guide records the matching rule for code here:
enforce tenancy, and leave role separation to the plugin rather than
growing the fallback.

Mono-RevId: a4f245a2b053007a801e5ef15006148b13735a87
Mono-RevId: ce4fab99f3b829a0405b305382a84b94bbe26c97
…ency resolver

No user-facing change. This adds an optional internal route field while
the snapshot-store rollout remains inactive.

Mono-RevId: 3da62651b5918e6873a83e0d30c2a05ad24b6542
Span and log attributes are now serialized by the server before being
stored, so the attribute text shown in the dashboard is exactly what
your task sent.

Mono-RevId: b789b40bd993d5096f0e10ca57554ecb0bfd12e9
Reduces sensitive values retained in CLI and SDK diagnostics. Build
command logs redact build arguments, environment pulls secure
destination permissions before writing, Git metadata removes URL
credentials, and retry telemetry records only a small safe set of URL
and response details without changing requests or retry behavior.

Mono-RevId: e6854bf5379682e208a49fcfa7477d8b799dc849
Enforce the existing dashboard permissions on queue and environment
mutations, prompt detail reads, idempotency key resets, paid add-on
purchases, and organization changes. Controls you cannot use are
disabled with an explanation rather than failing on submit, and
allocation and quota-increase requests stay available.

Billing notices now render from a fixed set of known message keys
instead of caller-supplied copy, and the schedules add-on returns stable
error messages.

Permission checks scoped to a project now pass that project through, so
a project-level role override applies wherever one is configured.

Branch archival now respects the configured permissions for dashboard
sessions and personal access tokens, with disabled controls explaining
denied access. Existing API-key scope requirements are unchanged.

Mono-RevId: 5a7c999c836e2b7d9a72f17f3ca48fefe5d46e6d
GitHub App installation callbacks now verify that the authenticated
installing user can access the installation before linking it to an
account.

Mono-RevId: c59ade6435294a39335d1b85cc881a0afd1d1452
Mono-RevId: c223e3c56d33e50c024789514cf218f46eded002
Waitpoint registration now verifies that the target run belongs to the
authenticated environment.

Mono-RevId: 0998236ba421350cd59c5c90fa09e918df0074cb
…ead of building a pod

Adds an alternative Kubernetes workload manager behind a new
`KUBERNETES_RUN_CRD_ENABLED` setting, off by default. Instead of
building a run pod directly, it creates a `Runner` object in the
`compute.trigger.dev/v1alpha1` API group and stops, leaving pod
construction to the operator that reconciles that object. This keeps the
reasoning about uid pinning, node selection and the label set a network
policy selects on in one place instead of copying it a third time.

The Helm chart exposes it as
`supervisor.config.kubernetes.runCrdEnabled`, also off by default, which
sets that variable and grants the supervisor's Role the two writes the
backend needs: creating the `Runner`, and creating and patching the
Secret that carries the deployment token. Turning it on requires the
`Runner` CRD and a controller reconciling it to be installed, or the
create fails.

The existing pod-building path is unchanged and remains the default. The
two are mutually exclusive by construction: whichever one runs is the
only thing that creates a workload for a cold start.

Mono-RevId: 0665f61da14cbc8ec45c7fb75b013cf45f93fdc8
…pen on slow SET

Mono-RevId: 97abba33b1d2895851eca6d9225c05fb8a99e027
Run state notifications were broadcast to both the deployed worker and
dev worker socket.io namespaces for every run, regardless of
environment. The dev worker namespace only accepts connections from
development environments, so that second broadcast was discarded for
every non-development run.

Notifications now reach the dev worker namespace only for runs in a
development environment. This removes the redundant broadcast and
roughly halves the Redis pub/sub traffic generated by the run
notification path.

Mono-RevId: 84cb5aead09067daeb979d793e3974c2a1da57c8
# Chat history reads a page at a time, and the model's context stays
private

Reading a `chat.agent` conversation's history used to download and parse
the whole conversation, so
it got slower as a chat grew. A conversation's stored transcript now
carries an index, so reading
the most recent messages fetches only those messages.

The same change closes a narrower problem: the model-side context an
agent keeps, its compacted
history and any injected context, was reachable from a browser. It is
not part of a transcript and
is no longer served with one.

## Before

```mermaid
sequenceDiagram
    participant Browser
    participant Webapp
    participant Store as Object store
    Browser->>Webapp: open a chat
    Webapp-->>Browser: presigned URL for the whole snapshot
    Browser->>Store: GET the entire object
    Store-->>Browser: messages AND model context
    Note over Browser,Store: the whole conversation, including context a transcript never shows
    Webapp->>Store: GET the entire object (page read)
    Webapp->>Webapp: parse all of it, return 50 messages
```

Every read paid for the whole conversation: request-thread CPU for the
API, bytes over the wire for
the browser. Paging alone could not fix it, because the messages a page
needs sit at the end of one
JSON document, so finding them meant parsing all of it.

## After

```mermaid
sequenceDiagram
    participant Browser
    participant Webapp
    participant Store as Object store
    Browser->>Webapp: open a chat
    Webapp->>Store: ranged GET, end of the object
    Store-->>Webapp: index + newest entries
    Webapp-->>Browser: messages and a cursor
    Note over Webapp,Store: one ranged read, and the browser never touches the store
```

The stored transcript is a header line holding the cursors and the
agent's own state, one line per
message, an index, and a fixed-width trailer giving the index's length.
A reader takes the end of
the object and decodes only the bytes holding the page it was asked for.

## Safety contract

- The private state lives in the header, so a page read cannot return it
by layout, not by
  remembering to strip a field.
- A cursor the transcript no longer holds yields an empty page, never
the newest entries, which
  would present recent messages as older ones.
- Page timestamps come from a message's position in the whole
transcript, not in its page, so pages
  fetched newest-first still merge into conversation order.
- Trimming is expressed relative to the compaction watermark, because
everything after it is live
  context the next boot converts and must never be dropped.

## Scope and impact

A conversation saved by an earlier version still reads correctly: the
reader recognises the old
format and reads the whole object. Each conversation moves to the new
layout the next time it
saves, so there is no migration step. An older reader cannot read the
new format, so roll forward
rather than back.

The built-in storage is deliberately basic about long conversations:
once an agent has compacted it
keeps roughly the last hundred messages and drops the rest, so what it
rewrites each turn stops
growing. A conversation that never compacts is kept whole. An app that
renders history further back
than that keeps its own transcript storage.

## Rollout controls

No flag. The read change is the same data through a cheaper path, and
gating the exposure fix would
mean leaving it open by default. Revert is a deploy revert: stored
objects are untouched and the
state is still written and read on the private path. Presigned URLs
already issued stay valid for
their lifetime, so the exposure fix is not retroactive for the few
minutes before a deploy.

What a regression looks like: `chat.agent: snapshot version/shape
mismatch` at run boot, or
`transcript endpoint: ranged read failed` in the webapp logs. The first
would mean a conversation a
later run cannot read, which is the one worth paging on.

## Verification

Tests prove: paging over byte offsets, including multibyte content where
a character-indexed table
would slice mid-message; reading the previous format; a conversation
whose stored media type
disagrees with its contents; a cursor the transcript no longer holds;
each page's position in the
whole transcript; that no byte a page read fetches contains the private
state; that a trimmed
transcript still restores the model's context; and that a watermark
outside the retained window
does not discard the summary. One test asserts the failure that guard
prevents, so it cannot be
dropped quietly. Real runs additionally cover a cold continuation
booting from a trimmed snapshot
and snapshot size plateauing over 130 turns.

Mono-RevId: 7e57e5e9b859592827719a35e6a624c1cd7123f6
…og line

Mono-RevId: ab2161b49c12d48a7fb6102f0d6a4142c97931ff
Stops writing task event attributes to the native JSON column; the
serialized attributes text is now the stored form.

Mono-RevId: c1a17fb9ea4ced31daa63c6e4b00425c1cc62894
Request idempotency was doing nothing. The key a client sends is hashed
together with its environment and task scope before it is looked up, but
the idempotency cache still required the raw UUID format and rejected
the hashed value, so every trigger, batch trigger and create-batch
request skipped deduplication. A request the server had already
accepted, whose response the client never received, would therefore
create a second run or batch when the SDK retried it.

The cache now accepts the derived key, so a retried request returns the
original run or batch. The client-supplied
`x-trigger-request-idempotency-key` header is still validated as a v4
UUID, now at the point where it is read; a header of any other shape is
ignored as before, so the request itself still succeeds. Keys stay
scoped to a single environment, task set and request type.

Mono-RevId: 85b6e31f5bdb722d99db28f66a2d5b533a0f3ce4
… updates

Mono-RevId: 53978f5b05eb06b35f284e821daab76dc45eaa01
Mono-RevId: 749cbfed4729e03d99a710f4ce62acdc0beba8ca
Mono-RevId: c8312b2935b569d642257e27711fe21cf8648ae2
…pt storage (#4916)

## Summary

Adds `docs/ai-chat/migrating-from-hydrate-messages.mdx`, a step-by-step
guide for agents that persist history through the deprecated
`hydrateMessages` hook and want to move onto a `TranscriptStorage`. The
transcript storage page kept a three-line migration list; this is the
full walkthrough, written from doing exactly this migration on the
durable chat example.

Before, the transcript storage page said:

```md
1. Implement `TranscriptStorage` over your existing tables. Your `hydrateMessages` body becomes `loadContext`; the writes you did in hooks become `save`.
```

After, that list says the read becomes `load`, `loadContext` is only for
a database that decides the model's context, and points at the guide.
Porting a `hydrateMessages` body into `loadContext` wholesale carries
the recovery and compaction code the runtime now owns, which is the
mistake the guide is there to prevent.

The guide covers:

- a table of what changes hands between the hook and the storage
- `load` and `save` over a row-per-message table, with the ordering
rules a row store has to follow (position assigned on first insert only,
never a write timestamp)
- when `loadContext` is needed, and when it is not
- what to delete from each hook: message and cursor writes, partial rows
and run-state flags, tool-call repair, the summary watermark,
empty-response filters
- the frontend history read with `createLoadTranscriptAction` and
`useLoadTranscript`
- the conformance suite, and a checklist

Registered in `docs.json` after the transcript storage page. Docs only;
no changeset.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
…age (#4917)

Adds an explicit ordering rule to the "Writing your own storage" list in
the transcript storage guide.

The ordering contract was implicit: the page said `put` for a known id
keeps its position and that `load` returns the whole conversation in
order, but never told an adapter author how to preserve order in a
row-per-message store, and never warned against a write-timestamp
column. That gap is easy to trip over (reaching for a `seq`/timestamp
column) because the contract deliberately has no sequence number, only
an order.

The new bullet states it outright: order is a transcript position, not a
write time; a document store gets it from `transcript.entries`; a row
store needs an order column set once when a `put` first inserts an id,
in `put`-arrival order, and left unchanged on an in-place replace; don't
sort by a write timestamp, because a replaced message must keep its
place and a mid-turn steering message sorts before the answer it shaped.

Docs-only. Verified against the runtime's `applyChanges`/`buildChanges`
in `transcriptStorage.ts`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…k extend

Run replication now recovers on its own after a Redis restart or outage.
The leader could previously lose its lock without noticing and log
"Cannot extend an already-expired lock" every few seconds while holding
the replication slot open, until the server was restarted. It now
re-acquires the lock, or steps down once and re-elects.

Deployments running under a process supervisor can set
`RUN_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS` (or the session equivalent)
to exit and be restarted when a replication stream cannot recover. It
defaults to 0, which keeps retrying and preserves existing behavior.

Mono-RevId: 465d1f9bd0bb720320c8dd294dde3c3a9b88f1d9
Reloading a chat while the agent is still answering now shows the
message being answered. The incoming message was previously persisted
only once the turn finished, so a refresh mid-answer rendered the reply
with no question above it.

The runtime now writes the message at the start of the turn, carrying
the previous turn's stream cursors so a mid-answer reload still resumes
from the last completed turn. That write is not awaited before the model
runs. The output stream is held on it instead, so no part of the answer
reaches the frontend before the message is durable, and time to first
token is unchanged.

The same ordering is available to your own writes as
`chat.deferBeforeOutput()`:

```ts
onTurnStart: async ({ chatId, uiMessages }) => {
  chat.deferBeforeOutput(
    db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
  );
},
```

It runs alongside the model like `chat.defer()`, but the answer waits
for it, so the next page load always sees the write. It orders the write
against what the frontend can see and not against the model, so a write
that a tool reads back during the same turn still needs to be awaited.

Mono-RevId: a84c08af51b376f5a49091d001ed1ec59149881f
… reads secret-key only

Session public tokens can now be narrowed to one stream with
`read:sessions:{id}:out`, and reading a session's `.in` channel now
requires a secret key. The dashboard agent's browser token uses the
narrowed scope.

Mono-RevId: 1214e6dd60256c7f1323e891580c16d500c8fd9a
## Docs: intro rework, agent anatomy rewrite, and more AI agent examples

- **Introduction.** Reworked the docs landing page to lead with AI
agents and workflows: a cleaner hero, a core concepts section, and short
sections for building agents, scaling and scheduling, and self hosting.
Corrected the licensing wording and refreshed the card styling.
- **Anatomy of an agent.** Rewrote the page so it teaches the three
parts of a chat agent (the agent task, the durable session, and the
frontend transport) and traces a single message through them, instead of
only linking out to other pages.
- **AI agent examples.** Added more example projects to the AI agents
overview: an ElevenLabs voice agent, the ask Trigger chat agent, a batch
LLM evaluator, and a Claude thinking chatbot.
Mono-RevId: fb6db42f103bdc6d35af62163ee33427fb67211d
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.