480bede0ad
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)
182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
import { generateJWT } from "@trigger.dev/core/v3/jwt";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
/**
|
|
* The query API is read-only, and the grammar is what enforces it. A parser test alone would
|
|
* stay green if the route ever compiled agent SQL somewhere else, so these drive the real route
|
|
* with a real signed environment JWT and stub only the ClickHouse client. A write must be
|
|
* refused before anything reaches ClickHouse.
|
|
*/
|
|
|
|
const ENVIRONMENT_ID = "env_1234";
|
|
const API_KEY = "tr_dev_abcdefghijklmnop";
|
|
|
|
const environment = {
|
|
id: ENVIRONMENT_ID,
|
|
type: "DEVELOPMENT",
|
|
slug: "dev",
|
|
branchName: null,
|
|
apiKey: API_KEY,
|
|
organizationId: "org_1",
|
|
projectId: "proj_1",
|
|
archivedAt: null,
|
|
concurrencyLimitBurstFactor: { toNumber: () => 1 },
|
|
maximumConcurrencyLimit: 10,
|
|
project: { id: "proj_1", externalRef: "proj_ref", deletedAt: null },
|
|
organization: { id: "org_1" },
|
|
orgMember: null,
|
|
parentEnvironment: null,
|
|
};
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
runtimeEnvironmentFindFirst: vi.fn(),
|
|
queryWithStats: vi.fn(),
|
|
customerQueryCreate: vi.fn(),
|
|
concurrencyAcquire: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/db.server", () => {
|
|
const client = {
|
|
runtimeEnvironment: {
|
|
findFirst: mocks.runtimeEnvironmentFindFirst,
|
|
findMany: async () => [],
|
|
},
|
|
revokedApiKey: { findMany: async () => [], findFirst: async () => null },
|
|
project: { findMany: async () => [] },
|
|
customerQuery: { findFirst: async () => null, create: mocks.customerQueryCreate },
|
|
};
|
|
return { prisma: client, $replica: client };
|
|
});
|
|
vi.mock("~/env.server", () => ({
|
|
env: {
|
|
SESSION_SECRET: "test-session-secret",
|
|
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: "30",
|
|
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: 1000000,
|
|
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: 50000,
|
|
QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS: 500000,
|
|
QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY: 1000000,
|
|
QUERY_CLICKHOUSE_MAX_RETURNED_ROWS: 1000,
|
|
},
|
|
}));
|
|
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
|
|
clickhouseFactory: {
|
|
getClickhouseForOrganization: async () => ({
|
|
reader: { queryWithStats: mocks.queryWithStats },
|
|
}),
|
|
},
|
|
}));
|
|
vi.mock("~/services/platform.v3.server", () => ({ getLimit: async () => 30 }));
|
|
vi.mock("~/services/queryConcurrencyLimiter.server", () => ({
|
|
queryConcurrencyLimiter: {
|
|
acquire: mocks.concurrencyAcquire,
|
|
release: async () => {},
|
|
},
|
|
DEFAULT_ORG_CONCURRENCY_LIMIT: 10,
|
|
GLOBAL_CONCURRENCY_LIMIT: 100,
|
|
}));
|
|
vi.mock("~/services/logger.server", () => ({
|
|
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
|
}));
|
|
vi.mock("~/v3/services/worker/workerGroupTokenService.server", () => ({
|
|
WorkerGroupTokenService: class {},
|
|
}));
|
|
vi.mock("~/v3/services/common.server", () => ({ ServiceValidationError: class extends Error {} }));
|
|
vi.mock("@internal/run-engine", () => ({ EngineServiceValidationError: class extends Error {} }));
|
|
|
|
import { action } from "~/routes/api.v1.query";
|
|
import { executeQuery } from "~/services/queryService.server";
|
|
|
|
/** The claims the env-JWT exchange mints (api.v1.projects.$projectRef.$env.jwt.ts). */
|
|
function mintEnvJwt(scopes: string[]) {
|
|
return generateJWT({
|
|
secretKey: API_KEY,
|
|
payload: {
|
|
sub: ENVIRONMENT_ID,
|
|
pub: true,
|
|
scopes,
|
|
act: { sub: "usr_1", client: "dashboard-agent" },
|
|
},
|
|
expirationTime: "1h",
|
|
});
|
|
}
|
|
|
|
async function runQuery(query: string): Promise<{ status: number; body: any }> {
|
|
const jwt = await mintEnvJwt(["read:query"]);
|
|
const response = await action({
|
|
request: new Request("https://api.trigger.dev/api/v1/query", {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ query }),
|
|
}),
|
|
params: {},
|
|
context: {},
|
|
} as any);
|
|
return { status: response.status, body: await response.json() };
|
|
}
|
|
|
|
describe("the query API route", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.runtimeEnvironmentFindFirst.mockResolvedValue(environment);
|
|
mocks.customerQueryCreate.mockResolvedValue({ id: "cq_1" });
|
|
mocks.concurrencyAcquire.mockResolvedValue({ success: true });
|
|
mocks.queryWithStats.mockReturnValue(async () => [null, { rows: [], stats: {} }]);
|
|
});
|
|
|
|
// Pins the seam the two refusals assert against: a read really does reach ClickHouse here,
|
|
// so `not.toHaveBeenCalled()` below means refused, not unreachable.
|
|
it("runs a read against ClickHouse", async () => {
|
|
const result = await runQuery("SELECT count() FROM runs");
|
|
|
|
expect(result.status).toBe(200);
|
|
expect(mocks.queryWithStats).toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a write smuggled in as a second statement", async () => {
|
|
const result = await runQuery("SELECT 1 FROM runs; DROP TABLE runs");
|
|
|
|
expect(result.status).toBe(400);
|
|
expect(mocks.queryWithStats).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a mutating statement", async () => {
|
|
const result = await runQuery("INSERT INTO runs (task_identifier) VALUES ('x')");
|
|
|
|
expect(result.status).toBe(400);
|
|
expect(mocks.queryWithStats).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// A busy service is not a bad query: 400 would tell a caller to rewrite a query that was fine.
|
|
it("answers a concurrency rejection with 429", async () => {
|
|
mocks.concurrencyAcquire.mockResolvedValue({ success: false, reason: "key_limit" });
|
|
|
|
const result = await runQuery("SELECT count() FROM runs");
|
|
|
|
expect(result.status).toBe(429);
|
|
expect(result.body.error).toContain("try again later");
|
|
});
|
|
});
|
|
|
|
describe("the query service", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.concurrencyAcquire.mockResolvedValue({ success: true });
|
|
mocks.queryWithStats.mockReturnValue(async () => [null, { rows: [], stats: {} }]);
|
|
});
|
|
|
|
it("keeps ClickHouse read-only when a caller overrides the settings", async () => {
|
|
await executeQuery({
|
|
name: "test-query",
|
|
query: "SELECT count() FROM runs",
|
|
scope: "environment",
|
|
organizationId: "org_1",
|
|
projectId: "proj_1",
|
|
environmentId: ENVIRONMENT_ID,
|
|
clickhouseSettings: { readonly: "0" },
|
|
} as any);
|
|
|
|
expect(mocks.queryWithStats).toHaveBeenCalled();
|
|
expect(mocks.queryWithStats.mock.calls[0][0].settings.readonly).toBe("1");
|
|
});
|
|
});
|