Skip to content

Commit 31698fa

Browse files
committed
refactor(dashboard-agent): split the tool set into one module per responsibility
tools.ts now only assembles ready adapters, in the same frozen key order: the HTTP/JWT client, result curation, the docs client, the source-read ledger, evidence canonicalisation, investigation persistence, and the api/navigation/ watch/alert tool groups each own their own module. A pure move. The prompt-prefix fingerprints are unchanged, which is what keeps the head-start and agent prefixes byte-identical.
1 parent 29d9a8e commit 31698fa

12 files changed

Lines changed: 1765 additions & 1553 deletions
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { tool, type ToolSet } from "ai";
2+
import { createAlertSchema, deleteAlertSchema, listAlertsSchema } from "./tool-schemas";
3+
import { NO_AUTH, type DashboardAgentApiClient } from "./tool-api-client";
4+
import type { DashboardAgentToolContext } from "./tool-context";
5+
6+
/**
7+
* The alert tools and the request helper they share. Project-level, so these use the
8+
* delegated token and never the env JWT, and a 403 is a capability refusal to explain.
9+
*/
10+
export function buildAlertTools(args: {
11+
ctx: DashboardAgentToolContext;
12+
client: DashboardAgentApiClient;
13+
}): ToolSet {
14+
const { ctx, client } = args;
15+
const { userActorToken } = ctx;
16+
const { origin, hasAuth } = client;
17+
18+
async function alertsRequest(
19+
method: "GET" | "POST" | "DELETE",
20+
path: string,
21+
body?: unknown
22+
): Promise<{ data: unknown } | { error: string }> {
23+
let res: Response;
24+
try {
25+
res = await fetch(`${origin}${path}`, {
26+
method,
27+
headers: {
28+
Authorization: `Bearer ${userActorToken!}`,
29+
Accept: "application/json",
30+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
31+
},
32+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
33+
});
34+
} catch (error) {
35+
return { error: `Couldn't reach the alerts API: ${(error as Error).message}` };
36+
}
37+
38+
const data = (await res.json().catch(() => undefined)) as
39+
| { error?: string; reason?: string; code?: string }
40+
| undefined;
41+
42+
// 403 is a capability refusal and `reason` says which one.
43+
if (res.status === 403) {
44+
return {
45+
error:
46+
data?.reason === "email_alerts_not_configured"
47+
? "Email delivery isn't set up on this instance, so an email alert can't be created. Tell the user that, and that watch results still show in the dashboard."
48+
: "Email alerts aren't enabled here. Tell the user that, and that watch results still show in the dashboard.",
49+
};
50+
}
51+
if (res.status === 400 && data?.code === "email_not_allowed") {
52+
return { error: data.error ?? "Alerts can only go to the user's own account email." };
53+
}
54+
if (!res.ok) {
55+
return { error: data?.error ?? `The alerts API failed (status ${res.status}).` };
56+
}
57+
return { data };
58+
}
59+
60+
return {
61+
// Project-level, so these use the delegated token, not the env JWT. Every call
62+
// carries the chat id, which is what the API scopes its authorization through.
63+
list_alerts: tool({
64+
...listAlertsSchema,
65+
execute: async () => {
66+
if (!hasAuth) return NO_AUTH;
67+
if (!ctx.chatId) return { error: "No chat is available to read alerts from." };
68+
const result = await alertsRequest(
69+
"GET",
70+
`/api/v1/dashboard-agent/alerts?chatId=${encodeURIComponent(ctx.chatId)}`
71+
);
72+
if ("error" in result) return result;
73+
const alerts = (result.data as { alerts?: unknown } | undefined)?.alerts;
74+
return { alerts: Array.isArray(alerts) ? alerts : [] };
75+
},
76+
}),
77+
78+
create_alert: tool({
79+
...createAlertSchema,
80+
execute: async ({ email }) => {
81+
if (!hasAuth) return NO_AUTH;
82+
if (!ctx.chatId) return { error: "No chat is available to create an alert from." };
83+
const result = await alertsRequest("POST", "/api/v1/dashboard-agent/alerts", {
84+
chatId: ctx.chatId,
85+
channel: "email",
86+
...(email ? { email } : {}),
87+
});
88+
if ("error" in result) return result;
89+
return { created: true, alert: (result.data as { alert?: unknown } | undefined)?.alert };
90+
},
91+
}),
92+
93+
delete_alert: tool({
94+
...deleteAlertSchema,
95+
execute: async ({ alertId }) => {
96+
if (!hasAuth) return NO_AUTH;
97+
if (!ctx.chatId) return { error: "No chat is available to change alerts from." };
98+
const result = await alertsRequest(
99+
"DELETE",
100+
`/api/v1/dashboard-agent/alerts/${encodeURIComponent(alertId)}`,
101+
{ chatId: ctx.chatId }
102+
);
103+
if ("error" in result) return result;
104+
return { deleted: true, alertId };
105+
},
106+
}),
107+
};
108+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { logger } from "@trigger.dev/sdk";
2+
3+
/**
4+
* The agent's HTTP surface: the delegated-token GET, the env-JWT exchange and its
5+
* turn-scoped cache, and the query POST both `run_query` and chart validation use.
6+
*/
7+
8+
export type FetchResult = { ok: true; data: unknown } | { ok: false; status: number };
9+
10+
// "query" is the server rejecting the TRQL, "transport" is the request breaking. Chart
11+
// validation only fails a render on "query".
12+
export type QueryPostResult =
13+
| { ok: true; rows: Array<Record<string, unknown>> }
14+
| { ok: false; kind: "query" | "transport"; error: string };
15+
16+
export const NO_AUTH = { error: "No delegated access is available for this turn." } as const;
17+
18+
export async function apiGet(origin: string, path: string, token: string): Promise<FetchResult> {
19+
const res = await fetch(`${origin}${path}`, {
20+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
21+
});
22+
if (!res.ok) return { ok: false, status: res.status };
23+
return { ok: true, data: await res.json() };
24+
}
25+
26+
// The exchange ceilings these scopes to the delegated token's read-only cap, so the
27+
// JWT can never widen the grant. Null when there is no current env, or on a denial.
28+
async function exchangeEnvJwt(
29+
origin: string,
30+
userActorToken: string,
31+
projectRef: string,
32+
environmentName: string
33+
): Promise<string | null> {
34+
const res = await fetch(`${origin}/api/v1/projects/${projectRef}/${environmentName}/jwt`, {
35+
method: "POST",
36+
headers: { Authorization: `Bearer ${userActorToken}`, "Content-Type": "application/json" },
37+
body: JSON.stringify({
38+
claims: { scopes: ["read:runs", "read:deployments", "read:errors", "read:query"] },
39+
}),
40+
});
41+
if (!res.ok) return null;
42+
const data = (await res.json()) as { token?: string };
43+
return data.token ?? null;
44+
}
45+
46+
export type DashboardAgentApiClient = {
47+
/** The API origin with any trailing slash removed. Empty when none was injected. */
48+
origin: string;
49+
/** Whether this turn has both a delegated token and an origin to spend it on. */
50+
hasAuth: boolean;
51+
/** A GET as the environment JWT. `null` means there is no current environment. */
52+
envApiGet(path: string): Promise<FetchResult | null>;
53+
postQuery(query: string, period: string | undefined): Promise<QueryPostResult | null>;
54+
validateChartQuery(query: string, period: string | undefined): Promise<string | null>;
55+
};
56+
57+
export type ApiClientContext = {
58+
userActorToken?: string;
59+
apiOrigin?: string;
60+
projectRef?: string;
61+
environmentName?: string;
62+
};
63+
64+
export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient {
65+
const { userActorToken, apiOrigin, projectRef, environmentName } = ctx;
66+
const origin = apiOrigin ? apiOrigin.replace(/\/$/, "") : "";
67+
const hasAuth = Boolean(userActorToken && origin);
68+
69+
// Turn-scoped, since the tool set is rebuilt per turn, and keyed by project +
70+
// environment. Caching the promise makes concurrent calls share one exchange.
71+
const envJwts = new Map<string, Promise<string | null>>();
72+
function getEnvJwt(refresh = false): Promise<string | null> {
73+
if (!hasAuth || !projectRef || !environmentName) return Promise.resolve(null);
74+
const key = `${projectRef}/${environmentName}`;
75+
if (refresh) envJwts.delete(key);
76+
let pending = envJwts.get(key);
77+
if (!pending) {
78+
pending = exchangeEnvJwt(origin, userActorToken!, projectRef, environmentName);
79+
envJwts.set(key, pending);
80+
}
81+
return pending;
82+
}
83+
84+
/**
85+
* `null` means there is no current environment. On an unauthorized result the cache
86+
* entry is dropped and the call is retried once, since a token can be minted stale.
87+
*/
88+
async function withEnvJwt<T>(
89+
call: (jwt: string) => Promise<T>,
90+
isUnauthorized: (result: T) => boolean
91+
): Promise<T | null> {
92+
const jwt = await getEnvJwt();
93+
if (!jwt) return null;
94+
const first = await call(jwt);
95+
if (!isUnauthorized(first)) return first;
96+
const fresh = await getEnvJwt(true);
97+
if (!fresh) return first;
98+
return call(fresh);
99+
}
100+
101+
const unauthorizedGet = (result: FetchResult) => !result.ok && result.status === 401;
102+
103+
function envApiGet(path: string): Promise<FetchResult | null> {
104+
return withEnvJwt((jwt) => apiGet(origin, path, jwt), unauthorizedGet);
105+
}
106+
107+
// A POST, so it can't use envApiGet, but keeps the same JWT cache and one-shot
108+
// re-exchange on a 401. Shared by run_query and chart-block validation.
109+
async function postQuery(
110+
query: string,
111+
period: string | undefined
112+
): Promise<QueryPostResult | null> {
113+
const attempt = await withEnvJwt<{ res: Response } | { error: string }>(
114+
async (jwt) => {
115+
try {
116+
return {
117+
res: await fetch(`${origin}/api/v1/query`, {
118+
method: "POST",
119+
headers: {
120+
Authorization: `Bearer ${jwt}`,
121+
"Content-Type": "application/json",
122+
Accept: "application/json",
123+
},
124+
body: JSON.stringify({ query, scope: "environment", period, format: "json" }),
125+
}),
126+
};
127+
} catch (error) {
128+
return { error: `Query request failed: ${(error as Error).message}` };
129+
}
130+
},
131+
(result) => "res" in result && result.res.status === 401
132+
);
133+
if (!attempt) return null;
134+
if ("error" in attempt) return { ok: false, kind: "transport", error: attempt.error };
135+
const res = attempt.res;
136+
// The route returns 400 with { error } for invalid TRQL.
137+
const data = (await res.json().catch(() => ({}))) as { results?: unknown; error?: string };
138+
if (!res.ok) {
139+
return {
140+
ok: false,
141+
kind: res.status >= 500 ? "transport" : "query",
142+
error: data.error ?? `Query failed (status ${res.status}).`,
143+
};
144+
}
145+
return {
146+
ok: true,
147+
rows: Array.isArray(data.results) ? (data.results as Array<Record<string, unknown>>) : [],
148+
};
149+
}
150+
151+
// Skipped rather than blocking the render when there is no token or the request broke.
152+
async function validateChartQuery(
153+
query: string,
154+
period: string | undefined
155+
): Promise<string | null> {
156+
const result = await postQuery(query, period);
157+
if (!result || result.ok) return null;
158+
if (result.kind === "transport") {
159+
logger.warn("Skipped chart query validation", { error: result.error });
160+
return null;
161+
}
162+
return result.error;
163+
}
164+
165+
return { origin, hasAuth, envApiGet, postQuery, validateChartQuery };
166+
}

0 commit comments

Comments
 (0)