Skip to content

feat(webapp): query boundary pinned end-to-end and a capped query retry - #4549

Merged
kathiekiwi merged 30 commits into
fix/watch-mode-keepalive-tri-13065from
feat/query-safety-tri-11165
Aug 12, 2026
Merged

feat(webapp): query boundary pinned end-to-end and a capped query retry#4549
kathiekiwi merged 30 commits into
fix/watch-mode-keepalive-tri-13065from
feat/query-safety-tri-11165

Conversation

@kathiekiwi

@kathiekiwi kathiekiwi commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

What & why

The agent's query tool is read-only, but that was true by three separate facts and only one of them had a test. This proves the boundary holds by contract rather than by prompt, and stops a broken query from burning a whole agent turn.

Two small guards, the rest is tests. TRI-11165.

Stack

Stacked on #4548 (watch-mode keepalive). Merge that first.

What's inside

  • A route-level read-only test (apps/webapp/test/queryRouteReadOnly.test.ts) that drives api.v1.query with a real signed environment JWT: a multi-statement write and a mutating statement are both refused before anything reaches ClickHouse, and a plain read passes so the seam stays live.
  • readonly=1 made non-overridable in queryService.server.ts — caller clickhouseSettings were spread after the defaults and could clear it.
  • A per-turn query-retry cap in the agent's run_query tool (internal-packages/dashboard-agent/src/tool-api.ts): three consecutive failures returns a terminal "stop and answer with what you have".

Key decisions

  • The read-only guarantee is grammar-level, not filtered. TRQL has no write statements — they don't parse — ClickHouse runs with readonly=1, and the org/project/env scoping is injected server-side from the credential. The request body can't widen scope or turn a read into a write.
  • The deny test runs through the route, not the parser. A parser-only test would stay green if a refactor routed agent SQL around the compiler; driving the real route with a signed JWT pins the boundary end-to-end, and the deliberate positive read keeps the assertion honest.
  • The retry cap lives per turn, not in the prompt. A failed query hands the model the database error to fix, and usually it does — but the only other limit was the turn's 10 steps, so one query the model couldn't fix could eat the whole turn and leave the user with no answer. The tool set is built per turn, so the counter caps consecutive failures; a success resets it. The retry instruction rides the error text, so the prompt prefix is unchanged.

Testing

  • queryRouteReadOnly.test.ts — write statements → 400, ClickHouse never called; a read passes.
  • tool-query-retry-cap.test.ts — terminal at the third consecutive failure, counter resets on a success.
  • The load-bearing guards were control-broken first (readonly override re-enabled; cap removed) and the tests went red.

The read-only guard was enforced by the TRQL grammar and a parser test, but nothing
proved the route itself refuses a write; a route test now drives api.v1.query with a
signed environment JWT and asserts nothing reaches ClickHouse. readonly=1 is no longer
overridable by a caller's clickhouseSettings. run_query gives up after three consecutive
failures so a broken query can't burn a whole agent turn.

TRI-11165
@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9f67130

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c54ca713-ea17-4930-a9d0-30c6976b06cd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The webapp now rejects mutating query statements and enforces readonly: "1" after merging caller settings. Integration tests cover authenticated reads, rejected writes, and setting overrides. The dashboard agent now tracks consecutive run_query failures per turn, stops after three failures, and resets the counter after a successful query. Tests cover the failure cap and reset behavior.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the two main changes: enforcing the query boundary and capping consecutive query retries.
Description check ✅ Passed The description explains the motivation, implementation, key decisions, testing, and linked issue, with only non-critical template sections omitted.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/query-safety-tri-11165

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@14ea61c

trigger.dev

npm i https://pkg.pr.new/trigger.dev@14ea61c

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@14ea61c

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@14ea61c

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@14ea61c

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@14ea61c

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@14ea61c

@trigger.dev/schema-to-json

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

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@14ea61c

commit: 14ea61c

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

kathiekiwi and others added 3 commits August 12, 2026 07:38
## What & why

Test-only hardening for chat.agent durability. chat.agent gets its
durability from the primitive — object-store snapshot + S2 `.in`/`.out`
replay + continuation boot — but several store-level mechanisms that
replay lands on had no regression test, and two of them were criticals
in the 2026-06-10 chat.agent audit: **cross-tenant isolation** and **no
duplicate mid-stream turn**. This adds those cases against a real
Postgres table (testcontainers, no mocks).
[TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166).

