Skip to content

Commit 9c2082b

Browse files
committed
feat(dashboard-agent): let data lookups target another project/environment
Add a per-call project/environment override on list_runs, get_run, get_run_trace, get_error, and get_queue, so a not-found lookup can be retried elsewhere in the org. The env-JWT exchange targets and caches per override; the default (no-override) path is unchanged. System prompt gains the grounding rule to retry and name where it was found.
1 parent f64c674 commit 9c2082b

5 files changed

Lines changed: 238 additions & 43 deletions

File tree

internal-packages/dashboard-agent/src/__snapshots__/prompt-prefix.test.ts.snap

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export type DashboardAgentApiClient = {
115115
/** Whether this turn has both a delegated token and an origin to spend it on. */
116116
hasAuth: boolean;
117117
/** A GET as the environment JWT, or why no environment JWT could be made. */
118-
envApiGet(path: string): Promise<EnvFetchResult>;
118+
envApiGet(path: string, target?: ApiTarget): Promise<EnvFetchResult>;
119119
postQuery(query: string, period: string | undefined): Promise<QueryPostResult | EnvUnavailable>;
120120
validateChartQuery(query: string, period: string | undefined): Promise<string | null>;
121121
};
@@ -128,6 +128,11 @@ export type ApiClientContext = {
128128
environmentBranch?: string;
129129
};
130130

