Skip to content

Commit 0edbb22

Browse files
committed
feat(webapp): pin the query boundary end-to-end and cap the query retry
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
1 parent 27b04fb commit 0edbb22

5 files changed

Lines changed: 267 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Queries stay read-only, and the agent now stops after a few failed queries in a row and answers with what it found instead of spending the whole reply retrying.

apps/webapp/app/services/queryService.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,7 @@ export async function executeQuery<TOut extends z.ZodSchema>(
397397
...getDefaultClickhouseSettings(),
398398
...queryCacheSettings,
399399
...baseOptions.clickhouseSettings, // Allow caller overrides if needed
400+
readonly: "1", // Not overridable: every query through here is read-only.
400401
},
401402
querySettings: {
402403
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { generateJWT } from "@trigger.dev/core/v3/jwt";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
/**
5+
* The query API is read-only, and the grammar is what enforces it. A parser test alone would
6+
* stay green if the route ever compiled agent SQL somewhere else, so these drive the real route
7+
* with a real signed environment JWT and stub only the ClickHouse client. A write must be
8+
* refused before anything reaches ClickHouse.
9+
*/
10+
11+
const ENVIRONMENT_ID = "env_1234";
12+
const API_KEY = "tr_dev_abcdefghijklmnop";
13+
14+
const environment = {
15+
id: ENVIRONMENT_ID,
16+
type: "DEVELOPMENT",
17+
slug: "dev",
18+
branchName: null,
19+
apiKey: API_KEY,
20+
organizationId: "org_1",
21+
projectId: "proj_1",
22+
archivedAt: null,
23+
concurrencyLimitBurstFactor: { toNumber: () => 1 },
24+
maximumConcurrencyLimit: 10,
25+
project: { id: "proj_1", externalRef: "proj_ref", deletedAt: null },
26+
organization: { id: "org_1" },
27+
orgMember: null,
28+
parentEnvironment: null,
29+
};
30+
31+
const mocks = vi.hoisted(() => ({
32+
runtimeEnvironmentFindFirst: vi.fn(),
33+
queryWithStats: vi.fn(),
34+
customerQueryCreate: vi.fn(),
35+
}));
36+
37+
vi.mock("~/db.server", () => {
38+
const client = {
39+
runtimeEnvironment: {
40+
findFirst: mocks.runtimeEnvironmentFindFirst,
41+
findMany: async () => [],
42+
},
43+
revokedApiKey: { findMany: async () => [], findFirst: async () => null },
44+
project: { findMany: async () => [] },
45+
customerQuery: { findFirst: async () => null, create: mocks.customerQueryCreate },
46+
};
47+
return { prisma: client, $replica: client };
48+
});
49+
vi.mock("~/env.server", () => ({
50+
env: {
51+
SESSION_SECRET: "test-session-secret",
52+
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: "30",
53+
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: 1000000,
54+
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: 50000,
55+
QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS: 500000,
56+
QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY: 1000000,
57+
QUERY_CLICKHOUSE_MAX_RETURNED_ROWS: 1000,
58+
},
59+
}));
60+
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
61+
clickhouseFactory: {
62+
getClickhouseForOrganization: async () => ({
63+
reader: { queryWithStats: mocks.queryWithStats },
64+
}),
65+
},
66+
}));
67+
vi.mock("~/services/platform.v3.server", () => ({ getLimit: async () => 30 }));
68+
vi.mock("~/services/queryConcurrencyLimiter.server", () => ({
69+
queryConcurrencyLimiter: {
70+
acquire: async () => ({ success: true }),
71+
release: async () => {},
72+
},
73+
DEFAULT_ORG_CONCURRENCY_LIMIT: 10,
74+
GLOBAL_CONCURRENCY_LIMIT: 100,
75+
}));
76+
vi.mock("~/services/logger.server", () => ({
77+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
78+
}));
79+
vi.mock("~/v3/services/worker/workerGroupTokenService.server", () => ({
80+
WorkerGroupTokenService: class {},
81+
}));
82+
vi.mock("~/v3/services/common.server", () => ({ ServiceValidationError: class extends Error {} }));
83+
vi.mock("@internal/run-engine", () => ({ EngineServiceValidationError: class extends Error {} }));
84+
85+
import { action } from "~/routes/api.v1.query";
86+
import { executeQuery } from "~/services/queryService.server";
87+
88+
/** The claims the env-JWT exchange mints (api.v1.projects.$projectRef.$env.jwt.ts). */
89+
function mintEnvJwt(scopes: string[]) {
90+
return generateJWT({
91+
secretKey: API_KEY,
92+
payload: {
93+
sub: ENVIRONMENT_ID,
94+
pub: true,
95+
scopes,
96+
act: { sub: "usr_1", client: "dashboard-agent" },
97+
},
98+
expirationTime: "1h",
99+
});
100+
}
101+
102+
async function runQuery(query: string): Promise<{ status: number; body: any }> {
103+
const jwt = await mintEnvJwt(["read:query"]);
104+
const response = await action({
105+
request: new Request("https://api.trigger.dev/api/v1/query", {
106+
method: "POST",
107+
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
108+
body: JSON.stringify({ query }),
109+
}),
110+
params: {},
111+
context: {},
112+
} as any);
113+
return { status: response.status, body: await response.json() };
114+
}
115+
116+
describe("the query API route", () => {
117+
beforeEach(() => {
118+
vi.clearAllMocks();
119+
mocks.runtimeEnvironmentFindFirst.mockResolvedValue(environment);
120+
mocks.customerQueryCreate.mockResolvedValue({ id: "cq_1" });
121+
mocks.queryWithStats.mockReturnValue(async () => [null, { rows: [], stats: {} }]);
122+
});
123+
124+
// Pins the seam the two refusals assert against: a read really does reach ClickHouse here,
125+
// so `not.toHaveBeenCalled()` below means refused, not unreachable.
126+
it("runs a read against ClickHouse", async () => {
127+
const result = await runQuery("SELECT count() FROM runs");
128+
129+
expect(result.status).toBe(200);
130+
expect(mocks.queryWithStats).toHaveBeenCalled();
131+
});
132+
133+
it("refuses a write smuggled in as a second statement", async () => {
134+
const result = await runQuery("SELECT 1 FROM runs; DROP TABLE runs");
135+
136+
expect(result.status).toBe(400);
137+
expect(mocks.queryWithStats).not.toHaveBeenCalled();
138+
});
139+
140+
it("refuses a mutating statement", async () => {
141+
const result = await runQuery("INSERT INTO runs (task_identifier) VALUES ('x')");
142+
143+
expect(result.status).toBe(400);
144+
expect(mocks.queryWithStats).not.toHaveBeenCalled();
145+
});
146+
});
147+
148+
describe("the query service", () => {
149+
beforeEach(() => {
150+
vi.clearAllMocks();
151+
mocks.queryWithStats.mockReturnValue(async () => [null, { rows: [], stats: {} }]);
152+
});
153+
154+
it("keeps ClickHouse read-only when a caller overrides the settings", async () => {
155+
await executeQuery({
156+
name: "test-query",
157+
query: "SELECT count() FROM runs",
158+
scope: "environment",
159+
organizationId: "org_1",
160+
projectId: "proj_1",
161+
environmentId: ENVIRONMENT_ID,
162+
clickhouseSettings: { readonly: "0" },
163+
} as any);
164+
165+
expect(mocks.queryWithStats).toHaveBeenCalled();
166+
expect(mocks.queryWithStats.mock.calls[0][0].settings.readonly).toBe("1");
167+
});
168+
});