## Stack

Stacked on **#4549** (query boundary). Merge that first.

## What's inside

| # | Mechanism | Test | Coverage | Control-broken |
|---|-----------|------|----------|----------------|
| 1 | **Cross-tenant isolation** (audit critical) |
`dashboardAgentTenantIsolation.test.ts` | Full at the store seam we own:
getChatMessages / getSession / chatExists / listChats /
countUserMessages / appendChatMessageOnce all refuse a foreign (org,
user) — a foreign tenant reads not-found, never a transcript or the
session's public access token | Yes |
| 2 | **No duplicate mid-stream turn** (audit critical) |
`dashboardAgentDurableResume.test.ts` | Full: a streamed-then-resumed
turn finalises in place and appends nothing; row count and the position
allocator both pinned | Yes |
| 3 | **Crash-resume reconstructs state** |
`dashboardAgentDurableResume.test.ts` | Full: replay keeps the mid-turn
append, finalises the turn's own message, loses no messages, and
rebuilds the session cursor read back via getSession | — |
| 4 | **Mid-stream refresh resumes in-flight turn (Last-Event-ID)** |
`dashboardAgentDurableResume.test.ts` | Seam-only: the cursor getSession
hands a refreshed client, and a later turn advances (never appends) it.
Client-side reconnect / Last-Event-ID replay is already covered in
`packages/trigger-sdk/src/v3/chat.test.ts` — not duplicated | — |
| 5 | **Snapshot write-failure path** |
`dashboardAgentDurableResume.test.ts` | Full at this seam: a persistTurn
that throws commits nothing (no rows, allocator untouched, cursor
unchanged — the version-mismatch case), and the retry replays with no
loss | — |
| 6 | **`.out` trimming / OOM retry restarts cleanly** |
`dashboardAgentDurableResume.test.ts` | Seam-only: a restarted turn that
re-sends its snapshot loses no data and doubles nothing. `.out` trimming
and the OOM restart itself are inside the closed primitive (not
reachable) | — |

The "Full" vs "Seam-only" column is the honest distinction: full means
the whole mechanism is exercised from the repos we own; seam-only means
we pin the store contract the primitive depends on, and the
primitive-internal half lives where we can't reach it.

## Key decisions

- **The two criticals were control-broken first.** Isolation: removing
the `organizationId` filter from `getChatMessages` leaks org A's
transcript to the owner's user id under another org — the test fails at
`toBeNull()`. No-duplicate: removing the stored-id drop in
`storeChatMessages` makes a replayed persistTurn over-reserve positions
(next free slot jumps 3 → 7) — the test fails on the allocator
assertion. Both reverted.
- **Real Postgres, no mocks.** testcontainers against an actual table,
so the store contract is proven, not asserted against a stub.

## Residual follow-ups

These live inside the closed chat.agent primitive package and can't be
unit-tested from the repos we own; the tests above are the store-level
backstop they depend on:

- The snapshot URL's own auth gate (the audit's snapshot-URL auth gap) —
enforced in the primitive; here we prove the webapp store never hands a
foreign tenant the PAT it would boot from.
- Object-store snapshot write + S2 `.in`/`.out` replay at the transport
level.
- `.out` trimming never dropping in-flight data, and the OOM restart
mechanism itself.

## Testing

`pnpm run test --filter webapp` —
`dashboardAgentTenantIsolation.test.ts` and
`dashboardAgentDurableResume.test.ts`.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +1 to +33
import {
appendChatMessageOnceByChatId,
createChat,
createDashboardAgentDb,
getChatMessages,
getSession,
persistMessages,
persistTurn,
type DashboardAgentDb,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect } from "vitest";