131+
// A per-call override of which project/environment a data lookup targets, for reads
132+
// that cross into another project of the same organization. Omitted fields fall back
133+
// to the context's own project/environment.
134+
export type ApiTarget = { projectRef?: string; environmentName?: string };
135+
131136
export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient {
132137
const { userActorToken, apiOrigin, projectRef, environmentName, environmentBranch } = ctx;
133138
const origin = apiOrigin ? apiOrigin.replace(/\/$/, "") : "";
@@ -137,20 +142,20 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient
137142
// environment. Caching the promise makes concurrent calls share one exchange.
138143
type EnvJwt = { ok: true; token: string } | EnvUnavailable;
139144
const envJwts = new Map<string, Promise<EnvJwt>>();
140-
function getEnvJwt(refresh = false): Promise<EnvJwt> {
141-
if (!hasAuth || !projectRef || !environmentName) return Promise.resolve(MISSING_ENV);
142-
const key = `${projectRef}/${environmentName}/${environmentBranch ?? ""}`;
145+
function getEnvJwt(refresh = false, target?: ApiTarget): Promise<EnvJwt> {
146+
// An override drops the branch: it names another project/environment, which the
147+
// current branch can't be assumed to apply to. A field left off the override still
148+
// falls back to ctx's own value.
149+
const ref = target?.projectRef ?? projectRef;
150+
const env = target?.environmentName ?? environmentName;
151+
const branch = target ? undefined : environmentBranch;
152+
if (!hasAuth || !ref || !env) return Promise.resolve(MISSING_ENV);
153+
const key = `${ref}/${env}/${branch ?? ""}`;
143154
if (refresh) envJwts.delete(key);
144155
let pending = envJwts.get(key);
145156
if (!pending) {
146157
// A failed exchange is not cached: a 403 or a 5xx would otherwise pin the whole turn.
147-
pending = exchangeEnvJwt(
148-
origin,
149-
userActorToken!,
150-
projectRef,
151-
environmentName,
152-
environmentBranch
153-
).then((result) => {
158+
pending = exchangeEnvJwt(origin, userActorToken!, ref, env, branch).then((result) => {
154159
if (!result.ok) envJwts.delete(key);
155160
return result;
156161
});
@@ -165,22 +170,23 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient
165170
*/
166171
async function withEnvJwt<T extends object>(
167172
call: (jwt: string) => Promise<T>,
168-
isUnauthorized: (result: T) => boolean
173+
isUnauthorized: (result: T) => boolean,
174+
target?: ApiTarget
169175
): Promise<T | EnvUnavailable> {
170-
const jwt = await getEnvJwt();
176+
const jwt = await getEnvJwt(false, target);
171177
if (!jwt.ok) return jwt;
172178
const first = await call(jwt.token);
173179
if (!isUnauthorized(first)) return first;
174-
const fresh = await getEnvJwt(true);
180+
const fresh = await getEnvJwt(true, target);
175181
if (!fresh.ok) return first;
176182
return call(fresh.token);
177183
}
178184

179185
const unauthorizedGet = (result: FetchResult) =>
180186
!result.ok && "status" in result && result.status === 401;
181187

182-
function envApiGet(path: string): Promise<EnvFetchResult> {
183-
return withEnvJwt((jwt) => apiGet(origin, path, jwt), unauthorizedGet);
188+
function envApiGet(path: string, target?: ApiTarget): Promise<EnvFetchResult> {
189+
return withEnvJwt((jwt) => apiGet(origin, path, jwt), unauthorizedGet, target);
184190
}
185191

186192
// A POST, so it can't use envApiGet, but keeps the same JWT cache and one-shot
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { ZodTypeAny } from "zod";
3+
import { buildApiTools } from "./tool-api";
4+
import { createApiClient } from "./tool-api-client";
5+
import {
6+
getErrorSchema,
7+
getQueueSchema,
8+
getRunSchema,
9+
getRunTraceSchema,
10+
listRunsSchema,
11+
} from "./tool-schemas";
12+
13+
/**
14+
* The `project`/`environment` override on data lookups: the JWT exchange has to target
15+
* the override, not ctx, and cache per target the same way the default path does. The
16+
* default path (no override) must be byte-for-byte unchanged.
17+
*/
18+
19+
const ORIGIN = "https://api.example.com";
20+
21+
type Call = { url: string; body?: unknown };
22+
let calls: Call[] = [];
23+
24+
function stubFetch() {
25+
return vi.fn(async (input: any, init: any = {}) => {
26+
const url = typeof input === "string" ? input : input.url;
27+
calls.push({ url, body: init.body ? JSON.parse(init.body) : undefined });
28+
if (url.endsWith("/jwt")) {
29+
// The env JWT is minted for whichever project/environment segment the exchange
30+
// addressed, so the token echoes it back for the assertions below.
31+
const match = url.match(/\/api\/v1\/projects\/([^/]+)\/([^/]+)\/jwt$/);
32+
return Response.json({ token: `jwt:${match![1]}/${match![2]}` });
33+
}
34+
return Response.json({ data: [] });
35+
});
36+
}
37+
38+
function tools() {
39+
const ctx = {
40+
userActorToken: "uat",
41+
apiOrigin: ORIGIN,
42+
projectRef: "proj_current",
43+
environmentName: "prod",
44+
};
45+
return buildApiTools({
46+
ctx,
47+
client: createApiClient(ctx),
48+
renderInvestigations: (() => []) as any,
49+
spanLedger: { recordTraceSpans: () => {} },
50+
});
51+
}
52+
53+
const jwtCalls = () => calls.filter((c) => c.url.endsWith("/jwt"));
54+
55+
beforeEach(() => {
56+
calls = [];
57+
vi.stubGlobal("fetch", stubFetch());
58+
});
59+
afterEach(() => vi.unstubAllGlobals());
60+
61+
describe("the project/environment override", () => {
62+
it("exchanges the JWT for the overridden project and environment, not ctx's", async () => {
63+
const t = tools();
64+
65+
await (t.list_runs as any).execute(
66+
{ project: "proj_other", environment: "staging" },
67+
{} as any
68+
);
69+
70+
expect(jwtCalls()).toHaveLength(1);
71+
expect(jwtCalls()[0].url).toBe(`${ORIGIN}/api/v1/projects/proj_other/staging/jwt`);
72+
});
73+
74+
it("leaves the default path (no override) unchanged", async () => {
75+
const t = tools();
76+
77+
await (t.list_runs as any).execute({}, {} as any);
78+
79+
expect(jwtCalls()).toHaveLength(1);
80+
expect(jwtCalls()[0].url).toBe(`${ORIGIN}/api/v1/projects/proj_current/prod/jwt`);
81+
});
82+
83+
it("caches the exchanged JWT per target within the turn", async () => {
84+
const t = tools();
85+
86+
await (t.list_runs as any).execute(
87+
{ project: "proj_other", environment: "staging" },
88+
{} as any
89+
);
90+
await (t.get_error as any).execute(
91+
{ errorId: "error_1", project: "proj_other", environment: "staging" },
92+
{} as any
93+
);
94+
await (t.list_runs as any).execute({}, {} as any);
95+
96+
// One exchange for the override target, one for the default target — never re-exchanged.
97+
expect(jwtCalls()).toHaveLength(2);
98+
});
99+
100+
it("defaults environment to the current environment's name when only project is given", async () => {
101+
const t = tools();
102+
103+
await (t.get_run as any).execute({ runId: "run_1", project: "proj_other" }, {} as any);
104+
105+
expect(jwtCalls()[0].url).toBe(`${ORIGIN}/api/v1/projects/proj_other/prod/jwt`);
106+
});
107+
});
108+
109+
describe("project/environment schema round-trip", () => {
110+
it.each([
111+
["list_runs", listRunsSchema, {}],
112+
["get_run", getRunSchema, { runId: "run_1" }],
113+
["get_run_trace", getRunTraceSchema, { runId: "run_1" }],
114+
["get_error", getErrorSchema, { errorId: "error_1" }],
115+
["get_queue", getQueueSchema, { queue: "my-queue" }],
116+
])("%s accepts project/environment and stays valid without them", (_name, schema, base) => {
117+
const inputSchema = schema.inputSchema as ZodTypeAny;
118+
const withOverride = inputSchema.safeParse({
119+
...base,
120+
project: "proj_other",
121+
environment: "staging",
122+
});
123+
expect(withOverride.success).toBe(true);
124+
125+
const withoutOverride = inputSchema.safeParse(base);
126+
expect(withoutOverride.success).toBe(true);
127+
});
128+
});

0 commit comments

Comments
 (0)