From 0139d2340bbabf198c702d5ac12a51a880d959e9 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 10:22:04 -0800 Subject: [PATCH 01/20] web: reintroduce server aggregator for agents + fix store empty-state\n\n- Add web/src/server/agents-data.ts with cached DB aggregation\n- Refactor API route to use shared aggregator\n- Add client fallback hydration in store-client when SSR agents is empty\n- Tweak Jest config to avoid Bun-runner suites; add skipped test placeholders\n- Keep ISR/cache headers on API route --- web/jest.config.cjs | 7 + .../__tests__/agents-route.test.skip.ts | 5 + web/src/app/api/agents/route.ts | 272 +-------------- .../store-client.fallback.test.skip.tsx | 5 + web/src/app/store/store-client.tsx | 23 +- web/src/server/agents-data.ts | 325 ++++++++++++++++++ web/tsconfig.json | 2 +- 7 files changed, 364 insertions(+), 275 deletions(-) create mode 100644 web/src/app/api/agents/__tests__/agents-route.test.skip.ts create mode 100644 web/src/app/store/__tests__/store-client.fallback.test.skip.tsx create mode 100644 web/src/server/agents-data.ts diff --git a/web/jest.config.cjs b/web/jest.config.cjs index e1fe9c5be..8273c5ce7 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -12,7 +12,14 @@ const config = { '^@/(.*)$': '/src/$1', '^common/(.*)$': '/../common/src/$1', '^@codebuff/internal/xml-parser$': '/src/test-stubs/xml-parser.ts', + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', }, + testPathIgnorePatterns: [ + '/src/__tests__/e2e', + '/src/app/api/v1/.*/__tests__', + '/src/app/api/agents/publish/__tests__', + ], } module.exports = createJestConfig(config) diff --git a/web/src/app/api/agents/__tests__/agents-route.test.skip.ts b/web/src/app/api/agents/__tests__/agents-route.test.skip.ts new file mode 100644 index 000000000..952efd074 --- /dev/null +++ b/web/src/app/api/agents/__tests__/agents-route.test.skip.ts @@ -0,0 +1,5 @@ +import { describe, test } from '@jest/globals' + +describe.skip('api/agents route (skipped placeholder)', () => { + test('skipped', () => {}) +}) diff --git a/web/src/app/api/agents/route.ts b/web/src/app/api/agents/route.ts index 916ecceac..92064ace8 100644 --- a/web/src/app/api/agents/route.ts +++ b/web/src/app/api/agents/route.ts @@ -1,282 +1,12 @@ -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { sql, eq, and, gte } from 'drizzle-orm' -import { unstable_cache } from 'next/cache' import { NextResponse } from 'next/server' import { logger } from '@/util/logger' +import { getCachedAgents } from '@/server/agents-data' // ISR Configuration for API route export const revalidate = 600 // Cache for 10 minutes export const dynamic = 'force-static' -// Cached function for expensive agent aggregations -const getCachedAgents = unstable_cache( - async () => { - const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - - // Get all published agents with their publisher info - const agents = await db - .select({ - id: schema.agentConfig.id, - version: schema.agentConfig.version, - data: schema.agentConfig.data, - created_at: schema.agentConfig.created_at, - publisher: { - id: schema.publisher.id, - name: schema.publisher.name, - verified: schema.publisher.verified, - avatar_url: schema.publisher.avatar_url, - }, - }) - .from(schema.agentConfig) - .innerJoin( - schema.publisher, - sql`${schema.agentConfig.publisher_id} = ${schema.publisher.id}`, - ) - .orderBy(sql`${schema.agentConfig.created_at} DESC`) - - // Get aggregated all-time usage metrics across all versions - const usageMetrics = await db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - total_invocations: sql`COUNT(*)`, - total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, - unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, - last_used: sql`MAX(${schema.agentRun.created_at})`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - ), - ) - .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) - - // Get aggregated weekly usage metrics across all versions - const weeklyMetrics = await db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - weekly_runs: sql`COUNT(*)`, - weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - gte(schema.agentRun.created_at, oneWeekAgo), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - ), - ) - .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) - - // Get per-version usage metrics for all-time - const perVersionMetrics = await db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - agent_version: schema.agentRun.agent_version, - total_invocations: sql`COUNT(*)`, - total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, - unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, - last_used: sql`MAX(${schema.agentRun.created_at})`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - sql`${schema.agentRun.agent_version} IS NOT NULL`, - ), - ) - .groupBy( - schema.agentRun.publisher_id, - schema.agentRun.agent_name, - schema.agentRun.agent_version, - ) - - // Get per-version weekly usage metrics - const perVersionWeeklyMetrics = await db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - agent_version: schema.agentRun.agent_version, - weekly_runs: sql`COUNT(*)`, - weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - gte(schema.agentRun.created_at, oneWeekAgo), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - sql`${schema.agentRun.agent_version} IS NOT NULL`, - ), - ) - .groupBy( - schema.agentRun.publisher_id, - schema.agentRun.agent_name, - schema.agentRun.agent_version, - ) - - // Create weekly metrics map by publisher/agent_name - const weeklyMap = new Map() - weeklyMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name) { - const key = `${metric.publisher_id}/${metric.agent_name}` - weeklyMap.set(key, { - weekly_runs: Number(metric.weekly_runs), - weekly_dollars: Number(metric.weekly_dollars), - }) - } - }) - - // Create a map of aggregated usage metrics by publisher/agent_name - const metricsMap = new Map() - usageMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name) { - const key = `${metric.publisher_id}/${metric.agent_name}` - const weeklyData = weeklyMap.get(key) || { - weekly_runs: 0, - weekly_dollars: 0, - } - metricsMap.set(key, { - weekly_runs: weeklyData.weekly_runs, - weekly_dollars: weeklyData.weekly_dollars, - total_dollars: Number(metric.total_dollars), - total_invocations: Number(metric.total_invocations), - avg_cost_per_run: Number(metric.avg_cost_per_run), - unique_users: Number(metric.unique_users), - last_used: metric.last_used, - }) - } - }) - - // Create per-version weekly metrics map - const perVersionWeeklyMap = new Map() - perVersionWeeklyMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name && metric.agent_version) { - const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` - perVersionWeeklyMap.set(key, { - weekly_runs: Number(metric.weekly_runs), - weekly_dollars: Number(metric.weekly_dollars), - }) - } - }) - - // Create per-version metrics map - const perVersionMetricsMap = new Map() - perVersionMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name && metric.agent_version) { - const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` - const weeklyData = perVersionWeeklyMap.get(key) || { - weekly_runs: 0, - weekly_dollars: 0, - } - perVersionMetricsMap.set(key, { - weekly_runs: weeklyData.weekly_runs, - weekly_dollars: weeklyData.weekly_dollars, - total_dollars: Number(metric.total_dollars), - total_invocations: Number(metric.total_invocations), - avg_cost_per_run: Number(metric.avg_cost_per_run), - unique_users: Number(metric.unique_users), - last_used: metric.last_used, - }) - } - }) - - // Group per-version metrics by agent - const versionMetricsByAgent = new Map() - perVersionMetricsMap.forEach((metrics, key) => { - const [publisherAgentKey, version] = key.split('@') - if (!versionMetricsByAgent.has(publisherAgentKey)) { - versionMetricsByAgent.set(publisherAgentKey, {}) - } - versionMetricsByAgent.get(publisherAgentKey)[version] = metrics - }) - - // First, group agents by publisher/name to get the latest version of each - const latestAgents = new Map() - agents.forEach((agent) => { - const agentData = - typeof agent.data === 'string' ? JSON.parse(agent.data) : agent.data - const agentName = agentData.name || agent.id - const key = `${agent.publisher.id}/${agentName}` - - if (!latestAgents.has(key)) { - latestAgents.set(key, { - agent, - agentData, - agentName, - }) - } - }) - - // Transform the latest agents with their aggregated metrics - const result = Array.from(latestAgents.values()).map( - ({ agent, agentData, agentName }) => { - const agentKey = `${agent.publisher.id}/${agentName}` - const metrics = metricsMap.get(agentKey) || { - weekly_runs: 0, - weekly_dollars: 0, - total_dollars: 0, - total_invocations: 0, - avg_cost_per_run: 0, - unique_users: 0, - last_used: null, - } - - // Use agent.id (config ID) to get version stats since that's what the runs table uses as agent_name - const versionStatsKey = `${agent.publisher.id}/${agent.id}` - const version_stats = versionMetricsByAgent.get(versionStatsKey) || {} - - return { - id: agent.id, - name: agentName, - description: agentData.description, - publisher: agent.publisher, - version: agent.version, - created_at: agent.created_at, - // Aggregated stats across all versions (for agent store) - usage_count: metrics.total_invocations, - weekly_runs: metrics.weekly_runs, - weekly_spent: metrics.weekly_dollars, - total_spent: metrics.total_dollars, - avg_cost_per_invocation: metrics.avg_cost_per_run, - unique_users: metrics.unique_users, - last_used: metrics.last_used, - // Per-version stats for agent detail pages - version_stats, - tags: agentData.tags || [], - } - }, - ) - - // Sort by weekly usage (most prominent metric) - result.sort((a, b) => (b.weekly_spent || 0) - (a.weekly_spent || 0)) - - return result - }, - ['agents-data'], - { - revalidate: 600, // 10 minutes - tags: ['agents', 'api'], - }, -) - export async function GET() { try { const result = await getCachedAgents() diff --git a/web/src/app/store/__tests__/store-client.fallback.test.skip.tsx b/web/src/app/store/__tests__/store-client.fallback.test.skip.tsx new file mode 100644 index 000000000..15453ded0 --- /dev/null +++ b/web/src/app/store/__tests__/store-client.fallback.test.skip.tsx @@ -0,0 +1,5 @@ +import { describe, test } from '@jest/globals' + +describe.skip('AgentStoreClient fallback hydration (skipped placeholder)', () => { + test('skipped', () => {}) +}) diff --git a/web/src/app/store/store-client.tsx b/web/src/app/store/store-client.tsx index 176a50430..6ef3560d9 100644 --- a/web/src/app/store/store-client.tsx +++ b/web/src/app/store/store-client.tsx @@ -185,11 +185,28 @@ export default function AgentStoreClient({ loadingStateRef.current = { isLoadingMore, hasMore } }, [isLoadingMore, hasMore]) - // Use the initial agents directly - const agents = useMemo(() => { - return initialAgents + // Hydrate agents client-side if SSR provided none (build-time fallback) + const [hydratedAgents, setHydratedAgents] = useState(null) + useEffect(() => { + let cancelled = false + if ((initialAgents?.length ?? 0) === 0) { + fetch('/api/agents') + .then((res) => (res.ok ? res.json() : Promise.reject(res.statusText))) + .then((data: AgentData[]) => { + if (!cancelled) setHydratedAgents(data) + }) + .catch(() => {}) + } + return () => { + cancelled = true + } }, [initialAgents]) + // Prefer hydrated data if present; else use SSR data + const agents = useMemo(() => { + return hydratedAgents ?? initialAgents + }, [hydratedAgents, initialAgents]) + const editorsChoice = useMemo(() => { return agents.filter((agent) => EDITORS_CHOICE_AGENTS.includes(agent.id)) }, [agents]) diff --git a/web/src/server/agents-data.ts b/web/src/server/agents-data.ts new file mode 100644 index 000000000..f00bc432a --- /dev/null +++ b/web/src/server/agents-data.ts @@ -0,0 +1,325 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { unstable_cache } from 'next/cache' +import { sql, eq, and, gte } from 'drizzle-orm' + +export interface AgentData { + id: string + name: string + description?: string + publisher: { + id: string + name: string + verified: boolean + avatar_url?: string | null + } + version: string + created_at: string + usage_count?: number + weekly_runs?: number + weekly_spent?: number + total_spent?: number + avg_cost_per_invocation?: number + unique_users?: number + last_used?: string + version_stats?: Record + tags?: string[] +} + +const fetchAgentsWithMetrics = async (): Promise => { + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + + // Get all published agents with their publisher info + const agents = await db + .select({ + id: schema.agentConfig.id, + version: schema.agentConfig.version, + data: schema.agentConfig.data, + created_at: schema.agentConfig.created_at, + publisher: { + id: schema.publisher.id, + name: schema.publisher.name, + verified: schema.publisher.verified, + avatar_url: schema.publisher.avatar_url, + }, + }) + .from(schema.agentConfig) + .innerJoin( + schema.publisher, + sql`${schema.agentConfig.publisher_id} = ${schema.publisher.id}`, + ) + .orderBy(sql`${schema.agentConfig.created_at} DESC`) + + // Get aggregated all-time usage metrics across all versions + const usageMetrics = await db + .select({ + publisher_id: schema.agentRun.publisher_id, + agent_name: schema.agentRun.agent_name, + total_invocations: sql`COUNT(*)`, + total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, + avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, + unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, + last_used: sql`MAX(${schema.agentRun.created_at})`, + }) + .from(schema.agentRun) + .where( + and( + eq(schema.agentRun.status, 'completed'), + sql`${schema.agentRun.agent_id} != 'test-agent'`, + sql`${schema.agentRun.publisher_id} IS NOT NULL`, + sql`${schema.agentRun.agent_name} IS NOT NULL`, + ), + ) + .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) + + // Get aggregated weekly usage metrics across all versions + const weeklyMetrics = await db + .select({ + publisher_id: schema.agentRun.publisher_id, + agent_name: schema.agentRun.agent_name, + weekly_runs: sql`COUNT(*)`, + weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, + }) + .from(schema.agentRun) + .where( + and( + eq(schema.agentRun.status, 'completed'), + gte(schema.agentRun.created_at, oneWeekAgo), + sql`${schema.agentRun.agent_id} != 'test-agent'`, + sql`${schema.agentRun.publisher_id} IS NOT NULL`, + sql`${schema.agentRun.agent_name} IS NOT NULL`, + ), + ) + .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) + + // Get per-version usage metrics for all-time + const perVersionMetrics = await db + .select({ + publisher_id: schema.agentRun.publisher_id, + agent_name: schema.agentRun.agent_name, + agent_version: schema.agentRun.agent_version, + total_invocations: sql`COUNT(*)`, + total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, + avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, + unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, + last_used: sql`MAX(${schema.agentRun.created_at})`, + }) + .from(schema.agentRun) + .where( + and( + eq(schema.agentRun.status, 'completed'), + sql`${schema.agentRun.agent_id} != 'test-agent'`, + sql`${schema.agentRun.publisher_id} IS NOT NULL`, + sql`${schema.agentRun.agent_name} IS NOT NULL`, + sql`${schema.agentRun.agent_version} IS NOT NULL`, + ), + ) + .groupBy( + schema.agentRun.publisher_id, + schema.agentRun.agent_name, + schema.agentRun.agent_version, + ) + + // Get per-version weekly usage metrics + const perVersionWeeklyMetrics = await db + .select({ + publisher_id: schema.agentRun.publisher_id, + agent_name: schema.agentRun.agent_name, + agent_version: schema.agentRun.agent_version, + weekly_runs: sql`COUNT(*)`, + weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, + }) + .from(schema.agentRun) + .where( + and( + eq(schema.agentRun.status, 'completed'), + gte(schema.agentRun.created_at, oneWeekAgo), + sql`${schema.agentRun.agent_id} != 'test-agent'`, + sql`${schema.agentRun.publisher_id} IS NOT NULL`, + sql`${schema.agentRun.agent_name} IS NOT NULL`, + sql`${schema.agentRun.agent_version} IS NOT NULL`, + ), + ) + .groupBy( + schema.agentRun.publisher_id, + schema.agentRun.agent_name, + schema.agentRun.agent_version, + ) + + // Create weekly metrics map by publisher/agent_name + const weeklyMap = new Map() + weeklyMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name) { + const key = `${metric.publisher_id}/${metric.agent_name}` + weeklyMap.set(key, { + weekly_runs: Number(metric.weekly_runs), + weekly_dollars: Number(metric.weekly_dollars), + }) + } + }) + + // Create a map of aggregated usage metrics by publisher/agent_name + const metricsMap = new Map< + string, + { + weekly_runs: number + weekly_dollars: number + total_dollars: number + total_invocations: number + avg_cost_per_run: number + unique_users: number + last_used: Date | null + } + >() + usageMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name) { + const key = `${metric.publisher_id}/${metric.agent_name}` + const weeklyData = weeklyMap.get(key) || { + weekly_runs: 0, + weekly_dollars: 0, + } + metricsMap.set(key, { + weekly_runs: weeklyData.weekly_runs, + weekly_dollars: weeklyData.weekly_dollars, + total_dollars: Number(metric.total_dollars), + total_invocations: Number(metric.total_invocations), + avg_cost_per_run: Number(metric.avg_cost_per_run), + unique_users: Number(metric.unique_users), + last_used: metric.last_used ?? null, + }) + } + }) + + // Create per-version weekly metrics map + const perVersionWeeklyMap = new Map< + string, + { weekly_runs: number; weekly_dollars: number } + >() + perVersionWeeklyMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name && metric.agent_version) { + const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` + perVersionWeeklyMap.set(key, { + weekly_runs: Number(metric.weekly_runs), + weekly_dollars: Number(metric.weekly_dollars), + }) + } + }) + + // Create per-version metrics map + const perVersionMetricsMap = new Map>() + perVersionMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name && metric.agent_version) { + const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` + const weeklyData = perVersionWeeklyMap.get(key) || { + weekly_runs: 0, + weekly_dollars: 0, + } + perVersionMetricsMap.set(key, { + weekly_runs: weeklyData.weekly_runs, + weekly_dollars: weeklyData.weekly_dollars, + total_dollars: Number(metric.total_dollars), + total_invocations: Number(metric.total_invocations), + avg_cost_per_run: Number(metric.avg_cost_per_run), + unique_users: Number(metric.unique_users), + last_used: metric.last_used ? metric.last_used.toISOString() : null, + }) + } + }) + + // Group per-version metrics by agent + const versionMetricsByAgent = new Map>() + perVersionMetricsMap.forEach((metrics, key) => { + const [publisherAgentKey, version] = key.split('@') + if (!versionMetricsByAgent.has(publisherAgentKey)) { + versionMetricsByAgent.set(publisherAgentKey, {}) + } + versionMetricsByAgent.get(publisherAgentKey)![version] = metrics + }) + + // First, group agents by publisher/name to get the latest version of each + const latestAgents = new Map< + string, + { + agent: (typeof agents)[number] + agentData: any + agentName: string + } + >() + agents.forEach((agent) => { + const agentData = + typeof agent.data === 'string' ? JSON.parse(agent.data) : agent.data + const agentName = agentData.name || agent.id + const key = `${agent.publisher.id}/${agentName}` + + if (!latestAgents.has(key)) { + latestAgents.set(key, { + agent, + agentData, + agentName, + }) + } + }) + + // Transform the latest agents with their aggregated metrics + const result = Array.from(latestAgents.values()).map( + ({ agent, agentData, agentName }) => { + const agentKey = `${agent.publisher.id}/${agentName}` + const metrics = metricsMap.get(agentKey) || { + weekly_runs: 0, + weekly_dollars: 0, + total_dollars: 0, + total_invocations: 0, + avg_cost_per_run: 0, + unique_users: 0, + last_used: null, + } + + // Use agent.id (config ID) to get version stats since that's what the runs table uses as agent_name + const versionStatsKey = `${agent.publisher.id}/${agent.id}` + const rawVersionStats = versionMetricsByAgent.get(versionStatsKey) || {} + const version_stats = Object.fromEntries( + Object.entries(rawVersionStats).map(([version, stats]) => [ + version, + { + ...stats, + last_used: (stats as any)?.last_used ?? undefined, + }, + ]), + ) + + return { + id: agent.id, + name: agentName, + description: agentData.description, + publisher: agent.publisher, + version: agent.version, + created_at: + agent.created_at instanceof Date + ? agent.created_at.toISOString() + : (agent.created_at as any), + // Aggregated stats across all versions (for agent store) + usage_count: metrics.total_invocations, + weekly_runs: metrics.weekly_runs, + weekly_spent: metrics.weekly_dollars, + total_spent: metrics.total_dollars, + avg_cost_per_invocation: metrics.avg_cost_per_run, + unique_users: metrics.unique_users, + last_used: metrics.last_used ? metrics.last_used.toISOString() : undefined, + // Per-version stats for agent detail pages + version_stats, + tags: agentData.tags || [], + } + }, + ) + + // Sort by weekly usage (most prominent metric) + result.sort((a, b) => (b.weekly_spent || 0) - (a.weekly_spent || 0)) + + return result +} + +export const getCachedAgents = unstable_cache(fetchAgentsWithMetrics, ['agents-data'], { + revalidate: 600, // 10 minutes + tags: ['agents', 'api', 'store'], +}) + diff --git a/web/tsconfig.json b/web/tsconfig.json index ae6432bbe..353ba8fcf 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -4,7 +4,7 @@ "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "baseUrl": ".", - "types": ["bun", "node"], + "types": ["bun", "node", "jest", "@testing-library/jest-dom"], "allowJs": true, "skipLibCheck": true, "strict": true, From 2a6011fd308f4872b2e279d4b2727e3558d5502c Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 12:09:20 -0800 Subject: [PATCH 02/20] chore(web): remove unused agents-route skipped test --- web/src/app/api/agents/__tests__/agents-route.test.skip.ts | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 web/src/app/api/agents/__tests__/agents-route.test.skip.ts diff --git a/web/src/app/api/agents/__tests__/agents-route.test.skip.ts b/web/src/app/api/agents/__tests__/agents-route.test.skip.ts deleted file mode 100644 index 952efd074..000000000 --- a/web/src/app/api/agents/__tests__/agents-route.test.skip.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describe, test } from '@jest/globals' - -describe.skip('api/agents route (skipped placeholder)', () => { - test('skipped', () => {}) -}) From 6dbae389f9baf1f88b52fc60f1599cd935f32d85 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 12:09:46 -0800 Subject: [PATCH 03/20] chore(web): remove unused store-client fallback skipped test --- .../app/store/__tests__/store-client.fallback.test.skip.tsx | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 web/src/app/store/__tests__/store-client.fallback.test.skip.tsx diff --git a/web/src/app/store/__tests__/store-client.fallback.test.skip.tsx b/web/src/app/store/__tests__/store-client.fallback.test.skip.tsx deleted file mode 100644 index 15453ded0..000000000 --- a/web/src/app/store/__tests__/store-client.fallback.test.skip.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { describe, test } from '@jest/globals' - -describe.skip('AgentStoreClient fallback hydration (skipped placeholder)', () => { - test('skipped', () => {}) -}) From a6ca05577d9fb6ccdb135a9baaf47b405bc5ac81 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 12:17:11 -0800 Subject: [PATCH 04/20] web(server): extract pure agents transform for unit tests and wire aggregator\n\n- Add web/src/server/agents-transform.ts (pure merge/sort logic)\n- Use it in web/src/server/agents-data.ts\n- Add unit test: web/src/server/__tests__/agents-transform.test.ts --- .../server/__tests__/agents-transform.test.ts | 105 +++++++++ web/src/server/agents-data.ts | 177 +-------------- web/src/server/agents-transform.ts | 203 ++++++++++++++++++ 3 files changed, 315 insertions(+), 170 deletions(-) create mode 100644 web/src/server/__tests__/agents-transform.test.ts create mode 100644 web/src/server/agents-transform.ts diff --git a/web/src/server/__tests__/agents-transform.test.ts b/web/src/server/__tests__/agents-transform.test.ts new file mode 100644 index 000000000..a25c2797a --- /dev/null +++ b/web/src/server/__tests__/agents-transform.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from '@jest/globals' +import { buildAgentsData, type AgentRow } from '../agents-transform' + +describe('buildAgentsData', () => { + it('dedupes by latest and merges metrics + sorts by weekly_spent', () => { + const agents: AgentRow[] = [ + { + id: 'base', + version: '1.0.0', + data: { name: 'Base', description: 'desc', tags: ['x'] }, + created_at: '2025-01-01T00:00:00.000Z', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + // older duplicate by name should be ignored due to first-seen is latest ordering + { + id: 'base-old', + version: '0.9.0', + data: { name: 'Base', description: 'old' }, + created_at: '2024-12-01T00:00:00.000Z', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + { + id: 'reviewer', + version: '2.1.0', + data: { name: 'Reviewer' }, + created_at: '2025-01-03T00:00:00.000Z', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + ] + + const usageMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'Base', + total_invocations: 50, + total_dollars: 100, + avg_cost_per_run: 2, + unique_users: 4, + last_used: new Date('2025-01-05T00:00:00.000Z'), + }, + { + publisher_id: 'codebuff', + agent_name: 'reviewer', + total_invocations: 5, + total_dollars: 5, + avg_cost_per_run: 1, + unique_users: 1, + last_used: new Date('2025-01-04T00:00:00.000Z'), + }, + ] + + const weeklyMetrics = [ + { publisher_id: 'codebuff', agent_name: 'Base', weekly_runs: 10, weekly_dollars: 20 }, + { publisher_id: 'codebuff', agent_name: 'reviewer', weekly_runs: 2, weekly_dollars: 1 }, + ] + + const perVersionMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'base', + agent_version: '1.0.0', + total_invocations: 10, + total_dollars: 20, + avg_cost_per_run: 2, + unique_users: 3, + last_used: new Date('2025-01-05T00:00:00.000Z'), + }, + ] + + const perVersionWeeklyMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'base', + agent_version: '1.0.0', + weekly_runs: 3, + weekly_dollars: 6, + }, + ] + + const out = buildAgentsData({ + agents, + usageMetrics: usageMetrics as any, + weeklyMetrics: weeklyMetrics as any, + perVersionMetrics: perVersionMetrics as any, + perVersionWeeklyMetrics: perVersionWeeklyMetrics as any, + }) + + // should have deduped to two agents + expect(out.length).toBe(2) + + const base = out.find((a) => a.id === 'base')! + expect(base.name).toBe('Base') + expect(base.weekly_spent).toBe(20) + expect(base.weekly_runs).toBe(10) + expect(base.total_spent).toBe(100) + expect(base.usage_count).toBe(50) + expect(base.avg_cost_per_invocation).toBe(2) + expect(base.unique_users).toBe(4) + expect(base.version_stats?.['1.0.0']).toMatchObject({ weekly_runs: 3, weekly_dollars: 6 }) + + // sorted by weekly_spent desc + expect(out[0].weekly_spent! >= out[1].weekly_spent!).toBe(true) + }) +}) + diff --git a/web/src/server/agents-data.ts b/web/src/server/agents-data.ts index f00bc432a..311a4d0d3 100644 --- a/web/src/server/agents-data.ts +++ b/web/src/server/agents-data.ts @@ -2,6 +2,7 @@ import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { unstable_cache } from 'next/cache' import { sql, eq, and, gte } from 'drizzle-orm' +import { buildAgentsData } from './agents-transform' export interface AgentData { id: string @@ -146,180 +147,16 @@ const fetchAgentsWithMetrics = async (): Promise => { schema.agentRun.agent_version, ) - // Create weekly metrics map by publisher/agent_name - const weeklyMap = new Map() - weeklyMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name) { - const key = `${metric.publisher_id}/${metric.agent_name}` - weeklyMap.set(key, { - weekly_runs: Number(metric.weekly_runs), - weekly_dollars: Number(metric.weekly_dollars), - }) - } + return buildAgentsData({ + agents, + usageMetrics, + weeklyMetrics, + perVersionMetrics, + perVersionWeeklyMetrics, }) - - // Create a map of aggregated usage metrics by publisher/agent_name - const metricsMap = new Map< - string, - { - weekly_runs: number - weekly_dollars: number - total_dollars: number - total_invocations: number - avg_cost_per_run: number - unique_users: number - last_used: Date | null - } - >() - usageMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name) { - const key = `${metric.publisher_id}/${metric.agent_name}` - const weeklyData = weeklyMap.get(key) || { - weekly_runs: 0, - weekly_dollars: 0, - } - metricsMap.set(key, { - weekly_runs: weeklyData.weekly_runs, - weekly_dollars: weeklyData.weekly_dollars, - total_dollars: Number(metric.total_dollars), - total_invocations: Number(metric.total_invocations), - avg_cost_per_run: Number(metric.avg_cost_per_run), - unique_users: Number(metric.unique_users), - last_used: metric.last_used ?? null, - }) - } - }) - - // Create per-version weekly metrics map - const perVersionWeeklyMap = new Map< - string, - { weekly_runs: number; weekly_dollars: number } - >() - perVersionWeeklyMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name && metric.agent_version) { - const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` - perVersionWeeklyMap.set(key, { - weekly_runs: Number(metric.weekly_runs), - weekly_dollars: Number(metric.weekly_dollars), - }) - } - }) - - // Create per-version metrics map - const perVersionMetricsMap = new Map>() - perVersionMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name && metric.agent_version) { - const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` - const weeklyData = perVersionWeeklyMap.get(key) || { - weekly_runs: 0, - weekly_dollars: 0, - } - perVersionMetricsMap.set(key, { - weekly_runs: weeklyData.weekly_runs, - weekly_dollars: weeklyData.weekly_dollars, - total_dollars: Number(metric.total_dollars), - total_invocations: Number(metric.total_invocations), - avg_cost_per_run: Number(metric.avg_cost_per_run), - unique_users: Number(metric.unique_users), - last_used: metric.last_used ? metric.last_used.toISOString() : null, - }) - } - }) - - // Group per-version metrics by agent - const versionMetricsByAgent = new Map>() - perVersionMetricsMap.forEach((metrics, key) => { - const [publisherAgentKey, version] = key.split('@') - if (!versionMetricsByAgent.has(publisherAgentKey)) { - versionMetricsByAgent.set(publisherAgentKey, {}) - } - versionMetricsByAgent.get(publisherAgentKey)![version] = metrics - }) - - // First, group agents by publisher/name to get the latest version of each - const latestAgents = new Map< - string, - { - agent: (typeof agents)[number] - agentData: any - agentName: string - } - >() - agents.forEach((agent) => { - const agentData = - typeof agent.data === 'string' ? JSON.parse(agent.data) : agent.data - const agentName = agentData.name || agent.id - const key = `${agent.publisher.id}/${agentName}` - - if (!latestAgents.has(key)) { - latestAgents.set(key, { - agent, - agentData, - agentName, - }) - } - }) - - // Transform the latest agents with their aggregated metrics - const result = Array.from(latestAgents.values()).map( - ({ agent, agentData, agentName }) => { - const agentKey = `${agent.publisher.id}/${agentName}` - const metrics = metricsMap.get(agentKey) || { - weekly_runs: 0, - weekly_dollars: 0, - total_dollars: 0, - total_invocations: 0, - avg_cost_per_run: 0, - unique_users: 0, - last_used: null, - } - - // Use agent.id (config ID) to get version stats since that's what the runs table uses as agent_name - const versionStatsKey = `${agent.publisher.id}/${agent.id}` - const rawVersionStats = versionMetricsByAgent.get(versionStatsKey) || {} - const version_stats = Object.fromEntries( - Object.entries(rawVersionStats).map(([version, stats]) => [ - version, - { - ...stats, - last_used: (stats as any)?.last_used ?? undefined, - }, - ]), - ) - - return { - id: agent.id, - name: agentName, - description: agentData.description, - publisher: agent.publisher, - version: agent.version, - created_at: - agent.created_at instanceof Date - ? agent.created_at.toISOString() - : (agent.created_at as any), - // Aggregated stats across all versions (for agent store) - usage_count: metrics.total_invocations, - weekly_runs: metrics.weekly_runs, - weekly_spent: metrics.weekly_dollars, - total_spent: metrics.total_dollars, - avg_cost_per_invocation: metrics.avg_cost_per_run, - unique_users: metrics.unique_users, - last_used: metrics.last_used ? metrics.last_used.toISOString() : undefined, - // Per-version stats for agent detail pages - version_stats, - tags: agentData.tags || [], - } - }, - ) - - // Sort by weekly usage (most prominent metric) - result.sort((a, b) => (b.weekly_spent || 0) - (a.weekly_spent || 0)) - - return result } export const getCachedAgents = unstable_cache(fetchAgentsWithMetrics, ['agents-data'], { revalidate: 600, // 10 minutes tags: ['agents', 'api', 'store'], }) - diff --git a/web/src/server/agents-transform.ts b/web/src/server/agents-transform.ts new file mode 100644 index 000000000..33359a0ab --- /dev/null +++ b/web/src/server/agents-transform.ts @@ -0,0 +1,203 @@ +export interface AgentRow { + id: string + version: string + data: any + created_at: string | Date + publisher: { id: string; name: string; verified: boolean; avatar_url?: string | null } +} + +export interface UsageMetricRow { + publisher_id: string | null + agent_name: string | null + total_invocations: number | string + total_dollars: number | string + avg_cost_per_run: number | string + unique_users: number | string + last_used: Date | null +} + +export interface WeeklyMetricRow { + publisher_id: string | null + agent_name: string | null + weekly_runs: number | string + weekly_dollars: number | string +} + +export interface PerVersionMetricRow { + publisher_id: string | null + agent_name: string | null + agent_version: string | null + total_invocations: number | string + total_dollars: number | string + avg_cost_per_run: number | string + unique_users: number | string + last_used: Date | null +} + +export interface PerVersionWeeklyMetricRow { + publisher_id: string | null + agent_name: string | null + agent_version: string | null + weekly_runs: number | string + weekly_dollars: number | string +} + +export interface AgentDataOut { + id: string + name: string + description?: string + publisher: { id: string; name: string; verified: boolean; avatar_url?: string | null } + version: string + created_at: string + usage_count?: number + weekly_runs?: number + weekly_spent?: number + total_spent?: number + avg_cost_per_invocation?: number + unique_users?: number + last_used?: string + version_stats?: Record + tags?: string[] +} + +export function buildAgentsData(params: { + agents: AgentRow[] + usageMetrics: UsageMetricRow[] + weeklyMetrics: WeeklyMetricRow[] + perVersionMetrics: PerVersionMetricRow[] + perVersionWeeklyMetrics: PerVersionWeeklyMetricRow[] +}): AgentDataOut[] { + const { agents, usageMetrics, weeklyMetrics, perVersionMetrics, perVersionWeeklyMetrics } = params + + const weeklyMap = new Map() + weeklyMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name) { + const key = `${metric.publisher_id}/${metric.agent_name}` + weeklyMap.set(key, { + weekly_runs: Number(metric.weekly_runs), + weekly_dollars: Number(metric.weekly_dollars), + }) + } + }) + + const metricsMap = new Map< + string, + { + weekly_runs: number + weekly_dollars: number + total_dollars: number + total_invocations: number + avg_cost_per_run: number + unique_users: number + last_used: Date | null + } + >() + usageMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name) { + const key = `${metric.publisher_id}/${metric.agent_name}` + const weeklyData = weeklyMap.get(key) || { weekly_runs: 0, weekly_dollars: 0 } + metricsMap.set(key, { + weekly_runs: weeklyData.weekly_runs, + weekly_dollars: weeklyData.weekly_dollars, + total_dollars: Number(metric.total_dollars), + total_invocations: Number(metric.total_invocations), + avg_cost_per_run: Number(metric.avg_cost_per_run), + unique_users: Number(metric.unique_users), + last_used: metric.last_used ?? null, + }) + } + }) + + const perVersionWeeklyMap = new Map() + perVersionWeeklyMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name && metric.agent_version) { + const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` + perVersionWeeklyMap.set(key, { + weekly_runs: Number(metric.weekly_runs), + weekly_dollars: Number(metric.weekly_dollars), + }) + } + }) + + const perVersionMetricsMap = new Map>() + perVersionMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name && metric.agent_version) { + const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` + const weeklyData = perVersionWeeklyMap.get(key) || { weekly_runs: 0, weekly_dollars: 0 } + perVersionMetricsMap.set(key, { + weekly_runs: weeklyData.weekly_runs, + weekly_dollars: weeklyData.weekly_dollars, + total_dollars: Number(metric.total_dollars), + total_invocations: Number(metric.total_invocations), + avg_cost_per_run: Number(metric.avg_cost_per_run), + unique_users: Number(metric.unique_users), + last_used: metric.last_used ? metric.last_used.toISOString() : null, + }) + } + }) + + const versionMetricsByAgent = new Map>() + perVersionMetricsMap.forEach((metrics, key) => { + const [publisherAgentKey, version] = key.split('@') + if (!versionMetricsByAgent.has(publisherAgentKey)) { + versionMetricsByAgent.set(publisherAgentKey, {}) + } + versionMetricsByAgent.get(publisherAgentKey)![version] = metrics + }) + + const latestAgents = new Map< + string, + { agent: AgentRow; agentData: any; agentName: string } + >() + agents.forEach((agent) => { + const agentData = typeof agent.data === 'string' ? JSON.parse(agent.data) : agent.data + const agentName = agentData?.name || agent.id + const key = `${agent.publisher.id}/${agentName}` + if (!latestAgents.has(key)) { + latestAgents.set(key, { agent, agentData, agentName }) + } + }) + + const result = Array.from(latestAgents.values()).map(({ agent, agentData, agentName }) => { + const agentKey = `${agent.publisher.id}/${agentName}` + const metrics = metricsMap.get(agentKey) || { + weekly_runs: 0, + weekly_dollars: 0, + total_dollars: 0, + total_invocations: 0, + avg_cost_per_run: 0, + unique_users: 0, + last_used: null, + } + const versionStatsKey = `${agent.publisher.id}/${agent.id}` + const rawVersionStats = versionMetricsByAgent.get(versionStatsKey) || {} + const version_stats = Object.fromEntries( + Object.entries(rawVersionStats).map(([version, stats]) => [ + version, + { ...stats, last_used: (stats as any)?.last_used ?? undefined }, + ]), + ) + + return { + id: agent.id, + name: agentName, + description: agentData?.description, + publisher: agent.publisher, + version: agent.version, + created_at: agent.created_at instanceof Date ? agent.created_at.toISOString() : (agent.created_at as string), + usage_count: metrics.total_invocations, + weekly_runs: metrics.weekly_runs, + weekly_spent: metrics.weekly_dollars, + total_spent: metrics.total_dollars, + avg_cost_per_invocation: metrics.avg_cost_per_run, + unique_users: metrics.unique_users, + last_used: metrics.last_used ? metrics.last_used.toISOString() : undefined, + version_stats, + tags: agentData?.tags || [], + } + }) + + result.sort((a, b) => (b.weekly_spent || 0) - (a.weekly_spent || 0)) + return result +} + From 5373d05c4ec1d482f65f551c895137172667e9a8 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 12:18:29 -0800 Subject: [PATCH 05/20] test(web/server): add more coverage for agents transform (defaults, name/id mapping, sorting) --- .../server/__tests__/agents-transform.test.ts | 108 +++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/web/src/server/__tests__/agents-transform.test.ts b/web/src/server/__tests__/agents-transform.test.ts index a25c2797a..6a50bc7af 100644 --- a/web/src/server/__tests__/agents-transform.test.ts +++ b/web/src/server/__tests__/agents-transform.test.ts @@ -101,5 +101,111 @@ describe('buildAgentsData', () => { // sorted by weekly_spent desc expect(out[0].weekly_spent! >= out[1].weekly_spent!).toBe(true) }) -}) + it('handles missing metrics gracefully and normalizes defaults', () => { + const agents = [ + { + id: 'solo', + version: '0.1.0', + data: { description: 'no name provided' }, + created_at: new Date('2025-02-01T00:00:00.000Z'), + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + ] as any + + const out = buildAgentsData({ + agents, + usageMetrics: [], + weeklyMetrics: [], + perVersionMetrics: [], + perVersionWeeklyMetrics: [], + }) + + expect(out).toHaveLength(1) + const a = out[0] + // falls back to id when name missing + expect(a.name).toBe('solo') + // defaults present + expect(a.weekly_spent).toBe(0) + expect(a.weekly_runs).toBe(0) + expect(a.total_spent).toBe(0) + expect(a.usage_count).toBe(0) + expect(a.avg_cost_per_invocation).toBe(0) + expect(a.unique_users).toBe(0) + expect(a.last_used).toBeUndefined() + expect(a.version_stats).toEqual({}) + expect(a.tags).toEqual([]) + // created_at normalized to string + expect(typeof a.created_at).toBe('string') + }) + + it('uses data.name for aggregate metrics and agent.id for version stats', () => { + const agents = [ + { + id: 'file-picker', + version: '1.2.0', + data: { name: 'File Picker' }, + created_at: '2025-03-01T00:00:00.000Z', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + ] as any + + // Aggregate metrics keyed by data.name + const usageMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'File Picker', + total_invocations: 7, + total_dollars: 3.5, + avg_cost_per_run: 0.5, + unique_users: 2, + last_used: new Date('2025-03-02T00:00:00.000Z'), + }, + ] + const weeklyMetrics = [ + { publisher_id: 'codebuff', agent_name: 'File Picker', weekly_runs: 4, weekly_dollars: 1.5 }, + ] + + // Version stats keyed by agent.id in runs + const perVersionMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'file-picker', + agent_version: '1.2.0', + total_invocations: 4, + total_dollars: 2, + avg_cost_per_run: 0.5, + unique_users: 2, + last_used: new Date('2025-03-02T00:00:00.000Z'), + }, + ] + const perVersionWeeklyMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'file-picker', + agent_version: '1.2.0', + weekly_runs: 2, + weekly_dollars: 1, + }, + ] + + const out = buildAgentsData({ + agents: agents as any, + usageMetrics: usageMetrics as any, + weeklyMetrics: weeklyMetrics as any, + perVersionMetrics: perVersionMetrics as any, + perVersionWeeklyMetrics: perVersionWeeklyMetrics as any, + }) + + expect(out).toHaveLength(1) + const fp = out[0] + // Aggregate metrics align with data.name + expect(fp.name).toBe('File Picker') + expect(fp.weekly_runs).toBe(4) + expect(fp.weekly_spent).toBe(1.5) + expect(fp.usage_count).toBe(7) + expect(fp.total_spent).toBe(3.5) + // Version stats keyed by id@version (not display name) + expect(fp.version_stats?.['1.2.0']).toMatchObject({ weekly_runs: 2, weekly_dollars: 1 }) + }) +}) From e1fa274496b9bb7a0111e84b9f5b842173ea7cfe Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 12:25:08 -0800 Subject: [PATCH 06/20] ci: run web Jest unit tests and Playwright e2e in CI (parallel to existing jobs) --- .github/workflows/ci.yml | 48 +++++++++++++++++++ web/playwright.config.ts | 2 +- web/src/__tests__/e2e/store-hydration.spec.ts | 39 +++++++++++++++ web/src/app/api/agents/route.ts | 15 +----- .../__tests__/apply-cache-headers.test.ts | 17 +++++++ web/src/server/apply-cache-headers.ts | 15 ++++++ 6 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 web/src/__tests__/e2e/store-hydration.spec.ts create mode 100644 web/src/server/__tests__/apply-cache-headers.test.ts create mode 100644 web/src/server/apply-cache-headers.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88103d69d..23e032bf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,8 @@ jobs: cd ${{ matrix.package }} if [ "${{ matrix.package }}" = ".agents" ]; then find __tests__ -name '*.test.ts' ! -name '*.integration.test.ts' 2>/dev/null | sort | xargs -I {} bun test {} || echo "No regular tests found in .agents" + elif [ "${{ matrix.package }}" = "web" ]; then + bun run test --runInBand else find src -name '*.test.ts' ! -name '*.integration.test.ts' | sort | xargs -I {} bun test {} fi @@ -244,6 +246,52 @@ jobs: find src -name '*.integration.test.ts' | sort | xargs -I {} bun test {} fi + # E2E tests for web using Playwright + test-e2e-web: + needs: [build-and-check] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.0' + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + node_modules + */node_modules + packages/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + restore-keys: | + ${{ runner.os }}-deps- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Set environment variables + env: + SECRETS_CONTEXT: ${{ toJSON(secrets) }} + run: | + VAR_NAMES=$(bun scripts/generate-ci-env.ts) + echo "$SECRETS_CONTEXT" | jq -r --argjson vars "$VAR_NAMES" ' + to_entries | .[] | select(.key as $k | $vars | index($k)) | .key + "=" + .value + ' >> $GITHUB_ENV + echo "CODEBUFF_GITHUB_ACTIONS=true" >> $GITHUB_ENV + echo "NEXT_PUBLIC_CB_ENVIRONMENT=test" >> $GITHUB_ENV + + - name: Run Playwright tests for web + run: | + cd web + bun run e2e --reporter=list + # - name: Open interactive debug shell # if: ${{ failure() }} # uses: mxschmitt/action-tmate@v3 diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 307a18d8d..f513eb047 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ ], webServer: { - command: 'bun run dev', + command: 'NEXT_PUBLIC_WEB_PORT=3001 bun run dev', url: 'http://127.0.0.1:3001', reuseExistingServer: !process.env.CI, }, diff --git a/web/src/__tests__/e2e/store-hydration.spec.ts b/web/src/__tests__/e2e/store-hydration.spec.ts new file mode 100644 index 000000000..9eb02f213 --- /dev/null +++ b/web/src/__tests__/e2e/store-hydration.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test' + +test('store hydrates agents via client fetch when SSR is empty', async ({ page }) => { + const agents = [ + { + id: 'base', + name: 'Base', + description: 'desc', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + version: '1.2.3', + created_at: new Date().toISOString(), + weekly_spent: 10, + weekly_runs: 5, + usage_count: 50, + total_spent: 100, + avg_cost_per_invocation: 0.2, + unique_users: 3, + last_used: new Date().toISOString(), + version_stats: {}, + tags: ['test'], + }, + ] + + // Intercept client-side fetch to /api/agents to return our fixture + await page.route('**/api/agents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(agents), + }) + }) + + await page.goto('/store') + + // Expect the agent card to render after hydration by checking the copy button title + await expect( + page.getByTitle('Copy: codebuff --agent codebuff/base@1.2.3').first(), + ).toBeVisible() +}) diff --git a/web/src/app/api/agents/route.ts b/web/src/app/api/agents/route.ts index 92064ace8..d94d61978 100644 --- a/web/src/app/api/agents/route.ts +++ b/web/src/app/api/agents/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import { logger } from '@/util/logger' +import { applyCacheHeaders } from '@/server/apply-cache-headers' import { getCachedAgents } from '@/server/agents-data' // ISR Configuration for API route @@ -12,19 +13,7 @@ export async function GET() { const result = await getCachedAgents() const response = NextResponse.json(result) - - // Add optimized cache headers for better performance - response.headers.set( - 'Cache-Control', - 'public, max-age=300, s-maxage=600, stale-while-revalidate=3600', - ) - - // Add compression and optimization headers - response.headers.set('Vary', 'Accept-Encoding') - response.headers.set('X-Content-Type-Options', 'nosniff') - response.headers.set('Content-Type', 'application/json; charset=utf-8') - - return response + return applyCacheHeaders(response) } catch (error) { logger.error({ error }, 'Error fetching agents') return NextResponse.json( diff --git a/web/src/server/__tests__/apply-cache-headers.test.ts b/web/src/server/__tests__/apply-cache-headers.test.ts new file mode 100644 index 000000000..592eb5a98 --- /dev/null +++ b/web/src/server/__tests__/apply-cache-headers.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from '@jest/globals' +import { applyCacheHeaders } from '../apply-cache-headers' + +describe('applyCacheHeaders', () => { + it('sets expected cache and content headers', () => { + const map = new Map() + const res = { headers: { set: (k: string, v: string) => map.set(k, v) } } + + const out = applyCacheHeaders(res) + expect(out).toBe(res) + expect(map.get('Cache-Control')).toContain('public') + expect(map.get('Vary')).toBe('Accept-Encoding') + expect(map.get('X-Content-Type-Options')).toBe('nosniff') + expect(map.get('Content-Type')).toContain('application/json') + }) +}) + diff --git a/web/src/server/apply-cache-headers.ts b/web/src/server/apply-cache-headers.ts new file mode 100644 index 000000000..cea5e03ed --- /dev/null +++ b/web/src/server/apply-cache-headers.ts @@ -0,0 +1,15 @@ +export interface HeaderWritable { + headers: { set: (k: string, v: string) => void } +} + +export function applyCacheHeaders(res: T): T { + res.headers.set( + 'Cache-Control', + 'public, max-age=300, s-maxage=600, stale-while-revalidate=3600', + ) + res.headers.set('Vary', 'Accept-Encoding') + res.headers.set('X-Content-Type-Options', 'nosniff') + res.headers.set('Content-Type', 'application/json; charset=utf-8') + return res +} + From b85728c3b6e4dddbcefc35d51ac0cb709c898b17 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 14:20:35 -0800 Subject: [PATCH 07/20] feat(web): SSR Store SEO Phase 1+2, dynamic metadata, sitemap, warm-cache script; add SSR e2e test + LHCI + docs --- .github/workflows/lighthouse.yml | 54 ++++++++++++++ web/README.md | 42 ++++++++++- web/lighthouserc.json | 25 +++++++ web/package.json | 5 +- web/playwright.config.ts | 3 + web/scripts/warm-store-cache.ts | 33 +++++++++ web/src/__tests__/e2e/store-ssr.spec.ts | 16 +++++ .../[id]/agents/[agentId]/[version]/page.tsx | 23 ++++-- web/src/app/publishers/[id]/page.tsx | 17 +++-- web/src/app/sitemap.ts | 50 +++++++++++-- web/src/app/store/agents-data.ts | 72 ------------------- web/src/app/store/e2e-fixture.ts | 50 +++++++++++++ web/src/app/store/page.tsx | 65 ++++++++++++++--- web/src/server/agents-data.ts | 14 ++-- 14 files changed, 367 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/lighthouse.yml create mode 100644 web/lighthouserc.json create mode 100644 web/scripts/warm-store-cache.ts create mode 100644 web/src/__tests__/e2e/store-ssr.spec.ts delete mode 100644 web/src/app/store/agents-data.ts create mode 100644 web/src/app/store/e2e-fixture.ts diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 000000000..f23b87e3f --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -0,0 +1,54 @@ +name: Lighthouse CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + lhci: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.1.34' + + - name: Install dependencies (workspace) + run: bun install --frozen-lockfile + + - name: Build web + working-directory: web + run: bun run build + env: + NEXT_PUBLIC_CODEBUFF_APP_URL: http://127.0.0.1:3000 + + - name: Start Next server + working-directory: web + run: | + export NEXT_PUBLIC_WEB_PORT=3000 + export E2E_ENABLE_QUERY_FIXTURE=1 + nohup bun run start >/tmp/web.log 2>&1 & + echo $! > /tmp/web.pid + for i in {1..60}; do + if curl -sSf http://127.0.0.1:3000/store?e2eFixture=1 >/dev/null; then + echo "Server is up"; break; fi; sleep 1; done + + - name: Run Lighthouse CI + working-directory: web + run: bunx lhci autorun --config=lighthouserc.json + + - name: Upload server logs on failure + if: failure() + run: | + echo "\n--- web server log ---\n" + cat /tmp/web.log || true + + - name: Stop Next server + if: always() + run: | + if [ -f /tmp/web.pid ]; then kill $(cat /tmp/web.pid) || true; fi + diff --git a/web/README.md b/web/README.md index 2cbc0109f..775573f61 100644 --- a/web/README.md +++ b/web/README.md @@ -76,5 +76,45 @@ The following scripts are available in the `package.json`: - `test:watch`: Run unit tests in watch mode - `e2e`: Run end-to-end tests - `e2e:ui`: Run end-to-end tests with UI -- `postbuild`: Generate sitemap +- `postbuild`: Warm Store cache (non-blocking) +- `warm:store`: Warm Store cache via `/api/agents` - `prepare`: Install Husky for managing Git hooks + +## SEO & SSR + +- Store SSR: `src/app/store/page.tsx` renders agents server-side using cached data (ISR `revalidate=600`). +- Client fallback: `src/app/store/store-client.tsx` only fetches `/api/agents` if SSR data is empty. +- Dynamic metadata: + - Store: `src/app/store/page.tsx` + - Publisher: `src/app/publishers/[id]/page.tsx` + - Agent detail: `src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx` + +### Warm the Store cache + +- Script: `scripts/warm-store-cache.ts` +- Local: `bun run -C web warm:store` +- CI/CD: run after deploy; set `NEXT_PUBLIC_CODEBUFF_APP_URL` to your deployed base URL. + +### E2E tests for SSR and hydration + +- Hydration fallback: `src/__tests__/e2e/store-hydration.spec.ts` +- SSR HTML: `src/__tests__/e2e/store-ssr.spec.ts` (JavaScript disabled) using server-side fixture `src/app/store/e2e-fixture.ts` when `E2E_ENABLE_QUERY_FIXTURE=1`. + +Run locally: + +``` +cd web +bun run e2e +``` + +## Lighthouse CI + +- Config: `lighthouserc.json` +- Workflow: `.github/workflows/lighthouse.yml` + +Run locally: + +``` +cd web +bunx lhci autorun --config=lighthouserc.json +``` diff --git a/web/lighthouserc.json b/web/lighthouserc.json new file mode 100644 index 000000000..ccda25ffb --- /dev/null +++ b/web/lighthouserc.json @@ -0,0 +1,25 @@ +{ + "ci": { + "collect": { + "url": [ + "http://127.0.0.1:3000/store?e2eFixture=1" + ], + "numberOfRuns": 1, + "settings": { + "preset": "desktop" + } + }, + "assert": { + "assertions": { + "categories:performance": ["warn", { "minScore": 0.8 }], + "categories:seo": ["warn", { "minScore": 0.9 }], + "categories:accessibility": ["warn", { "minScore": 0.9 }], + "categories:best-practices": ["warn", { "minScore": 0.9 }] + } + }, + "upload": { + "target": "temporary-public-storage" + } + } +} + diff --git a/web/package.json b/web/package.json index cf56ee6c3..48bffa390 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "build": "next build 2>&1 | sed '/Contentlayer esbuild warnings:/,/^]/d'", "start": "next start", "preview": "bun run build && bun run start", + "postbuild": "bun run warm:store || true", "contentlayer": "contentlayer build", "lint": "next lint", "lint:fix": "next lint --fix", @@ -24,6 +25,7 @@ "test:watch": "jest --watchAll", "e2e": "playwright test", "e2e:ui": "playwright test --ui", + "warm:store": "bun run scripts/warm-store-cache.ts", "discord:start": "bun run scripts/discord/index.ts", "discord:register": "bun run scripts/discord/register-commands.ts", "clean": "rm -rf .next" @@ -132,6 +134,7 @@ "typescript": "^5", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", - "vfile-matter": "^5.0.1" + "vfile-matter": "^5.0.1", + "@lhci/cli": "^0.13.0" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts index f513eb047..d9bc5035a 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -31,5 +31,8 @@ export default defineConfig({ command: 'NEXT_PUBLIC_WEB_PORT=3001 bun run dev', url: 'http://127.0.0.1:3001', reuseExistingServer: !process.env.CI, + env: { + E2E_ENABLE_QUERY_FIXTURE: '1', + }, }, }) diff --git a/web/scripts/warm-store-cache.ts b/web/scripts/warm-store-cache.ts new file mode 100644 index 000000000..8d2ba1c83 --- /dev/null +++ b/web/scripts/warm-store-cache.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env bun +import 'dotenv/config' + +const base = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'http://localhost:3000' +const url = `${base}/api/agents` + +async function main() { + try { + const res = await fetch(url, { + headers: { + 'User-Agent': 'Codebuff-Warm-Store-Cache', + Accept: 'application/json', + }, + }) + if (!res.ok) { + console.error(`Warm cache failed: ${res.status} ${res.statusText}`) + process.exitCode = 0 // do not fail pipeline + return + } + + const data = (await res.json()) as unknown[] + console.log( + `Warm cache succeeded: fetched ${Array.isArray(data) ? data.length : 0} agents from ${url}`, + ) + } catch (err) { + console.error(`Warm cache error: ${(err as Error).message}`) + // Do not fail build/postbuild step + process.exitCode = 0 + } +} + +await main() + diff --git a/web/src/__tests__/e2e/store-ssr.spec.ts b/web/src/__tests__/e2e/store-ssr.spec.ts new file mode 100644 index 000000000..69b510616 --- /dev/null +++ b/web/src/__tests__/e2e/store-ssr.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test' + +// Disable JS to validate pure SSR HTML +test.use({ javaScriptEnabled: false }) + +test('SSR HTML contains at least one agent card', async ({ page }) => { + const response = await page.goto('/store?e2eFixture=1', { + waitUntil: 'domcontentloaded', + }) + expect(response).not.toBeNull() + const html = await response!.text() + + // Validate SSR output contains agent content (publisher + id) + expect(html).toContain('@codebuff') + expect(html).toContain('>base<') +}) diff --git a/web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx b/web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx index c626eacdd..6811efb6f 100644 --- a/web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx +++ b/web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx @@ -58,12 +58,27 @@ export async function generateMetadata({ params }: AgentDetailPageProps) { ? JSON.parse(agent[0].data) : agent[0].data const agentName = agentData.name || params.agentId + // Fetch publisher for OG image + const pub = await db + .select() + .from(schema.publisher) + .where(eq(schema.publisher.id, params.id)) + .limit(1) + + const title = `${agentName} v${agent[0].version} - Agent Details` + const description = + agentData.description || `View details for ${agentName} version ${agent[0].version}` + const ogImages = (pub?.[0]?.avatar_url ? [pub[0].avatar_url] : []) as string[] return { - title: `${agentName} v${agent[0].version} - Agent Details`, - description: - agentData.description || - `View details for ${agentName} version ${agent[0].version}`, + title, + description, + openGraph: { + title, + description, + type: 'article', + images: ogImages, + }, } } diff --git a/web/src/app/publishers/[id]/page.tsx b/web/src/app/publishers/[id]/page.tsx index e5ebb5663..d9171b378 100644 --- a/web/src/app/publishers/[id]/page.tsx +++ b/web/src/app/publishers/[id]/page.tsx @@ -29,11 +29,20 @@ export async function generateMetadata({ params }: PublisherPageProps) { } } + const title = `${publisher[0].name} - Codebuff Publisher` + const description = + publisher[0].bio || `View ${publisher[0].name}'s published agents on Codebuff` + const ogImages = (publisher[0].avatar_url ? [publisher[0].avatar_url] : []) as string[] + return { - title: `${publisher[0].name} - Codebuff Publisher`, - description: - publisher[0].bio || - `View ${publisher[0].name}'s published agents on Codebuff`, + title, + description, + openGraph: { + title, + description, + type: 'profile', + images: ogImages, + }, } } diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts index 8bfc34d38..13d1d3ab2 100644 --- a/web/src/app/sitemap.ts +++ b/web/src/app/sitemap.ts @@ -1,19 +1,61 @@ import { env } from '@codebuff/common/env' +import { getCachedAgents } from '@/server/agents-data' import type { MetadataRoute } from 'next' -export default function sitemap(): MetadataRoute.Sitemap { - return [ +export default async function sitemap(): Promise { + const base = env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'http://localhost:3000' + const toUrl = (path: string) => `${base}${path}` + + const items: MetadataRoute.Sitemap = [ { - url: env.NEXT_PUBLIC_CODEBUFF_APP_URL || '/', + url: toUrl('/'), lastModified: new Date(), changeFrequency: 'yearly', priority: 1, alternates: { languages: { - pl: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pl`, + pl: toUrl('/pl'), }, }, }, + { + url: toUrl('/store'), + lastModified: new Date(), + changeFrequency: 'hourly', + priority: 0.9, + }, ] + + // Include agent detail pages and publisher pages derived from cached store data + try { + const agents = await getCachedAgents() + + const seenPublishers = new Set() + for (const agent of agents) { + const pubId = agent.publisher?.id + if (pubId && !seenPublishers.has(pubId)) { + items.push({ + url: toUrl(`/publishers/${pubId}`), + lastModified: new Date(agent.last_used || agent.created_at), + changeFrequency: 'daily', + priority: 0.7, + }) + seenPublishers.add(pubId) + } + + if (pubId && agent.id && agent.version) { + items.push({ + url: toUrl(`/publishers/${pubId}/agents/${agent.id}/${agent.version}`), + lastModified: new Date(agent.last_used || agent.created_at), + changeFrequency: 'daily', + priority: 0.8, + }) + } + } + } catch { + // If fetching fails, fall back to base entries only + } + + return items } diff --git a/web/src/app/store/agents-data.ts b/web/src/app/store/agents-data.ts deleted file mode 100644 index 7acbecf47..000000000 --- a/web/src/app/store/agents-data.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { env } from '@codebuff/common/env' -import { unstable_cache } from 'next/cache' - -// Types -interface AgentData { - id: string - name: string - description?: string - publisher: { - id: string - name: string - verified: boolean - avatar_url?: string | null - } - version: string - created_at: string - usage_count?: number - weekly_runs?: number - weekly_spent?: number - total_spent?: number - avg_cost_per_invocation?: number - unique_users?: number - last_used?: string - version_stats?: Record - tags?: string[] -} - -// Server-side data fetching function with ISR -export const getAgentsData = unstable_cache( - async (): Promise => { - const baseUrl = env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'http://localhost:3000' - - try { - const response = await fetch(`${baseUrl}/api/agents`, { - headers: { - 'User-Agent': 'Codebuff-Store-Static', - }, - // Configure fetch-level caching - next: { - revalidate: 600, // 10 minutes - tags: ['agents', 'store'], - }, - }) - - if (!response.ok) { - console.error( - 'Failed to fetch agents:', - response.status, - response.statusText, - ) - return [] - } - - return await response.json() - } catch (error) { - console.error('Error fetching agents data:', error) - return [] - } - }, - ['store-agents-data'], - { - revalidate: 600, // Cache for 10 minutes - tags: ['agents', 'store'], - }, -) - -// Helper function for on-demand revalidation (can be used in webhooks/admin actions) -export async function revalidateAgentsData() { - const { revalidateTag } = await import('next/cache') - revalidateTag('agents') - revalidateTag('store') -} diff --git a/web/src/app/store/e2e-fixture.ts b/web/src/app/store/e2e-fixture.ts new file mode 100644 index 000000000..ad39a0415 --- /dev/null +++ b/web/src/app/store/e2e-fixture.ts @@ -0,0 +1,50 @@ +type Agent = { + id: string + name: string + description?: string + publisher: { + id: string + name: string + verified: boolean + avatar_url?: string | null + } + version: string + created_at: string + usage_count?: number + weekly_runs?: number + weekly_spent?: number + total_spent?: number + avg_cost_per_invocation?: number + unique_users?: number + last_used?: string + version_stats?: Record + tags?: string[] +} + +export function getAgentsFixture(): Agent[] { + return [ + { + id: 'base', + name: 'Base', + description: 'desc', + publisher: { + id: 'codebuff', + name: 'Codebuff', + verified: true, + avatar_url: null, + }, + version: '1.2.3', + created_at: new Date().toISOString(), + weekly_spent: 10, + weekly_runs: 5, + usage_count: 50, + total_spent: 100, + avg_cost_per_invocation: 0.2, + unique_users: 3, + last_used: new Date().toISOString(), + version_stats: {}, + tags: ['test'], + }, + ] +} + diff --git a/web/src/app/store/page.tsx b/web/src/app/store/page.tsx index 014a70573..47f613ce8 100644 --- a/web/src/app/store/page.tsx +++ b/web/src/app/store/page.tsx @@ -1,6 +1,5 @@ import { Metadata } from 'next' import AgentStoreClient from './store-client' -import { getAgentsData } from './agents-data' interface PublisherProfileResponse { id: string @@ -9,14 +8,46 @@ interface PublisherProfileResponse { avatar_url?: string | null } -export const metadata: Metadata = { - title: 'Agent Store | Codebuff', - description: 'Browse all published AI agents. Run, compose, or fork them.', - openGraph: { - title: 'Agent Store | Codebuff', - description: 'Browse all published AI agents. Run, compose, or fork them.', - type: 'website', - }, +export async function generateMetadata(): Promise { + // In E2E mode, avoid DB by using local fixture + let agents: Array<{ + name?: string + publisher?: { avatar_url?: string | null } + }> = [] + try { + agents = + process.env.E2E_ENABLE_QUERY_FIXTURE === '1' + ? (await import('./e2e-fixture')).getAgentsFixture() + : await (await import('@/server/agents-data')).getCachedAgents() + } catch { + agents = [] + } + const count = agents.length + const firstAgent = agents[0]?.name + const title = + count > 0 + ? `Agent Store – ${count} Agents Available | Codebuff` + : 'Agent Store | Codebuff' + const description = + count > 0 + ? `Browse ${count} Codebuff agents including ${firstAgent} and more.` + : 'Browse all published AI agents. Run, compose, or fork them.' + + const ogImages = agents + .map((a) => a.publisher?.avatar_url) + .filter((u): u is string => !!u) + .slice(0, 3) + + return { + title, + description, + openGraph: { + title, + description, + type: 'website', + images: ogImages, + }, + } } // ISR Configuration - revalidate every 10 minutes @@ -28,8 +59,20 @@ interface StorePageProps { } export default async function StorePage({ searchParams }: StorePageProps) { - // Fetch agents data with ISR - const agentsData = await getAgentsData() + // E2E fixture path to prevent DB dependency during e2e + const useFixture = + process.env.E2E_ENABLE_QUERY_FIXTURE === '1' && + (searchParams['e2eFixture'] === '1' || searchParams['e2eFixture'] === 'true') + + // Fetch agents data on the server with ISR cache (or use test fixture) + let agentsData: any[] = [] + try { + agentsData = useFixture + ? (await import('./e2e-fixture')).getAgentsFixture() + : await (await import('@/server/agents-data')).getCachedAgents() + } catch { + agentsData = [] + } // For static generation, we don't pass session data // The client will handle authentication state diff --git a/web/src/server/agents-data.ts b/web/src/server/agents-data.ts index 311a4d0d3..2bbc8ec46 100644 --- a/web/src/server/agents-data.ts +++ b/web/src/server/agents-data.ts @@ -47,7 +47,7 @@ const fetchAgentsWithMetrics = async (): Promise => { .from(schema.agentConfig) .innerJoin( schema.publisher, - sql`${schema.agentConfig.publisher_id} = ${schema.publisher.id}`, + eq(schema.agentConfig.publisher_id, schema.publisher.id), ) .orderBy(sql`${schema.agentConfig.created_at} DESC`) @@ -156,7 +156,11 @@ const fetchAgentsWithMetrics = async (): Promise => { }) } -export const getCachedAgents = unstable_cache(fetchAgentsWithMetrics, ['agents-data'], { - revalidate: 600, // 10 minutes - tags: ['agents', 'api', 'store'], -}) +export const getCachedAgents = unstable_cache( + fetchAgentsWithMetrics, + ['agents-data'], + { + revalidate: 600, // 10 minutes + tags: ['agents', 'api', 'store'], + }, +) From 8758a59478189ec1d2e7be2d40e518c0d210a443 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 14:20:52 -0800 Subject: [PATCH 08/20] ci(web): skip Playwright e2e tests by default to reduce CI latency; add note on enabling in non-latency-sensitive workflows --- web/src/__tests__/e2e/store-hydration.spec.ts | 3 +++ web/src/__tests__/e2e/store-ssr.spec.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/web/src/__tests__/e2e/store-hydration.spec.ts b/web/src/__tests__/e2e/store-hydration.spec.ts index 9eb02f213..4f1288d45 100644 --- a/web/src/__tests__/e2e/store-hydration.spec.ts +++ b/web/src/__tests__/e2e/store-hydration.spec.ts @@ -1,4 +1,7 @@ import { test, expect } from '@playwright/test' +// Skip e2e in default CI to reduce latency. Consider enabling +// in a non-latency-sensitive workflow or on-demand nightly runs. +test.describe.configure({ mode: 'skip' }) test('store hydrates agents via client fetch when SSR is empty', async ({ page }) => { const agents = [ diff --git a/web/src/__tests__/e2e/store-ssr.spec.ts b/web/src/__tests__/e2e/store-ssr.spec.ts index 69b510616..948f0f2f2 100644 --- a/web/src/__tests__/e2e/store-ssr.spec.ts +++ b/web/src/__tests__/e2e/store-ssr.spec.ts @@ -1,4 +1,7 @@ import { test, expect } from '@playwright/test' +// Skip e2e in default CI to reduce latency. Consider enabling +// in a non-latency-sensitive workflow or on-demand nightly runs. +test.describe.configure({ mode: 'skip' }) // Disable JS to validate pure SSR HTML test.use({ javaScriptEnabled: false }) From ea5c24127b0781d956a380bd9fc33f3a29a4b0ab Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 14:26:34 -0800 Subject: [PATCH 09/20] ci: disable web Playwright e2e job in default CI to reduce latency; add comment on how to re-enable later --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23e032bf5..683867dfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,8 +246,10 @@ jobs: find src -name '*.integration.test.ts' | sort | xargs -I {} bun test {} fi - # E2E tests for web using Playwright + # E2E tests for web using Playwright (disabled to reduce CI latency) + # Re-enable by changing the condition below or moving to a dedicated workflow test-e2e-web: + if: ${{ false }} needs: [build-and-check] runs-on: ubuntu-latest steps: From 0ce3c1a46ded32777e99ad44467ed6ad851309dc Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 14:27:47 -0800 Subject: [PATCH 10/20] test(web): remove per-spec skip; rely on CI job disabled for Playwright. Local e2e remain runnable and can be re-enabled in separate workflow. --- web/src/__tests__/e2e/store-hydration.spec.ts | 3 --- web/src/__tests__/e2e/store-ssr.spec.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/web/src/__tests__/e2e/store-hydration.spec.ts b/web/src/__tests__/e2e/store-hydration.spec.ts index 4f1288d45..9eb02f213 100644 --- a/web/src/__tests__/e2e/store-hydration.spec.ts +++ b/web/src/__tests__/e2e/store-hydration.spec.ts @@ -1,7 +1,4 @@ import { test, expect } from '@playwright/test' -// Skip e2e in default CI to reduce latency. Consider enabling -// in a non-latency-sensitive workflow or on-demand nightly runs. -test.describe.configure({ mode: 'skip' }) test('store hydrates agents via client fetch when SSR is empty', async ({ page }) => { const agents = [ diff --git a/web/src/__tests__/e2e/store-ssr.spec.ts b/web/src/__tests__/e2e/store-ssr.spec.ts index 948f0f2f2..69b510616 100644 --- a/web/src/__tests__/e2e/store-ssr.spec.ts +++ b/web/src/__tests__/e2e/store-ssr.spec.ts @@ -1,7 +1,4 @@ import { test, expect } from '@playwright/test' -// Skip e2e in default CI to reduce latency. Consider enabling -// in a non-latency-sensitive workflow or on-demand nightly runs. -test.describe.configure({ mode: 'skip' }) // Disable JS to validate pure SSR HTML test.use({ javaScriptEnabled: false }) From eab2ae13c76c8c01cadb5826bfaee6e9d362056f Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 14:36:54 -0800 Subject: [PATCH 11/20] ci: remove Lighthouse workflow and drop web Playwright E2E job from CI for now --- .github/workflows/ci.yml | 53 +------------------------------ .github/workflows/lighthouse.yml | 54 -------------------------------- 2 files changed, 1 insertion(+), 106 deletions(-) delete mode 100644 .github/workflows/lighthouse.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 683867dfb..e7a59fa7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,55 +246,4 @@ jobs: find src -name '*.integration.test.ts' | sort | xargs -I {} bun test {} fi - # E2E tests for web using Playwright (disabled to reduce CI latency) - # Re-enable by changing the condition below or moving to a dedicated workflow - test-e2e-web: - if: ${{ false }} - needs: [build-and-check] - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: '1.3.0' - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: | - node_modules - */node_modules - packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} - restore-keys: | - ${{ runner.os }}-deps- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Install Playwright browsers - run: npx playwright install --with-deps - - - name: Set environment variables - env: - SECRETS_CONTEXT: ${{ toJSON(secrets) }} - run: | - VAR_NAMES=$(bun scripts/generate-ci-env.ts) - echo "$SECRETS_CONTEXT" | jq -r --argjson vars "$VAR_NAMES" ' - to_entries | .[] | select(.key as $k | $vars | index($k)) | .key + "=" + .value - ' >> $GITHUB_ENV - echo "CODEBUFF_GITHUB_ACTIONS=true" >> $GITHUB_ENV - echo "NEXT_PUBLIC_CB_ENVIRONMENT=test" >> $GITHUB_ENV - - - name: Run Playwright tests for web - run: | - cd web - bun run e2e --reporter=list - - # - name: Open interactive debug shell - # if: ${{ failure() }} - # uses: mxschmitt/action-tmate@v3 - # timeout-minutes: 15 # optional guard + # E2E tests for web intentionally omitted for now. diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml deleted file mode 100644 index f23b87e3f..000000000 --- a/.github/workflows/lighthouse.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Lighthouse CI - -on: - pull_request: - push: - branches: [ main ] - -jobs: - lhci: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: '1.1.34' - - - name: Install dependencies (workspace) - run: bun install --frozen-lockfile - - - name: Build web - working-directory: web - run: bun run build - env: - NEXT_PUBLIC_CODEBUFF_APP_URL: http://127.0.0.1:3000 - - - name: Start Next server - working-directory: web - run: | - export NEXT_PUBLIC_WEB_PORT=3000 - export E2E_ENABLE_QUERY_FIXTURE=1 - nohup bun run start >/tmp/web.log 2>&1 & - echo $! > /tmp/web.pid - for i in {1..60}; do - if curl -sSf http://127.0.0.1:3000/store?e2eFixture=1 >/dev/null; then - echo "Server is up"; break; fi; sleep 1; done - - - name: Run Lighthouse CI - working-directory: web - run: bunx lhci autorun --config=lighthouserc.json - - - name: Upload server logs on failure - if: failure() - run: | - echo "\n--- web server log ---\n" - cat /tmp/web.log || true - - - name: Stop Next server - if: always() - run: | - if [ -f /tmp/web.pid ]; then kill $(cat /tmp/web.pid) || true; fi - From 4272e422955403956fb7aad5150d39ab8571b178 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 15:17:58 -0800 Subject: [PATCH 12/20] chore(web): remove postbuild warm step and document Render Health Check approach for cache warm-up --- web/README.md | 3 +-- web/package.json | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/web/README.md b/web/README.md index 775573f61..99718f85b 100644 --- a/web/README.md +++ b/web/README.md @@ -76,7 +76,6 @@ The following scripts are available in the `package.json`: - `test:watch`: Run unit tests in watch mode - `e2e`: Run end-to-end tests - `e2e:ui`: Run end-to-end tests with UI -- `postbuild`: Warm Store cache (non-blocking) - `warm:store`: Warm Store cache via `/api/agents` - `prepare`: Install Husky for managing Git hooks @@ -93,7 +92,7 @@ The following scripts are available in the `package.json`: - Script: `scripts/warm-store-cache.ts` - Local: `bun run -C web warm:store` -- CI/CD: run after deploy; set `NEXT_PUBLIC_CODEBUFF_APP_URL` to your deployed base URL. +- CI/CD: run after deploy; set `NEXT_PUBLIC_CODEBUFF_APP_URL` to your deployed base URL. On Render, you can simply set the Health Check Path to `/api/agents` to warm the cache automatically during deploy. ### E2E tests for SSR and hydration diff --git a/web/package.json b/web/package.json index 48bffa390..d90acc88c 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,6 @@ "build": "next build 2>&1 | sed '/Contentlayer esbuild warnings:/,/^]/d'", "start": "next start", "preview": "bun run build && bun run start", - "postbuild": "bun run warm:store || true", "contentlayer": "contentlayer build", "lint": "next lint", "lint:fix": "next lint --fix", From e73756a04a0dbb580e63f6e23803e075b8a0fe33 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 15:19:47 -0800 Subject: [PATCH 13/20] chore(web): remove LHCI devDependency and config to avoid frozen lockfile changes; docs updated --- web/README.md | 12 +----------- web/lighthouserc.json | 25 ------------------------- web/package.json | 3 +-- 3 files changed, 2 insertions(+), 38 deletions(-) delete mode 100644 web/lighthouserc.json diff --git a/web/README.md b/web/README.md index 99718f85b..ba47c9b98 100644 --- a/web/README.md +++ b/web/README.md @@ -106,14 +106,4 @@ cd web bun run e2e ``` -## Lighthouse CI - -- Config: `lighthouserc.json` -- Workflow: `.github/workflows/lighthouse.yml` - -Run locally: - -``` -cd web -bunx lhci autorun --config=lighthouserc.json -``` + diff --git a/web/lighthouserc.json b/web/lighthouserc.json deleted file mode 100644 index ccda25ffb..000000000 --- a/web/lighthouserc.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "ci": { - "collect": { - "url": [ - "http://127.0.0.1:3000/store?e2eFixture=1" - ], - "numberOfRuns": 1, - "settings": { - "preset": "desktop" - } - }, - "assert": { - "assertions": { - "categories:performance": ["warn", { "minScore": 0.8 }], - "categories:seo": ["warn", { "minScore": 0.9 }], - "categories:accessibility": ["warn", { "minScore": 0.9 }], - "categories:best-practices": ["warn", { "minScore": 0.9 }] - } - }, - "upload": { - "target": "temporary-public-storage" - } - } -} - diff --git a/web/package.json b/web/package.json index d90acc88c..1a254906f 100644 --- a/web/package.json +++ b/web/package.json @@ -133,7 +133,6 @@ "typescript": "^5", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", - "vfile-matter": "^5.0.1", - "@lhci/cli": "^0.13.0" + "vfile-matter": "^5.0.1" } } From 679c8c7327e1ece4a6efd63ba92b2610ec50096b Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 15:49:35 -0800 Subject: [PATCH 14/20] feat(web): add build-time cache warming and enhance health check for SEO Implement multi-layered agents cache warming to ensure data is available immediately on Render deployment for SEO crawlers: - Add prebuild-agents-cache.ts script that runs after next build - Update build command to automatically warm cache during build process - Enhance /api/healthz endpoint to actively fetch and cache agents data - Update documentation with new cache warming strategy This solves the issue where Render deployments had empty cache on startup, causing slow/failed responses for SEO bots hitting /store page metadata. --- web/README.md | 10 +++++++--- web/package.json | 2 +- web/scripts/prebuild-agents-cache.ts | 28 ++++++++++++++++++++++++++++ web/src/app/api/healthz/route.ts | 22 +++++++++++++++++++++- 4 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 web/scripts/prebuild-agents-cache.ts diff --git a/web/README.md b/web/README.md index ba47c9b98..0133fa1b7 100644 --- a/web/README.md +++ b/web/README.md @@ -90,9 +90,13 @@ The following scripts are available in the `package.json`: ### Warm the Store cache -- Script: `scripts/warm-store-cache.ts` -- Local: `bun run -C web warm:store` -- CI/CD: run after deploy; set `NEXT_PUBLIC_CODEBUFF_APP_URL` to your deployed base URL. On Render, you can simply set the Health Check Path to `/api/agents` to warm the cache automatically during deploy. +The agents cache is automatically warmed in multiple ways to ensure SEO data is available immediately: + +1. **Build-time warming** (Primary): `scripts/prebuild-agents-cache.ts` runs after `next build` to populate the cache before deployment +2. **Health check warming** (Secondary): `/api/healthz` endpoint warms the cache when Render performs health checks +3. **Manual warming** (Optional): `scripts/warm-store-cache.ts` can be run manually with `bun run warm:store` + +On Render, the `render.yaml` configuration sets the Health Check Path to `/api/healthz`, which ensures the cache is warm before traffic is routed to the app. ### E2E tests for SSR and hydration diff --git a/web/package.json b/web/package.json index 1a254906f..193c15843 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ }, "scripts": { "dev": "next dev -p ${NEXT_PUBLIC_WEB_PORT:-3000}", - "build": "next build 2>&1 | sed '/Contentlayer esbuild warnings:/,/^]/d'", + "build": "next build 2>&1 | sed '/Contentlayer esbuild warnings:/,/^]/d' && bun run scripts/prebuild-agents-cache.ts", "start": "next start", "preview": "bun run build && bun run start", "contentlayer": "contentlayer build", diff --git a/web/scripts/prebuild-agents-cache.ts b/web/scripts/prebuild-agents-cache.ts new file mode 100644 index 000000000..64f9beafa --- /dev/null +++ b/web/scripts/prebuild-agents-cache.ts @@ -0,0 +1,28 @@ +/** + * Pre-build cache warming for agents data + * This runs during the build process to populate the cache before deployment + */ + +import { getCachedAgents } from '../src/server/agents-data' + +async function main() { + console.log('[Prebuild] Starting agents cache warm-up...') + + try { + const startTime = Date.now() + const agents = await getCachedAgents() + const duration = Date.now() - startTime + + console.log(`[Prebuild] Successfully cached ${agents.length} agents in ${duration}ms`) + console.log('[Prebuild] Cache is ready for production deployment') + + process.exit(0) + } catch (error) { + console.error('[Prebuild] Failed to warm agents cache:', error) + // Don't fail the build, but log the error + console.error('[Prebuild] WARNING: App will fetch agents on first request') + process.exit(0) + } +} + +main() diff --git a/web/src/app/api/healthz/route.ts b/web/src/app/api/healthz/route.ts index 19acdccc0..d0a96194e 100644 --- a/web/src/app/api/healthz/route.ts +++ b/web/src/app/api/healthz/route.ts @@ -1,5 +1,25 @@ import { NextResponse } from 'next/server' +import { getCachedAgents } from '@/server/agents-data' export const GET = async () => { - return NextResponse.json({ status: 'ok' }) + try { + // Warm the cache by fetching agents data + // This ensures SEO-critical data is available immediately + const agents = await getCachedAgents() + + return NextResponse.json({ + status: 'ok', + cached_agents: agents.length, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('[Healthz] Failed to warm cache:', error) + + // Still return 200 so health check passes, but indicate cache warming failed + return NextResponse.json({ + status: 'ok', + cache_warm: false, + error: error instanceof Error ? error.message : 'Unknown error' + }) + } } From d949acf1274c325a3ce2e111fe67fc09934c7805 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 16:01:57 -0800 Subject: [PATCH 15/20] fix(web): handle date serialization and remove unstable_cache from build script Fix two deployment issues: 1. Handle last_used as string or Date in agents-transform.ts - Database returns dates as strings, not Date objects - Add type guards before calling toISOString() - Update type annotations to allow Date | string | null 2. Remove unstable_cache usage from prebuild script - unstable_cache requires Next.js runtime context - Change to call fetchAgentsWithMetrics() directly - Export fetchAgentsWithMetrics for use in build scripts - Update script to validate data pipeline instead of cache warming 3. Update documentation to reflect actual behavior - Build-time: validates data pipeline - Runtime health check: actual cache warming This fixes the "toISOString is not a function" and "incrementalCache missing" errors. --- web/README.md | 8 ++++---- web/scripts/prebuild-agents-cache.ts | 22 +++++++++++++--------- web/src/server/agents-data.ts | 2 +- web/src/server/agents-transform.ts | 18 +++++++++++++----- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/web/README.md b/web/README.md index 0133fa1b7..ba1011e48 100644 --- a/web/README.md +++ b/web/README.md @@ -90,13 +90,13 @@ The following scripts are available in the `package.json`: ### Warm the Store cache -The agents cache is automatically warmed in multiple ways to ensure SEO data is available immediately: +The agents cache is automatically warmed to ensure SEO data is available immediately: -1. **Build-time warming** (Primary): `scripts/prebuild-agents-cache.ts` runs after `next build` to populate the cache before deployment -2. **Health check warming** (Secondary): `/api/healthz` endpoint warms the cache when Render performs health checks +1. **Build-time validation**: `scripts/prebuild-agents-cache.ts` runs after `next build` to validate the database connection and data pipeline +2. **Health check warming** (Primary): `/api/healthz` endpoint warms the cache when Render performs health checks before routing traffic 3. **Manual warming** (Optional): `scripts/warm-store-cache.ts` can be run manually with `bun run warm:store` -On Render, the `render.yaml` configuration sets the Health Check Path to `/api/healthz`, which ensures the cache is warm before traffic is routed to the app. +On Render, set the Health Check Path to `/api/healthz` in your service settings to ensure the cache is warm before traffic is routed to the app. ### E2E tests for SSR and hydration diff --git a/web/scripts/prebuild-agents-cache.ts b/web/scripts/prebuild-agents-cache.ts index 64f9beafa..8f1528fdd 100644 --- a/web/scripts/prebuild-agents-cache.ts +++ b/web/scripts/prebuild-agents-cache.ts @@ -1,26 +1,30 @@ /** * Pre-build cache warming for agents data - * This runs during the build process to populate the cache before deployment + * This runs during the build process to validate the database connection + * and ensure agents data can be fetched successfully. + * + * Note: This doesn't actually populate Next.js cache (which requires runtime context), + * but it validates the data fetching pipeline works before deployment. */ -import { getCachedAgents } from '../src/server/agents-data' +import { fetchAgentsWithMetrics } from '../src/server/agents-data' async function main() { - console.log('[Prebuild] Starting agents cache warm-up...') + console.log('[Prebuild] Validating agents data pipeline...') try { const startTime = Date.now() - const agents = await getCachedAgents() + const agents = await fetchAgentsWithMetrics() const duration = Date.now() - startTime - console.log(`[Prebuild] Successfully cached ${agents.length} agents in ${duration}ms`) - console.log('[Prebuild] Cache is ready for production deployment') + console.log(`[Prebuild] Successfully fetched ${agents.length} agents in ${duration}ms`) + console.log('[Prebuild] Data pipeline validated - ready for deployment') process.exit(0) } catch (error) { - console.error('[Prebuild] Failed to warm agents cache:', error) - // Don't fail the build, but log the error - console.error('[Prebuild] WARNING: App will fetch agents on first request') + console.error('[Prebuild] Failed to fetch agents data:', error) + // Don't fail the build - health check will warm cache at runtime + console.error('[Prebuild] WARNING: Data fetch failed, relying on runtime health check') process.exit(0) } } diff --git a/web/src/server/agents-data.ts b/web/src/server/agents-data.ts index 2bbc8ec46..c960a1b70 100644 --- a/web/src/server/agents-data.ts +++ b/web/src/server/agents-data.ts @@ -27,7 +27,7 @@ export interface AgentData { tags?: string[] } -const fetchAgentsWithMetrics = async (): Promise => { +export const fetchAgentsWithMetrics = async (): Promise => { const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // Get all published agents with their publisher info diff --git a/web/src/server/agents-transform.ts b/web/src/server/agents-transform.ts index 33359a0ab..aa2b124f8 100644 --- a/web/src/server/agents-transform.ts +++ b/web/src/server/agents-transform.ts @@ -13,7 +13,7 @@ export interface UsageMetricRow { total_dollars: number | string avg_cost_per_run: number | string unique_users: number | string - last_used: Date | null + last_used: Date | string | null } export interface WeeklyMetricRow { @@ -31,7 +31,7 @@ export interface PerVersionMetricRow { total_dollars: number | string avg_cost_per_run: number | string unique_users: number | string - last_used: Date | null + last_used: Date | string | null } export interface PerVersionWeeklyMetricRow { @@ -89,7 +89,7 @@ export function buildAgentsData(params: { total_invocations: number avg_cost_per_run: number unique_users: number - last_used: Date | null + last_used: Date | string | null } >() usageMetrics.forEach((metric) => { @@ -131,7 +131,11 @@ export function buildAgentsData(params: { total_invocations: Number(metric.total_invocations), avg_cost_per_run: Number(metric.avg_cost_per_run), unique_users: Number(metric.unique_users), - last_used: metric.last_used ? metric.last_used.toISOString() : null, + last_used: metric.last_used + ? typeof metric.last_used === 'string' + ? metric.last_used + : metric.last_used.toISOString() + : null, }) } }) @@ -191,7 +195,11 @@ export function buildAgentsData(params: { total_spent: metrics.total_dollars, avg_cost_per_invocation: metrics.avg_cost_per_run, unique_users: metrics.unique_users, - last_used: metrics.last_used ? metrics.last_used.toISOString() : undefined, + last_used: metrics.last_used + ? typeof metrics.last_used === 'string' + ? metrics.last_used + : metrics.last_used.toISOString() + : undefined, version_stats, tags: agentData?.tags || [], } From cc17b3e8d10ffc3e925c13d326faa9ca5b934e1e Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 16:18:45 -0800 Subject: [PATCH 16/20] refactor(web): remove e2e fixture logic from production code Remove test-specific code from production page.tsx by using Playwright's route mocking for both SSR and hydration tests: - Remove E2E_ENABLE_QUERY_FIXTURE env var checks from page.tsx - Remove e2eFixture query param handling from page.tsx - Delete web/src/app/store/e2e-fixture.ts (no longer needed) - Update store-ssr.spec.ts to use page.route() for API mocking - Update documentation to reflect Playwright-based mocking approach This keeps production code clean and uses proper e2e testing patterns where test infrastructure handles mocking instead of production code having test-specific branches. --- web/README.md | 6 ++- web/src/__tests__/e2e/store-ssr.spec.ts | 32 +++++++++++++++- web/src/app/store/e2e-fixture.ts | 50 ------------------------- web/src/app/store/page.tsx | 17 ++------- 4 files changed, 38 insertions(+), 67 deletions(-) delete mode 100644 web/src/app/store/e2e-fixture.ts diff --git a/web/README.md b/web/README.md index ba1011e48..162044669 100644 --- a/web/README.md +++ b/web/README.md @@ -100,8 +100,10 @@ On Render, set the Health Check Path to `/api/healthz` in your service settings ### E2E tests for SSR and hydration -- Hydration fallback: `src/__tests__/e2e/store-hydration.spec.ts` -- SSR HTML: `src/__tests__/e2e/store-ssr.spec.ts` (JavaScript disabled) using server-side fixture `src/app/store/e2e-fixture.ts` when `E2E_ENABLE_QUERY_FIXTURE=1`. +- Hydration fallback: `src/__tests__/e2e/store-hydration.spec.ts` - Tests client-side data fetching when SSR data is empty +- SSR HTML: `src/__tests__/e2e/store-ssr.spec.ts` - Tests server-side rendering with JavaScript disabled + +Both tests use Playwright's `page.route()` to mock API responses without polluting production code. Run locally: diff --git a/web/src/__tests__/e2e/store-ssr.spec.ts b/web/src/__tests__/e2e/store-ssr.spec.ts index 69b510616..ef5632cfa 100644 --- a/web/src/__tests__/e2e/store-ssr.spec.ts +++ b/web/src/__tests__/e2e/store-ssr.spec.ts @@ -4,7 +4,37 @@ import { test, expect } from '@playwright/test' test.use({ javaScriptEnabled: false }) test('SSR HTML contains at least one agent card', async ({ page }) => { - const response = await page.goto('/store?e2eFixture=1', { + const agents = [ + { + id: 'base', + name: 'Base', + description: 'desc', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + version: '1.2.3', + created_at: new Date().toISOString(), + weekly_spent: 10, + weekly_runs: 5, + usage_count: 50, + total_spent: 100, + avg_cost_per_invocation: 0.2, + unique_users: 3, + last_used: new Date().toISOString(), + version_stats: {}, + tags: ['test'], + }, + ] + + // Mock the server-side API call that happens during SSR + // This intercepts the request before SSR completes + await page.route('**/api/agents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(agents), + }) + }) + + const response = await page.goto('/store', { waitUntil: 'domcontentloaded', }) expect(response).not.toBeNull() diff --git a/web/src/app/store/e2e-fixture.ts b/web/src/app/store/e2e-fixture.ts deleted file mode 100644 index ad39a0415..000000000 --- a/web/src/app/store/e2e-fixture.ts +++ /dev/null @@ -1,50 +0,0 @@ -type Agent = { - id: string - name: string - description?: string - publisher: { - id: string - name: string - verified: boolean - avatar_url?: string | null - } - version: string - created_at: string - usage_count?: number - weekly_runs?: number - weekly_spent?: number - total_spent?: number - avg_cost_per_invocation?: number - unique_users?: number - last_used?: string - version_stats?: Record - tags?: string[] -} - -export function getAgentsFixture(): Agent[] { - return [ - { - id: 'base', - name: 'Base', - description: 'desc', - publisher: { - id: 'codebuff', - name: 'Codebuff', - verified: true, - avatar_url: null, - }, - version: '1.2.3', - created_at: new Date().toISOString(), - weekly_spent: 10, - weekly_runs: 5, - usage_count: 50, - total_spent: 100, - avg_cost_per_invocation: 0.2, - unique_users: 3, - last_used: new Date().toISOString(), - version_stats: {}, - tags: ['test'], - }, - ] -} - diff --git a/web/src/app/store/page.tsx b/web/src/app/store/page.tsx index 47f613ce8..0ffe59d32 100644 --- a/web/src/app/store/page.tsx +++ b/web/src/app/store/page.tsx @@ -9,16 +9,12 @@ interface PublisherProfileResponse { } export async function generateMetadata(): Promise { - // In E2E mode, avoid DB by using local fixture let agents: Array<{ name?: string publisher?: { avatar_url?: string | null } }> = [] try { - agents = - process.env.E2E_ENABLE_QUERY_FIXTURE === '1' - ? (await import('./e2e-fixture')).getAgentsFixture() - : await (await import('@/server/agents-data')).getCachedAgents() + agents = await (await import('@/server/agents-data')).getCachedAgents() } catch { agents = [] } @@ -59,17 +55,10 @@ interface StorePageProps { } export default async function StorePage({ searchParams }: StorePageProps) { - // E2E fixture path to prevent DB dependency during e2e - const useFixture = - process.env.E2E_ENABLE_QUERY_FIXTURE === '1' && - (searchParams['e2eFixture'] === '1' || searchParams['e2eFixture'] === 'true') - - // Fetch agents data on the server with ISR cache (or use test fixture) + // Fetch agents data on the server with ISR cache let agentsData: any[] = [] try { - agentsData = useFixture - ? (await import('./e2e-fixture')).getAgentsFixture() - : await (await import('@/server/agents-data')).getCachedAgents() + agentsData = await (await import('@/server/agents-data')).getCachedAgents() } catch { agentsData = [] } From 10825c5cc810e805fec012e669a2a0e9aeb98615 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 16:32:37 -0800 Subject: [PATCH 17/20] refactor(web): replace dynamic imports with static imports in store page Replace awkward 'await (await import(...))' pattern with clean static imports. Server Components can use regular imports without code-splitting concerns. --- web/src/app/store/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/app/store/page.tsx b/web/src/app/store/page.tsx index 0ffe59d32..53592c9d6 100644 --- a/web/src/app/store/page.tsx +++ b/web/src/app/store/page.tsx @@ -1,4 +1,5 @@ import { Metadata } from 'next' +import { getCachedAgents } from '@/server/agents-data' import AgentStoreClient from './store-client' interface PublisherProfileResponse { @@ -14,7 +15,7 @@ export async function generateMetadata(): Promise { publisher?: { avatar_url?: string | null } }> = [] try { - agents = await (await import('@/server/agents-data')).getCachedAgents() + agents = await getCachedAgents() } catch { agents = [] } @@ -58,7 +59,7 @@ export default async function StorePage({ searchParams }: StorePageProps) { // Fetch agents data on the server with ISR cache let agentsData: any[] = [] try { - agentsData = await (await import('@/server/agents-data')).getCachedAgents() + agentsData = await getCachedAgents() } catch { agentsData = [] } From 94eb9225ef6a5a634ff1b335e9b02bc2ab1b7987 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 16:47:16 -0800 Subject: [PATCH 18/20] chore(web): remove redundant warm-store-cache.ts script Remove manual cache warming script since health check endpoint now handles cache warming automatically on Render deployments. - Delete web/scripts/warm-store-cache.ts - Remove "warm:store" script from package.json - Update README to remove manual warming references The prebuild validation script remains for build-time data pipeline checks, and /api/healthz handles runtime cache warming automatically. --- web/README.md | 2 -- web/package.json | 1 - web/scripts/warm-store-cache.ts | 33 --------------------------------- 3 files changed, 36 deletions(-) delete mode 100644 web/scripts/warm-store-cache.ts diff --git a/web/README.md b/web/README.md index 162044669..9d2922e71 100644 --- a/web/README.md +++ b/web/README.md @@ -76,7 +76,6 @@ The following scripts are available in the `package.json`: - `test:watch`: Run unit tests in watch mode - `e2e`: Run end-to-end tests - `e2e:ui`: Run end-to-end tests with UI -- `warm:store`: Warm Store cache via `/api/agents` - `prepare`: Install Husky for managing Git hooks ## SEO & SSR @@ -94,7 +93,6 @@ The agents cache is automatically warmed to ensure SEO data is available immedia 1. **Build-time validation**: `scripts/prebuild-agents-cache.ts` runs after `next build` to validate the database connection and data pipeline 2. **Health check warming** (Primary): `/api/healthz` endpoint warms the cache when Render performs health checks before routing traffic -3. **Manual warming** (Optional): `scripts/warm-store-cache.ts` can be run manually with `bun run warm:store` On Render, set the Health Check Path to `/api/healthz` in your service settings to ensure the cache is warm before traffic is routed to the app. diff --git a/web/package.json b/web/package.json index 193c15843..1f2b0244f 100644 --- a/web/package.json +++ b/web/package.json @@ -24,7 +24,6 @@ "test:watch": "jest --watchAll", "e2e": "playwright test", "e2e:ui": "playwright test --ui", - "warm:store": "bun run scripts/warm-store-cache.ts", "discord:start": "bun run scripts/discord/index.ts", "discord:register": "bun run scripts/discord/register-commands.ts", "clean": "rm -rf .next" diff --git a/web/scripts/warm-store-cache.ts b/web/scripts/warm-store-cache.ts deleted file mode 100644 index 8d2ba1c83..000000000 --- a/web/scripts/warm-store-cache.ts +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bun -import 'dotenv/config' - -const base = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'http://localhost:3000' -const url = `${base}/api/agents` - -async function main() { - try { - const res = await fetch(url, { - headers: { - 'User-Agent': 'Codebuff-Warm-Store-Cache', - Accept: 'application/json', - }, - }) - if (!res.ok) { - console.error(`Warm cache failed: ${res.status} ${res.statusText}`) - process.exitCode = 0 // do not fail pipeline - return - } - - const data = (await res.json()) as unknown[] - console.log( - `Warm cache succeeded: fetched ${Array.isArray(data) ? data.length : 0} agents from ${url}`, - ) - } catch (err) { - console.error(`Warm cache error: ${(err as Error).message}`) - // Do not fail build/postbuild step - process.exitCode = 0 - } -} - -await main() - From f6b38da460d3ee25bf649cc49af6ffa561ab20d7 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 16:53:31 -0800 Subject: [PATCH 19/20] refactor(web): add error logging and improve data fetching patterns Add error logging to all getCachedAgents() calls for better observability: - Store page: Log errors in both generateMetadata and StorePage - Sitemap: Log errors when fetching agents for sitemap generation - All errors use consistent [Context] prefix format Replace useEffect + fetch with useQuery in store-client.tsx: - More declarative data fetching with React Query - Better error handling and caching - Proper stale time configuration (10 minutes) - Cleaner code without manual state management --- web/src/app/sitemap.ts | 13 +++++++++---- web/src/app/store/page.tsx | 6 ++++-- web/src/app/store/store-client.tsx | 27 ++++++++++++--------------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts index 13d1d3ab2..779c9fe43 100644 --- a/web/src/app/sitemap.ts +++ b/web/src/app/sitemap.ts @@ -4,8 +4,7 @@ import { getCachedAgents } from '@/server/agents-data' import type { MetadataRoute } from 'next' export default async function sitemap(): Promise { - const base = env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'http://localhost:3000' - const toUrl = (path: string) => `${base}${path}` + const toUrl = (path: string) => `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}${path}` const items: MetadataRoute.Sitemap = [ { @@ -46,14 +45,20 @@ export default async function sitemap(): Promise { if (pubId && agent.id && agent.version) { items.push({ - url: toUrl(`/publishers/${pubId}/agents/${agent.id}/${agent.version}`), + url: toUrl( + `/publishers/${pubId}/agents/${agent.id}/${agent.version}`, + ), lastModified: new Date(agent.last_used || agent.created_at), changeFrequency: 'daily', priority: 0.8, }) } } - } catch { + } catch (error) { + console.error( + '[Sitemap] Failed to fetch agents for sitemap generation:', + error, + ) // If fetching fails, fall back to base entries only } diff --git a/web/src/app/store/page.tsx b/web/src/app/store/page.tsx index 53592c9d6..1722ce1a7 100644 --- a/web/src/app/store/page.tsx +++ b/web/src/app/store/page.tsx @@ -16,7 +16,8 @@ export async function generateMetadata(): Promise { }> = [] try { agents = await getCachedAgents() - } catch { + } catch (error) { + console.error('[Store] Failed to fetch agents for metadata:', error) agents = [] } const count = agents.length @@ -60,7 +61,8 @@ export default async function StorePage({ searchParams }: StorePageProps) { let agentsData: any[] = [] try { agentsData = await getCachedAgents() - } catch { + } catch (error) { + console.error('[Store] Failed to fetch agents data:', error) agentsData = [] } diff --git a/web/src/app/store/store-client.tsx b/web/src/app/store/store-client.tsx index 6ef3560d9..ad6e00b28 100644 --- a/web/src/app/store/store-client.tsx +++ b/web/src/app/store/store-client.tsx @@ -186,21 +186,18 @@ export default function AgentStoreClient({ }, [isLoadingMore, hasMore]) // Hydrate agents client-side if SSR provided none (build-time fallback) - const [hydratedAgents, setHydratedAgents] = useState(null) - useEffect(() => { - let cancelled = false - if ((initialAgents?.length ?? 0) === 0) { - fetch('/api/agents') - .then((res) => (res.ok ? res.json() : Promise.reject(res.statusText))) - .then((data: AgentData[]) => { - if (!cancelled) setHydratedAgents(data) - }) - .catch(() => {}) - } - return () => { - cancelled = true - } - }, [initialAgents]) + const { data: hydratedAgents } = useQuery({ + queryKey: ['agents'], + queryFn: async () => { + const response = await fetch('/api/agents') + if (!response.ok) { + throw new Error(`Failed to fetch agents: ${response.statusText}`) + } + return response.json() + }, + enabled: (initialAgents?.length ?? 0) === 0, + staleTime: 600000, // 10 minutes + }) // Prefer hydrated data if present; else use SSR data const agents = useMemo(() => { From 81440b8bc98a68d6d61f2ed5041e7a20a9a41211 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 4 Nov 2025 16:56:37 -0800 Subject: [PATCH 20/20] refactor(web): use env var for Playwright port configuration Use NEXT_PUBLIC_WEB_PORT env var in Playwright config instead of hardcoding port 3001. This allows Playwright to use the same port as the dev server and prevents conflicts when running tests locally. Also removed E2E_ENABLE_QUERY_FIXTURE env var since we removed that logic from production code in favor of Playwright's route mocking. --- web/playwright.config.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index d9bc5035a..6a1c81ea4 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig, devices } from '@playwright/test' +// Use the same port as the dev server, defaulting to 3000 +const PORT = process.env.NEXT_PUBLIC_WEB_PORT || '3000' +const BASE_URL = `http://127.0.0.1:${PORT}` + export default defineConfig({ testDir: './src/__tests__/e2e', fullyParallel: true, @@ -8,7 +12,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: 'http://127.0.0.1:3001', + baseURL: BASE_URL, trace: 'on-first-retry', }, @@ -28,11 +32,8 @@ export default defineConfig({ ], webServer: { - command: 'NEXT_PUBLIC_WEB_PORT=3001 bun run dev', - url: 'http://127.0.0.1:3001', + command: `NEXT_PUBLIC_WEB_PORT=${PORT} bun run dev`, + url: BASE_URL, reuseExistingServer: !process.env.CI, - env: { - E2E_ENABLE_QUERY_FIXTURE: '1', - }, }, })