Skip to content

Commit c3ee6a4

Browse files
committed
perf(webapp): resolve the agent card's links in one request
1 parent ebad471 commit c3ee6a4

4 files changed

Lines changed: 188 additions & 31 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from "vitest";
2+
import { MAX_URIS_PER_RESOLVE_REQUEST, planUriBatches } from "./resolve-uris";
3+
4+
const uri = (index: number) => `trigger://runs/run_${index}`;
5+
6+
describe("planUriBatches", () => {
7+
it("resolves a card's twenty citations in one request", () => {
8+
const batches = planUriBatches(Array.from({ length: 20 }, (_, index) => uri(index)));
9+
10+
expect(batches).toHaveLength(1);
11+
expect(batches[0]).toHaveLength(20);
12+
});
13+
14+
it("asks about each URI once", () => {
15+
const batches = planUriBatches([uri(1), uri(1), uri(2)]);
16+
17+
expect(batches).toEqual([[uri(1), uri(2)]]);
18+
});
19+
20+
it("caps a request and carries the rest over", () => {
21+
const count = MAX_URIS_PER_RESOLVE_REQUEST + 3;
22+
const batches = planUriBatches(Array.from({ length: count }, (_, index) => uri(index)));
23+
24+
expect(batches).toHaveLength(2);
25+
expect(batches[0]).toHaveLength(MAX_URIS_PER_RESOLVE_REQUEST);
26+
expect(batches[1]).toHaveLength(3);
27+
});
28+
29+
it("has nothing to send for nothing", () => {
30+
expect(planUriBatches([])).toEqual([]);
31+
});
32+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Batching for `trigger://` resolution. An investigation card cites ten to twenty targets, and
3+
* each request re-authorises and re-resolves the environment — so they go in one request.
4+
*/
5+
6+
/** One environment lookup and one repo lookup serve a whole batch. */
7+
export const MAX_URIS_PER_RESOLVE_REQUEST = 25;
8+
9+
/** A transient failure is worth retrying; a third one isn't. */
10+
export const MAX_RESOLVE_ATTEMPTS = 3;
11+
12+
export const RESOLVE_RETRY_DELAY_MS = 1_000;
13+
14+
/** Deduplicates, then splits into requests no bigger than the cap. */
15+
export function planUriBatches(
16+
uris: readonly string[],
17+
cap: number = MAX_URIS_PER_RESOLVE_REQUEST
18+
): string[][] {
19+
const unique = [...new Set(uris)];
20+
const batches: string[][] = [];
21+
for (let index = 0; index < unique.length; index += cap) {
22+
batches.push(unique.slice(index, index + cap));
23+
}
24+
return batches;
25+
}
Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import { isTriggerUri } from "@internal/dashboard-agent-contracts";
22
import { useCallback, useEffect, useRef, useState } from "react";
33
import type { ResolvedUri } from "./ReportView";
4+
import { MAX_RESOLVE_ATTEMPTS, planUriBatches, RESOLVE_RETRY_DELAY_MS } from "./resolve-uris";
45

5-
// Synchronous facade over the panel's async `resolve` action: the first render of a
6-
// URI returns null. Failures cache as null, so a URI is asked about exactly once.
6+
/**
7+
* Synchronous facade over the panel's async `resolve-many` action: the first render of a URI
8+
* returns null. A "nothing to open" answer is cached, a transient failure is retried a few times.
9+
*/
710
export function useTriggerUriResolver(actionPath: string): (uri: string) => ResolvedUri | null {
811
const [resolved, setResolved] = useState<Record<string, ResolvedUri | null>>({});
12+
// Mirrors `resolved` for the flush, which runs after a delay and must not read a stale closure.
13+
const answered = useRef<Record<string, ResolvedUri | null>>({});
914
// URIs the cards asked about this render; a ref because it's written during render.
1015
const seen = useRef(new Set<string>());
11-
const requested = useRef(new Set<string>());
16+
const inFlight = useRef(new Set<string>());
17+
const attempts = useRef(new Map<string, number>());
18+
const retryTimer = useRef<number | undefined>(undefined);
1219

1320
const resolveUri = useCallback(
1421
(uri: string): ResolvedUri | null => {
@@ -19,28 +26,78 @@ export function useTriggerUriResolver(actionPath: string): (uri: string) => Reso
1926
[resolved]
2027
);
2128

22-
// No dependency array on purpose: after every render, fetch whatever the
23-
// cards asked about that hasn't been requested yet.
24-
useEffect(() => {
25-
const toFetch = [...seen.current].filter((uri) => !requested.current.has(uri));
26-
for (const uri of toFetch) {
27-
requested.current.add(uri);
29+
const record = useCallback((entries: Record<string, ResolvedUri | null>) => {
30+
answered.current = { ...answered.current, ...entries };
31+
setResolved((previous) => ({ ...previous, ...entries }));
32+
}, []);
33+
34+
const flushRef = useRef<() => void>(() => {});
35+
36+
const flush = useCallback(() => {
37+
const pending = [...seen.current].filter(
38+
(uri) => !(uri in answered.current) && !inFlight.current.has(uri)
39+
);
40+
if (pending.length === 0) return;
41+
42+
for (const batch of planUriBatches(pending)) {
43+
for (const uri of batch) inFlight.current.add(uri);
44+
2845
const body = new FormData();
29-
body.set("intent", "resolve");
30-
body.set("uri", uri);
46+
body.set("intent", "resolve-many");
47+
body.set("uris", JSON.stringify(batch));
48+
3149
fetch(actionPath, { method: "POST", body })
32-
.then((res) => (res.ok ? (res.json() as Promise<{ path?: string; label?: string }>) : null))
50+
.then(async (res) => {
51+
if (!res.ok) throw new Error(`Failed to resolve links (${res.status})`);
52+
return (await res.json()) as {
53+
resolved?: Record<string, { path?: string; label?: string } | null>;
54+
};
55+
})
3356
.then((data) => {
34-
setResolved((prev) => ({
35-
...prev,
36-
[uri]: data?.path ? { url: data.path, label: data.label ?? uri } : null,
37-
}));
57+
// A 200 is the definitive answer, including "nothing to open": cached for good.
58+
const entries: Record<string, ResolvedUri | null> = {};
59+
for (const uri of batch) {
60+
const hit = data.resolved?.[uri];
61+
entries[uri] = hit?.path ? { url: hit.path, label: hit.label ?? uri } : null;
62+
}
63+
record(entries);
3864
})
3965
.catch(() => {
40-
setResolved((prev) => ({ ...prev, [uri]: null }));
66+
// Transient: a 5xx, a network blip, a deploy. Retried, then given up on.
67+
const exhausted: Record<string, ResolvedUri | null> = {};
68+
for (const uri of batch) {
69+
const tried = (attempts.current.get(uri) ?? 0) + 1;
70+
attempts.current.set(uri, tried);
71+
if (tried >= MAX_RESOLVE_ATTEMPTS) exhausted[uri] = null;
72+
}
73+
if (Object.keys(exhausted).length > 0) record(exhausted);
74+
// One timer for all batches; a render may never follow, so it can't be the trigger.
75+
if (retryTimer.current === undefined) {
76+
retryTimer.current = window.setTimeout(() => {
77+
retryTimer.current = undefined;
78+
flushRef.current();
79+
}, RESOLVE_RETRY_DELAY_MS);
80+
}
81+
})
82+
.finally(() => {
83+
for (const uri of batch) inFlight.current.delete(uri);
4184
});
4285
}
86+
}, [actionPath, record]);
87+
88+
// No dependency array on purpose: after every render, fetch whatever the cards
89+
// asked about that hasn't been answered yet.
90+
useEffect(() => {
91+
flushRef.current = flush;
92+
flush();
4393
});
4494

95+
useEffect(
96+
() => () => {
97+
if (retryTimer.current !== undefined) window.clearTimeout(retryTimer.current);
98+
},
99+
[]
100+
);
101+
45102
return resolveUri;
46103
}

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
MESSAGE_TOO_LARGE_CODE,
3333
MESSAGE_TOO_LARGE_ERROR,
3434
} from "~/components/dashboard-agent/message-limits";
35+
import { MAX_URIS_PER_RESOLVE_REQUEST } from "~/components/dashboard-agent/resolve-uris";
3536
import { $replica } from "~/db.server";
3637
import { env } from "~/env.server";
3738
import { findProjectBySlug } from "~/models/project.server";
@@ -82,6 +83,7 @@ const ActionBody = z.object({
8283
"delete",
8384
"read",
8485
"resolve",
86+
"resolve-many",
8587
"watch-cancel",
8688
"watch-create",
8789
]),
@@ -94,6 +96,8 @@ const ActionBody = z.object({
9496
pinned: z.enum(["true", "false"]).optional(),
9597
// A `trigger://` URI, for `resolve`.
9698
uri: z.string().optional(),
99+
// A JSON array of `trigger://` URIs, for `resolve-many`.
100+
uris: z.string().optional(),
97101
// The watch to cancel, for `watch-cancel`.
98102
watchId: z.string().min(1).optional(),
99103
// The configured card, for `watch-create`: a JSON `WatchDraft`.
@@ -207,6 +211,20 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
207211
});
208212
};
209213

214+
/** Only a source URI needs the connected repository, so a batch without one skips the read. */
215+
async function findRepositoryForSourceUris(projectId: string, uris: string[]) {
216+
if (!uris.some((uri) => uri.includes("/source/"))) return null;
217+
218+
const connected = await $replica.connectedGithubRepository.findFirst({
219+
where: {
220+
projectId,
221+
repository: { installation: { deletedAt: null, suspendedAt: null } },
222+
},
223+
select: { repository: { select: { fullName: true } } },
224+
});
225+
return connected?.repository ?? null;
226+
}
227+
210228
function messageTooLarge() {
211229
return json({ error: MESSAGE_TOO_LARGE_ERROR, code: MESSAGE_TOO_LARGE_CODE }, { status: 413 });
212230
}
@@ -335,21 +353,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
335353
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
336354
if (!environment) return json({ error: "Environment not found" }, { status: 404 });
337355

338-
// Only a source URI needs the connected repository.
339-
const repository = uri.includes("/source/")
340-
? await $replica.connectedGithubRepository.findFirst({
341-
where: {
342-
projectId: project.id,
343-
repository: { installation: { deletedAt: null, suspendedAt: null } },
344-
},
345-
select: { repository: { select: { fullName: true } } },
346-
})
347-
: null;
356+
const repository = await findRepositoryForSourceUris(project.id, [uri]);
348357

349-
const resolved = resolveTriggerUri(
350-
{ ...environment, repository: repository?.repository ?? null },
351-
uri
352-
);
358+
const resolved = resolveTriggerUri({ ...environment, repository }, uri);
353359
if (!resolved) return json({ error: "Nothing to open for that link" }, { status: 404 });
354360

355361
return json({
@@ -359,6 +365,43 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
359365
});
360366
}
361367

368+
// The card's citations in one request: one environment lookup and one repo lookup for the
369+
// whole batch, same environment scope as `resolve`.
370+
if (parsed.data.intent === "resolve-many") {
371+
let uris: string[];
372+
try {
373+
const list = JSON.parse(parsed.data.uris ?? "") as unknown;
374+
if (!Array.isArray(list) || list.some((uri) => typeof uri !== "string")) {
375+
return json({ error: "uris is required" }, { status: 400 });
376+
}
377+
uris = [...new Set(list as string[])];
378+
} catch {
379+
return json({ error: "uris is required" }, { status: 400 });
380+
}
381+
382+
if (uris.length === 0) return json({ error: "uris is required" }, { status: 400 });
383+
if (uris.length > MAX_URIS_PER_RESOLVE_REQUEST) {
384+
return json({ error: "Too many links in one request" }, { status: 400 });
385+
}
386+
387+
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
388+
if (!environment) return json({ error: "Environment not found" }, { status: 404 });
389+
390+
const repository = await findRepositoryForSourceUris(project.id, uris);
391+
const scope = { ...environment, repository };
392+
393+
// A null entry is the definitive "nothing to open": the client caches it.
394+
const resolved: Record<string, { path: string; label: string; external: boolean } | null> = {};
395+
for (const uri of uris) {
396+
const hit = resolveTriggerUri(scope, uri);
397+
resolved[uri] = hit
398+
? { path: hit.url, label: hit.label, external: hit.external ?? false }
399+
: null;
400+
}
401+
402+
return json({ resolved });
403+
}
404+
362405
// The configuration card's submit path. The environment comes from the URL and goes
363406
// through the same re-authorization a background tick passes, never from the body.
364407
if (parsed.data.intent === "watch-create") {

0 commit comments

Comments
 (0)