Skip to content

Commit 4794d5a

Browse files
committed
feat(web): add agent dependency tree visualization to store pages
- Add recursive tree view showing spawnableAgents hierarchy - Add Mermaid diagram as alternate visualization - Make agent IDs in Definition code clickable with hover icon - Integrate subagents into Definition section - Move Last Used into stats grid for cleaner layout Code quality: - Fix memory leak with AbortController in useEffect - Memoize Mermaid diagram generation - Add rel=noopener noreferrer to external links - Improve Mermaid label escaping for XSS prevention - Guard against non-array spawnableAgents Tests: - Add 21 unit tests for agent-tree.ts data layer
1 parent 20b9080 commit 4794d5a

File tree

8 files changed

+1710
-59
lines changed

8 files changed

+1710
-59
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import db from '@codebuff/internal/db'
2+
import * as schema from '@codebuff/internal/db/schema'
3+
import { and, eq, or } from 'drizzle-orm'
4+
import { NextResponse } from 'next/server'
5+
6+
import type { Logger } from '@codebuff/common/types/contracts/logger'
7+
8+
import {
9+
buildAgentTree,
10+
type AgentLookupResult,
11+
type AgentTreeData,
12+
} from '@/lib/agent-tree'
13+
14+
interface RouteParams {
15+
publisherId: string
16+
agentId: string
17+
version: string
18+
}
19+
20+
export interface GetDependenciesParams {
21+
params: Promise<RouteParams>
22+
logger: Logger
23+
}
24+
25+
interface PendingLookup {
26+
resolve: (result: AgentLookupResult | null) => void
27+
publisher: string
28+
agentId: string
29+
version: string
30+
}
31+
32+
/**
33+
* Creates a batching agent lookup function that automatically batches
34+
* concurrent requests into a single database query.
35+
*
36+
* This solves the N+1 query problem: when the tree builder processes siblings
37+
* in parallel with Promise.all, all their lookupAgent calls will be queued
38+
* and executed in a single batch query.
39+
*
40+
* Query reduction: ~2N queries -> ~maxDepth queries (typically ≤6 total)
41+
*/
42+
function createBatchingAgentLookup(
43+
publisherSet: Set<string>,
44+
logger: Logger,
45+
) {
46+
const cache = new Map<string, AgentLookupResult | null>()
47+
const pending: PendingLookup[] = []
48+
let batchScheduled = false
49+
50+
async function executeBatch() {
51+
batchScheduled = false
52+
if (pending.length === 0) return
53+
54+
// Grab all pending requests and clear the queue
55+
const batch = [...pending]
56+
pending.length = 0
57+
58+
try {
59+
const uniqueRequests = new Map<
60+
string,
61+
{ publisherId: string; agentId: string; version: string }
62+
>()
63+
64+
for (const req of batch) {
65+
const cacheKey = `${req.publisher}/${req.agentId}@${req.version}`
66+
67+
if (!publisherSet.has(req.publisher)) {
68+
cache.set(cacheKey, null)
69+
req.resolve(null)
70+
continue
71+
}
72+
73+
uniqueRequests.set(`${req.publisher}:${req.agentId}:${req.version}`, {
74+
publisherId: req.publisher,
75+
agentId: req.agentId,
76+
version: req.version,
77+
})
78+
}
79+
80+
let agents: Array<typeof schema.agentConfig.$inferSelect> = []
81+
if (uniqueRequests.size > 0) {
82+
const conditions = [...uniqueRequests.values()].map((req) =>
83+
and(
84+
eq(schema.agentConfig.id, req.agentId),
85+
eq(schema.agentConfig.version, req.version),
86+
eq(schema.agentConfig.publisher_id, req.publisherId),
87+
),
88+
)
89+
agents = await db
90+
.select()
91+
.from(schema.agentConfig)
92+
.where(conditions.length === 1 ? conditions[0] : or(...conditions))
93+
}
94+
95+
// Create lookup map for quick access
96+
const agentMap = new Map<string, typeof schema.agentConfig.$inferSelect>()
97+
for (const agent of agents) {
98+
agentMap.set(`${agent.publisher_id}:${agent.id}:${agent.version}`, agent)
99+
}
100+
101+
// Resolve all pending requests
102+
for (const req of batch) {
103+
const cacheKey = `${req.publisher}/${req.agentId}@${req.version}`
104+
105+
// Resolve duplicates from cache
106+
if (cache.has(cacheKey)) {
107+
req.resolve(cache.get(cacheKey) ?? null)
108+
continue
109+
}
110+
111+
if (!publisherSet.has(req.publisher)) {
112+
cache.set(cacheKey, null)
113+
req.resolve(null)
114+
continue
115+
}
116+
117+
const lookupKey = `${req.publisher}:${req.agentId}:${req.version}`
118+
const agent = agentMap.get(lookupKey)
119+
if (!agent) {
120+
cache.set(cacheKey, null)
121+
req.resolve(null)
122+
continue
123+
}
124+
125+
const agentData =
126+
typeof agent.data === 'string' ? JSON.parse(agent.data) : agent.data
127+
128+
const result: AgentLookupResult = {
129+
displayName: agentData?.displayName ?? agentData?.name ?? req.agentId,
130+
spawnerPrompt: agentData?.spawnerPrompt ?? null,
131+
spawnableAgents: Array.isArray(agentData?.spawnableAgents)
132+
? agentData.spawnableAgents
133+
: [],
134+
isAvailable: true,
135+
}
136+
137+
cache.set(cacheKey, result)
138+
req.resolve(result)
139+
}
140+
} catch (error) {
141+
logger.error({ error }, 'Batch agent lookup failed')
142+
for (const req of batch) {
143+
const cacheKey = `${req.publisher}/${req.agentId}@${req.version}`
144+
if (!cache.has(cacheKey)) {
145+
cache.set(cacheKey, null)
146+
}
147+
req.resolve(cache.get(cacheKey) ?? null)
148+
}
149+
}
150+
}
151+
152+
return async function lookupAgent(
153+
publisher: string,
154+
agentId: string,
155+
version: string,
156+
): Promise<AgentLookupResult | null> {
157+
const cacheKey = `${publisher}/${agentId}@${version}`
158+
159+
// Return from cache if available
160+
if (cache.has(cacheKey)) {
161+
return cache.get(cacheKey) ?? null
162+
}
163+
164+
// Queue the request and schedule batch execution
165+
return new Promise((resolve) => {
166+
pending.push({ resolve, publisher, agentId, version })
167+
168+
if (!batchScheduled) {
169+
batchScheduled = true
170+
// Use setImmediate to batch all concurrent requests in the same tick
171+
setImmediate(executeBatch)
172+
}
173+
})
174+
}
175+
}
176+
177+
export async function getDependencies({
178+
params,
179+
logger,
180+
}: GetDependenciesParams) {
181+
try {
182+
const { publisherId, agentId, version } = await params
183+
184+
if (!publisherId || !agentId || !version) {
185+
return NextResponse.json(
186+
{ error: 'Missing required parameters' },
187+
{ status: 400 },
188+
)
189+
}
190+
191+
// Pre-fetch all publishers once (small table, single query)
192+
// This eliminates N publisher queries
193+
const allPublishers = await db.select().from(schema.publisher)
194+
const publisherSet = new Set(allPublishers.map((p) => p.id))
195+
196+
// Verify the root publisher exists
197+
if (!publisherSet.has(publisherId)) {
198+
return NextResponse.json(
199+
{ error: 'Publisher not found' },
200+
{ status: 404 },
201+
)
202+
}
203+
204+
// Create batching lookup function
205+
const lookupAgent = createBatchingAgentLookup(publisherSet, logger)
206+
207+
// Find the root agent
208+
const rootAgent = await db
209+
.select()
210+
.from(schema.agentConfig)
211+
.where(
212+
and(
213+
eq(schema.agentConfig.id, agentId),
214+
eq(schema.agentConfig.version, version),
215+
eq(schema.agentConfig.publisher_id, publisherId),
216+
),
217+
)
218+
.then((rows) => rows[0])
219+
220+
if (!rootAgent) {
221+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
222+
}
223+
224+
const rootData =
225+
typeof rootAgent.data === 'string'
226+
? JSON.parse(rootAgent.data)
227+
: rootAgent.data
228+
229+
const spawnableAgents: string[] = Array.isArray(rootData.spawnableAgents)
230+
? rootData.spawnableAgents
231+
: []
232+
233+
if (spawnableAgents.length === 0) {
234+
const emptyTree: AgentTreeData = {
235+
root: {
236+
fullId: `${publisherId}/${agentId}@${version}`,
237+
agentId,
238+
publisher: publisherId,
239+
version,
240+
displayName: rootData.displayName ?? rootData.name ?? agentId,
241+
spawnerPrompt: rootData.spawnerPrompt ?? null,
242+
isAvailable: true,
243+
children: [],
244+
isCyclic: false,
245+
},
246+
totalAgents: 1,
247+
maxDepth: 0,
248+
hasCycles: false,
249+
}
250+
return NextResponse.json(emptyTree)
251+
}
252+
253+
// Build the dependency tree
254+
// The batching lookup will automatically batch all concurrent requests
255+
// from each tree level into single queries
256+
const tree = await buildAgentTree({
257+
rootPublisher: publisherId,
258+
rootAgentId: agentId,
259+
rootVersion: version,
260+
rootDisplayName: rootData.displayName ?? rootData.name ?? agentId,
261+
rootSpawnerPrompt: rootData.spawnerPrompt ?? null,
262+
rootSpawnableAgents: spawnableAgents,
263+
lookupAgent,
264+
maxDepth: 5,
265+
})
266+
267+
return NextResponse.json(tree)
268+
} catch (error) {
269+
logger.error({ error }, 'Error fetching agent dependencies')
270+
return NextResponse.json(
271+
{ error: 'Internal server error' },
272+
{ status: 500 },
273+
)
274+
}
275+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { getDependencies } from './_get'
2+
3+
import type { NextRequest } from 'next/server'
4+
5+
import { logger } from '@/util/logger'
6+
7+
interface RouteParams {
8+
params: Promise<{
9+
publisherId: string
10+
agentId: string
11+
version: string
12+
}>
13+
}
14+
15+
export async function GET(_request: NextRequest, { params }: RouteParams) {
16+
return getDependencies({
17+
params,
18+
logger,
19+
})
20+
}

0 commit comments

Comments
 (0)