/**
* Durability of a chat.agent turn across a crash and a resume, against a real table
* (TRI-11166).
*
* The primitive gives chat.agent durability by snapshotting the transcript and replaying it
* on the next boot. These tests pin the store seam that replay lands on: the completing turn
* re-sends its whole snapshot, so the store has to fold that replay into exactly one row per
* message — no double-appended turn, no lost mid-turn message — and reconstruct the session
* cursor a refreshed client resumes from.
*
* What is NOT covered here, because it lives inside the closed chat.agent primitive package
* (object-store snapshot write, S2 `.in`/`.out` replay, `.out` trimming, OOM restart): the
* transport-level replay and the snapshot URL's own auth. The client-side reconnect / Last-
* Event-ID replay is covered in packages/trigger-sdk/src/v3/chat.test.ts. These tests are the
* store-level backstop those depend on. See the PR body for the residual follow-ups.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pull request bundles unrelated chat-store tests with the query change

Two large test files covering chat storage durability and tenant isolation (apps/webapp/test/dashboardAgentDurableResume.test.ts:1) are added alongside the query read-only/retry work, so the change covers more than one issue, which the contribution rules disallow.
Impact: Reviewers must evaluate unrelated work in one pass, making the change slower and riskier to accept.

Rule text and scope mismatch

CONTRIBUTING.md: "We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features."

The PR body and .server-changes/query-boundary-and-retry-cap.md describe only the read-only query boundary (TRI-11165) and the run_query retry cap. The two added files instead exercise @internal/dashboard-agent-db (persistTurn, getSession, getChatMessages, …) and are annotated with a different issue, TRI-11166 (apps/webapp/test/dashboardAgentTenantIsolation.test.ts:21). They also test code that lives in internal-packages/dashboard-agent-db, while AGENTS.md asks for test files to sit next to the source they cover.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +219 to +227
// 429 is the concurrency rejection and the rate limiter: nothing is wrong with the
// query, so it is not a query error.
if (res.status === 429) {
return {
ok: false,
kind: "busy",
error: `${data.error ?? "The query service is busy right now."} You can retry the same query shortly.`,
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 429 from the shared rate limiter is folded into the same "busy" bucket

The client treats any 429 as busy. Besides the concurrency rejection, the route can also be rate limited by the shared API rate limiter, whose error body shape differs from { error }; in that case data.error is undefined and the model just gets the generic "The query service is busy right now. You can retry the same query shortly." Since busy never counts toward the retry cap, a persistently rate-limited environment lets the agent keep retrying queries until the turn's step budget is exhausted — the exact failure mode the cap was added to prevent. Worth confirming the rate-limit path is bounded elsewhere.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

## What & why

The Free-plan agent-message allowance was only a client-side hint
(`useIsFreePlan()` was hardcoded to `undefined`, so nothing was
enforced) and the running count was a live `COUNT(*)` over
`chat_messages` — which meant deleting a chat silently freed quota. This
PR makes the allowance a real server-side limit with a durable counter,
so the cap holds regardless of the client and a deleted chat can't
reclaim messages within the period.

The cloud side that fills the actual per-plan number is a separate PR
(TRI-12863 P0). Until it deploys the `agentMessages` limit key is
absent, which resolves to the repo's unlimited sentinel — the correct
fail-open default, and why this ships independently.

## What's inside

- **New counter table** `trigger_dashboard_agent.agent_message_usage`,
keyed `(organization_id, period)` where `period` is a UTC calendar month
`"YYYY-MM"`. FK-free (`pgSchema` convention), plus a drizzle migration.
Deliberately **not** joined to chats — that closes the delete-a-chat
hole. Distinct from TRI-13068's `AiUsageEvent`; neither is derived from
the other.
- **New service** `dashboardAgentQuota.server.ts`: a pure
`checkAgentMessageQuota({ used, limit })` (so the MCP send path can
reuse the rule later) plus an org-scoped resolver that reads the period
counter and the cached plan limit, and a `recordAgentMessageSent`
increment.
- **Counting on send**: the create path (head start) and the `.in`
append path each increment one user message. The append path counts only
after the existing `trigger === "action"` 403, so **wakes never count**.
- **Enforcement**: at/over the limit both send routes return `403 {
error: "message_quota_reached", limit }`, which the client renders as
`AgentUpgradeBlock` rather than a generic failure. Never a silent drop.
- **Client**: `?quota=1` now reads the period counter; `useIsFreePlan()`
is a real read gated on **billing presence** (no subscription →
self-hosted → no cap, no upgrade UI), not on the plan value.

## Key decisions

- **Fails open.** The cap is a nudge, not a security boundary. An absent
limit (self-hosted, or pre-P0) resolves to the unlimited sentinel
`100_000_000` (never `Infinity` — that serializes to `null` in the Redis
limit cache), and a counter read that throws returns "no cap".
Self-hosted needs zero extra branching — it falls out of the fallback,
with a test to prove it.
- **Billing-presence gate.** The upgrade UI keys off whether a
subscription exists, so a self-hosted install with no billing shows no
cap and no upsell label.
- **The `(org, period)` table closes the delete hole.** A standalone
counter, not a count over chat rows, so deleting a chat can't free quota
inside the period.

## Testing

`apps/webapp/test/dashboardAgentQuota.test.ts` (testcontainers, no
mocks):
- pure `checkAgentMessageQuota` under/at/over/unlimited (control-breaks
the `>=`);
- `agentTurnCountsAgainstQuota` counts a message but not a wake
(control-breaks the wake exclusion);
- the counter accumulates and a **deleted chat can't free quota** within
the period; other periods/orgs start fresh;
- the resolver reports reached over the limit and never reached when
unlimited;
- fails open when the limit is absent (self-hosted) and when the counter
read throws.

`TRI-12863`
@kathiekiwi
kathiekiwi merged commit e2948f1 into fix/watch-mode-keepalive-tri-13065 Aug 12, 2026
9 of 11 checks passed
@kathiekiwi
kathiekiwi deleted the feat/query-safety-tri-11165 branch August 12, 2026 08:27

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review

Comment on lines +44 to +54
const readLimit =
params.readLimit ??
(async (organizationId: string) => {
const cached = await getCachedLimit(
organizationId,
AGENT_MESSAGE_LIMIT_KEY,
UNLIMITED_AGENT_MESSAGES
);
// A cache error leaves `val` empty; fall open to unlimited.
return cached.val ?? UNLIMITED_AGENT_MESSAGES;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A plan that allows zero agent messages is treated as unlimited

The monthly message allowance is looked up in a way that turns a plan value of zero into "no limit" (getCachedLimit at apps/webapp/app/services/dashboardAgentQuota.server.ts:47), so an organisation whose plan grants no agent messages can send them without restriction.

Impact: Organisations on a plan with the agent switched off would still be able to chat with the dashboard agent for free.

Mechanism: `getLimit`'s falsy fallback versus the zero-aware reader added for watches

getCachedLimit delegates to getLimit, which does if (!result) return fallback (apps/webapp/app/services/platform.v3.server.ts:427). A plan value of 0 is falsy, so the fallback UNLIMITED_AGENT_MESSAGES (100_000_000) is returned and checkAgentMessageQuota never reports reached.

The same PR introduces getCachedLimitAllowingZero / limitValueAllowingZero (apps/webapp/app/services/platform.v3.server.ts:489-514) precisely for this case and uses it for the watch limits (apps/webapp/app/services/dashboardAgentWatchLimits.server.ts:24, with the comment "A plan of 0 means zero, not absent"). The message-quota resolver was not migrated to it, so the two limit families disagree on what 0 means.

Suggested change
const readLimit =
params.readLimit ??
(async (organizationId: string) => {
const cached = await getCachedLimit(
organizationId,
AGENT_MESSAGE_LIMIT_KEY,
UNLIMITED_AGENT_MESSAGES
);
// A cache error leaves `val` empty; fall open to unlimited.
return cached.val ?? UNLIMITED_AGENT_MESSAGES;
});
const readLimit =
params.readLimit ??
(async (organizationId: string) => {
const cached = await getCachedLimitAllowingZero(
organizationId,
AGENT_MESSAGE_LIMIT_KEY,
UNLIMITED_AGENT_MESSAGES
);
// A cache error leaves `val` empty; fall open to unlimited.
return cached.val ?? UNLIMITED_AGENT_MESSAGES;
});
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 53 to +54

return resolveMessageQuota({
isFreePlan,
used: usedElsewhere === undefined ? undefined : usedElsewhere + countUserMessages(messages),
});
return resolveMessageQuota({ isFreePlan, used });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 The client caps at a hardcoded 20 while the server enforces the plan limit

useAgentMessageQuota now reads the org-wide server counter but resolves it against the client constant FREE_PLAN_MESSAGE_LIMIT = 20 (apps/webapp/app/components/dashboard-agent/message-quota.ts:5), whereas the server compares the same counter against the plan's agentMessages limit (apps/webapp/app/services/dashboardAgentQuota.server.ts:63). Today the plan key is absent and resolves to the unlimited sentinel, so on cloud free plans the composer will be hidden at 20 messages even though the server would happily accept more; conversely if the cloud limit is ever set below 20 the client will keep offering the composer until the server 403s. The ?quota=1 loader already knows the effective limit — returning it alongside used would keep the two in step.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +85 to +89
// A concurrency rejection is "too busy", not a bad query: 429 so callers retry it
// instead of rewriting a query that was fine.
if (isQueryConcurrencyRejection(queryResult.error)) {
return json({ error: queryResult.error.message }, { status: 429 });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 429 is a new response status for an existing public endpoint

Concurrency rejections previously surfaced as 400 (they are QueryErrors) and now return 429. The OpenAPI spec is updated, but any existing SDK/client that branches on status === 400 for "query rejected" will now see an unhandled status; the agent client is updated (internal-packages/dashboard-agent/src/tool-api-client.ts:221-227) but packages/trigger-sdk's query.execute was not audited in this diff. Worth confirming the SDK surfaces the 429 with a sensible error rather than a generic failure.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

kathiekiwi added a commit that referenced this pull request Aug 12, 2026
… — and fixes (#4516)

Plan enforcement for the dashboard agent — message quota and watch
limits — plus the component gallery, fixes and test hardening from the
same stack (#4548, #4549, #4550, #4552, #4556 merged here).

## Plan enforcement
([TRI-12863](https://linear.app/triggerdotdev/issue/TRI-12863))

**Agent message quota.** The Free-plan allowance becomes a real
server-side limit with a durable counter. New `agent_message_usage`
table keyed `(organization_id, period)` — deliberately not joined to
chats, so deleting a chat can't free quota within the period. Both send
paths count one user message (wakes never count) and refuse at the cap
with `403 message_quota_reached`, which the client renders as an upgrade
panel, never a silent drop. The refusal code is a single shared constant
on both sides.

**Watch limits.** A watch whose window exceeds the plan's
`agentWatchMaxHours`, or that would push the org past its
`agentWatchers` count, is refused with `watch_limit_reached` (409 on the
API, an upgrade hint on the card). Plan limits only tighten the existing
code ceilings (`min(plan, 24h)`, per-chat cap of 3 still applies). A
plan limit of zero means zero, not unlimited. Questions answerable
instantly are answered before any plan refusal — a one-shot consumes no
slot and never sees an upgrade nag.

**Fails open by design.** Cloud ships the actual per-plan numbers
separately (TRI-12863 P0). Until then absent limits resolve to the
unlimited sentinel and the upgrade UI is gated on billing presence —
self-hosted sees no cap, no upsell, with tests proving the fallback.
Both quotas are nudges, not security boundaries: a failing limit read
never blocks a send.

## Component gallery

An admin-only gallery of every agent card state: five
`storybook.agent-*` pages (chat UI, view blocks, report, investigation,
watch) with their shared shell and manifest, demo fixtures, two
demo-only cards, toast examples, and the screenshot script. No LLM and
no data — every state renders from fixtures under
`dashboard-agent/demo/`, never reachable from a production path.
Designers and reviewers can look at every state, including the report
states, without seeding anything.

## And fixes

**SDK: watch-mode chat subscriptions survive quiet windows** (TRI-13065,
TRI-13070) — watch mode keeps reconnecting across empty long-poll
windows and only stops on abort or a settled session; a passive
subscriber can no longer stop a turn it doesn't own (`stopOnAbort` is
explicit, default off). Review findings fixed alongside: a superseded
stream's async teardown no longer removes the live successor's abort
controller or multi-tab claim, and stopping a generation hands the chat
back to the user's other tabs.

**Query boundary pinned end-to-end**
([TRI-11165](https://linear.app/triggerdotdev/issue/TRI-11165)) — a
route-level test drives `api.v1.query` with a real signed environment
JWT (writes refused before ClickHouse, a read passes); `readonly=1` made
non-overridable; a per-turn cap stops the model burning a turn rewriting
a query it can't fix (deterministic SQL errors only — busy/transport
rejections don't count).

**chat.agent durability regression suite**
([TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166)) —
testcontainers-backed coverage of the two audit criticals (cross-tenant
isolation, no duplicate mid-stream turn, both control-broken) plus
crash-resume, cursor-based refresh, clean rollback of a mid-write turn
failure (torn by a real constraint violation), and OOM-restart replay.

**Investigation sweep backoff** — stale investigations get an attempt
counter and backoff so a poison row can't pin the sweep queue head
(migration `0005`: `sweep_attempts`, `last_sweep_attempt_at`).

## Screenshots

<img width="1440" height="791" alt="Screenshot 2026-08-06 at 00 36 19"
src="https://github.com/user-attachments/assets/6a68cd42-8580-469d-afe7-e28d1eef18e1"
/>

🤖 Generated with [Claude Code](https://claude.com/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