internal-packages/dashboard-agent/src/tool-api.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ export function withLiveState(metrics: unknown, queueType: "task" | "custom", li
177177
};
178178
}
179179

180+
/** Failed `run_query` calls in a row before the tool tells the model to stop and answer. */
181+
export const MAX_CONSECUTIVE_QUERY_FAILURES = 3;
182+
180183
export function buildApiTools(args: {
181184
ctx: DashboardAgentToolContext;
182185
client: DashboardAgentApiClient;
@@ -186,6 +189,12 @@ export function buildApiTools(args: {
186189
const { userActorToken, projectRef, environmentName, environmentBranch } = ctx;
187190
const { origin, hasAuth, envApiGet, postQuery, validateChartQuery } = client;
188191

192+
// A failed query hands the model the database error to fix, and it usually does. When it
193+
// doesn't, the only other limit is the turn's 10 steps, so one broken query can eat the
194+
// whole turn and leave the user with no answer at all. This tool set is built per turn,
195+
// so the counter caps consecutive failures within one turn.
196+
let consecutiveQueryFailures = 0;
197+
189198
return {
190199
list_projects: tool({
191200
...listProjectsSchema,
@@ -341,7 +350,16 @@ export function buildApiTools(args: {
341350
execute: async ({ query, period }) => {
342351
const result = await postQuery(query, period);
343352
if (isEnvUnavailable(result)) return envUnavailableError(result, "query");
344-
if (!result.ok) return { error: result.error };
353+
if (!result.ok) {
354+
consecutiveQueryFailures++;
355+
if (consecutiveQueryFailures >= MAX_CONSECUTIVE_QUERY_FAILURES) {
356+
return {
357+
error: `${result.error} That is ${consecutiveQueryFailures} queries in a row that failed. Stop querying and answer the user with what you already have.`,
358+
};
359+
}
360+
return { error: result.error };
361+
}
362+
consecutiveQueryFailures = 0;
345363
const cap = 200;
346364
const rows = result.rows;
347365
return { rows: rows.slice(0, cap), rowCount: rows.length, truncated: rows.length > cap };
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { buildApiTools, MAX_CONSECUTIVE_QUERY_FAILURES } from "./tool-api";
3+
import type { DashboardAgentApiClient } from "./tool-api-client";
4+
5+
/**
6+
* A failed query hands the model the database error to fix. Without a cap, the only other
7+
* limit is the turn's step budget, so a model that keeps rewriting the same broken query
8+
* burns the whole turn and the user gets no answer. After three failures in a row the tool
9+
* tells it to stop and answer.
10+
*/
11+
12+
function queryTool(postQuery: DashboardAgentApiClient["postQuery"]) {
13+
const client = {
14+
origin: "https://api.example.com",
15+
hasAuth: true,
16+
envApiGet: async () => ({ ok: false as const, status: 500 }),
17+
postQuery,
18+
validateChartQuery: async () => null,
19+
} as unknown as DashboardAgentApiClient;
20+
const tools = buildApiTools({
21+
ctx: { userActorToken: "uat", apiOrigin: client.origin },
22+
client,
23+
renderInvestigations: (() => []) as any,
24+
});
25+
return (query: string) => (tools.run_query as any).execute({ query }, {} as any);
26+
}
27+
28+
const failure = {
29+
ok: false as const,
30+
kind: "query" as const,
31+
error: "Unknown expression identifier 'createdAt'.",
32+
};
33+
const success = { ok: true as const, rows: [{ n: 1 }] };
34+
35+
describe("run_query's consecutive-failure cap", () => {
36+
it("keeps handing back the plain error until the cap", async () => {
37+
const run = queryTool(async () => failure);
38+
39+
for (let attempt = 1; attempt < MAX_CONSECUTIVE_QUERY_FAILURES; attempt++) {
40+
const result = await run("SELECT createdAt FROM runs");
41+
expect(result.error).toBe(failure.error);
42+
}
43+
});
44+
45+
it("tells the model to stop and answer at the cap", async () => {
46+
const run = queryTool(async () => failure);
47+
48+
let result: { error: string } = { error: "" };
49+
for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES; attempt++) {
50+
result = await run("SELECT createdAt FROM runs");
51+
}
52+
53+
expect(result.error).toContain(failure.error);
54+
expect(result.error).toContain("answer the user with what you already have");
55+
});
56+
57+
it("counts consecutive failures only, so a good query clears the count", async () => {
58+
const postQuery = vi
59+
.fn()
60+
.mockResolvedValueOnce(failure)
61+
.mockResolvedValueOnce(failure)
62+
.mockResolvedValueOnce(success)
63+
.mockResolvedValue(failure);
64+
const run = queryTool(postQuery as any);
65+
66+
await run("bad");
67+
await run("bad");
68+
await run("good");
69+
const result = await run("bad");
70+
71+
expect(result.error).toBe(failure.error);
72+
});
73+
});

0 commit comments

Comments
 (0)