- Rewrite useWorkspaceTools hook to use @tanstack/react-query + Eden - Add QueryClientProvider to app layout - Remove syncWorkspaceTools/listWorkspaceToolsForContext from database layer - Remove workspace tool inventory sync from service and worker - Tools are now fetched directly from the API, not materialized to Convex
Unified · Split
| @@ -27,7 +27,6 @@ type MutationName = · 26 unmodified lines | |||
| 27 | | "database:upsertAccessPolicy" | 27 | | "database:upsertAccessPolicy" |
| 28 | | "database:upsertCredential" | 28 | | "database:upsertCredential" |
| 29 | | "database:upsertToolSource" | 29 | | "database:upsertToolSource" |
| 30 | | "database:syncWorkspaceTools" | ||
| 31 | | "database:deleteToolSource" | 30 | | "database:deleteToolSource" |
| 32 | | "database:createTaskEvent"; | 31 | | "database:createTaskEvent"; |
| 33 | 32 | ||
| @@ -46,7 +45,6 @@ type QueryName = · 12 unmodified lines | |||
| 46 | | "database:resolveCredential" | 45 | | "database:resolveCredential" |
| 47 | | "database:listToolSources" | 46 | | "database:listToolSources" |
| 48 | | "database:listToolSourceWorkspaceUpdates" | 47 | | "database:listToolSourceWorkspaceUpdates" |
| 49 | | "database:listWorkspaceToolsForContext" | ||
| 50 | | "database:listTaskEvents"; | 48 | | "database:listTaskEvents"; |
| 51 | 49 | ||
| 52 | export class ExecutorDatabase { | 50 | export class ExecutorDatabase { |
| @@ -225,21 +223,6 @@ export class ExecutorDatabase { · 172 unmodified lines | |||
| 225 | return await this.query("database:listToolSourceWorkspaceUpdates", {}); | 223 | return await this.query("database:listToolSourceWorkspaceUpdates", {}); |
| 226 | } | 224 | } |
| 227 | 225 | ||
| 228 | async syncWorkspaceTools(params: { | ||
| 229 | workspaceId: string; | ||
| 230 | tools: ToolDescriptor[]; | ||
| 231 | }): Promise<boolean> { | ||
| 232 | return await this.mutation("database:syncWorkspaceTools", params); | ||
| 233 | } | ||
| 234 | |||
| 235 | async listWorkspaceToolsForContext(params: { | ||
| 236 | workspaceId: string; | ||
| 237 | actorId?: string; | ||
| 238 | clientId?: string; | ||
| 239 | }): Promise<ToolDescriptor[]> { | ||
| 240 | return await this.query("database:listWorkspaceToolsForContext", params); | ||
| 241 | } | ||
| 242 | |||
| 243 | async deleteToolSource(workspaceId: string, sourceId: string): Promise<boolean> { | 226 | async deleteToolSource(workspaceId: string, sourceId: string): Promise<boolean> { |
| 244 | return await this.mutation("database:deleteToolSource", { workspaceId, sourceId }); | 227 | return await this.mutation("database:deleteToolSource", { workspaceId, sourceId }); |
| 245 | } | 228 | } |
| @@ -131,7 +131,6 @@ export class ExecutorService { · 130 unmodified lines | |||
| 131 | string, | 131 | string, |
| 132 | { signature: string; loadedAt: number; tools: Map<string, ToolDefinition> } | 132 | { signature: string; loadedAt: number; tools: Map<string, ToolDefinition> } |
| 133 | >(); | 133 | >(); |
| 134 | private readonly workspaceToolInventorySignatures = new Map<string, string>(); | ||
| 135 | private readonly workspaceToolLoadWarnings = new Map<string, string[]>(); | 134 | private readonly workspaceToolLoadWarnings = new Map<string, string[]>(); |
| 136 | private readonly inFlightTaskIds = new Set<string>(); | 135 | private readonly inFlightTaskIds = new Set<string>(); |
| 137 | private readonly autoExecuteTasks: boolean; | 136 | private readonly autoExecuteTasks: boolean; |
| @@ -491,9 +490,6 @@ export class ExecutorService { · 353 unmodified lines | |||
| 491 | const signature = sourceSignature(workspaceId, sources); | 490 | const signature = sourceSignature(workspaceId, sources); |
| 492 | const cached = this.workspaceToolCache.get(workspaceId); | 491 | const cached = this.workspaceToolCache.get(workspaceId); |
| 493 | if (cached && cached.signature === signature) { | 492 | if (cached && cached.signature === signature) { |
| 494 | if (this.workspaceToolInventorySignatures.get(workspaceId) !== signature) { | ||
| 495 | await this.syncWorkspaceToolInventory(workspaceId, cached.tools, signature); | ||
| 496 | } | ||
| 497 | return cached.tools; | 493 | return cached.tools; |
| 498 | } | 494 | } |
| 499 | 495 | ||
| @@ -531,32 +527,9 @@ export class ExecutorService { · 31 unmodified lines | |||
| 531 | tools: merged, | 527 | tools: merged, |
| 532 | }); | 528 | }); |
| 533 | 529 | ||
| 534 | await this.syncWorkspaceToolInventory(workspaceId, merged, signature); | ||
| 535 | |||
| 536 | return merged; | 530 | return merged; |
| 537 | } | 531 | } |
| 538 | 532 | ||
| 539 | private async syncWorkspaceToolInventory( | ||
| 540 | workspaceId: string, | ||
| 541 | tools: Map<string, ToolDefinition>, | ||
| 542 | signature: string, | ||
| 543 | ): Promise<void> { | ||
| 544 | const inventory = [...tools.values()].map((tool) => ({ | ||
| 545 | path: tool.path, | ||
| 546 | description: tool.description, | ||
| 547 | approval: tool.approval, | ||
| 548 | source: tool.source, | ||
| 549 | argsType: tool.metadata?.argsType, | ||
| 550 | returnsType: tool.metadata?.returnsType, | ||
| 551 | })); | ||
| 552 | |||
| 553 | await this.db.syncWorkspaceTools({ | ||
| 554 | workspaceId, | ||
| 555 | tools: inventory, | ||
| 556 | }); | ||
| 557 | this.workspaceToolInventorySignatures.set(workspaceId, signature); | ||
| 558 | } | ||
| 559 | |||
| 560 | private getToolDecision( | 533 | private getToolDecision( |
| 561 | task: TaskRecord, | 534 | task: TaskRecord, |
| 562 | tool: ToolDefinition, | 535 | tool: ToolDefinition, |
| @@ -51,7 +51,6 @@ const convexClient = new ConvexClient(convexUrl, { · 50 unmodified lines | |||
| 51 | }); | 51 | }); |
| 52 | 52 | ||
| 53 | let draining = false; | 53 | let draining = false; |
| 54 | let syncingToolInventories = false; | ||
| 55 | 54 | ||
| 56 | async function drainQueue(trigger: string): Promise<void> { | 55 | async function drainQueue(trigger: string): Promise<void> { |
| 57 | if (draining) { | 56 | if (draining) { |
| @@ -79,30 +78,6 @@ async function drainQueue(trigger: string): Promise<void> { · 21 unmodified lines | |||
| 79 | } | 78 | } |
| 80 | } | 79 | } |
| 81 | 80 | ||
| 82 | async function syncWorkspaceToolInventories(trigger: string): Promise<void> { | ||
| 83 | if (syncingToolInventories) { | ||
| 84 | return; | ||
| 85 | } | ||
| 86 | |||
| 87 | syncingToolInventories = true; | ||
| 88 | try { | ||
| 89 | const updates = await service.listToolSourceWorkspaceUpdates(); | ||
| 90 | if (updates.length === 0) { | ||
| 91 | return; | ||
| 92 | } | ||
| 93 | |||
| 94 | console.log(`[worker] ${trigger}: syncing tool inventories for ${updates.length} workspace(s)`); | ||
| 95 | for (const update of updates) { | ||
| 96 | await service.refreshWorkspaceTools(update.workspaceId); | ||
| 97 | } | ||
| 98 | } catch (error) { | ||
| 99 | const message = error instanceof Error ? error.message : String(error); | ||
| 100 | console.error(`[worker] tool inventory sync failed: ${message}`); | ||
| 101 | } finally { | ||
| 102 | syncingToolInventories = false; | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | const queueSubscription = convexClient.onUpdate( | 81 | const queueSubscription = convexClient.onUpdate( |
| 107 | api.database.listQueuedTaskIds, | 82 | api.database.listQueuedTaskIds, |
| 108 | { limit: 1 }, | 83 | { limit: 1 }, |
| @@ -116,31 +91,15 @@ const queueSubscription = convexClient.onUpdate( · 7 unmodified lines | |||
| 116 | }, | 91 | }, |
| 117 | ); | 92 | ); |
| 118 | 93 | ||
| 119 | const toolSourceSubscription = convexClient.onUpdate( | ||
| 120 | api.database.listToolSourceWorkspaceUpdates, | ||
| 121 | {}, | ||
| 122 | (updates) => { | ||
| 123 | if (updates.length > 0) { | ||
| 124 | void syncWorkspaceToolInventories("onUpdate"); | ||
| 125 | } | ||
| 126 | }, | ||
| 127 | (error) => { | ||
| 128 | console.warn(`[worker] tool source watcher error: ${error.message}`); | ||
| 129 | }, | ||
| 130 | ); | ||
| 131 | |||
| 132 | const interval = setInterval(() => { | 94 | const interval = setInterval(() => { |
| 133 | void drainQueue("interval"); | 95 | void drainQueue("interval"); |
| 134 | void syncWorkspaceToolInventories("interval"); | ||
| 135 | }, pollMs); | 96 | }, pollMs); |
| 136 | 97 | ||
| 137 | await drainQueue("startup"); | 98 | await drainQueue("startup"); |
| 138 | await syncWorkspaceToolInventories("startup"); | ||
| 139 | 99 | ||
| 140 | function shutdown(signal: string): void { | 100 | function shutdown(signal: string): void { |
| 141 | console.log(`[worker] received ${signal}, shutting down...`); | 101 | console.log(`[worker] received ${signal}, shutting down...`); |
| 142 | queueSubscription.unsubscribe(); | 102 | queueSubscription.unsubscribe(); |
| 143 | toolSourceSubscription.unsubscribe(); | ||
| 144 | clearInterval(interval); | 103 | clearInterval(interval); |
| 145 | void convexClient.close(); | 104 | void convexClient.close(); |
| 146 | process.exit(0); | 105 | process.exit(0); |
| @@ -2,6 +2,7 @@ import type { Metadata } from "next"; · 1 unmodified line | |||
| 2 | import { JetBrains_Mono, Inter } from "next/font/google"; | 2 | import { JetBrains_Mono, Inter } from "next/font/google"; |
| 3 | import { Toaster } from "sonner"; | 3 | import { Toaster } from "sonner"; |
| 4 | import { AppConvexProvider } from "@/lib/convex-provider"; | 4 | import { AppConvexProvider } from "@/lib/convex-provider"; |
| 5 | import { QueryProvider } from "@/lib/query-provider"; | ||
| 5 | import { SessionProvider } from "@/lib/session-context"; | 6 | import { SessionProvider } from "@/lib/session-context"; |
| 6 | import "./globals.css"; | 7 | import "./globals.css"; |
| 7 | 8 | ||
| @@ -31,9 +32,11 @@ export default function RootLayout({ · 23 unmodified lines | |||
| 31 | className={`${inter.variable} ${jetbrainsMono.variable} antialiased`} | 32 | className={`${inter.variable} ${jetbrainsMono.variable} antialiased`} |
| 32 | > | 33 | > |
| 33 | <AppConvexProvider> | 34 | <AppConvexProvider> |
| 34 | <SessionProvider> | 35 | <QueryProvider> |
| 35 | {children} | 36 | <SessionProvider> |
| 36 | </SessionProvider> | 37 | {children} |
| 38 | </SessionProvider> | ||
| 39 | </QueryProvider> | ||
| 37 | </AppConvexProvider> | 40 | </AppConvexProvider> |
| 38 | <Toaster | 41 | <Toaster |
| 39 | theme="dark" | 42 | theme="dark" |
| @@ -251,7 +251,7 @@ export function DashboardView() { · 250 unmodified lines | |||
| 251 | </div> | 251 | </div> |
| 252 | 252 | ||
| 253 | {/* Tools summary */} | 253 | {/* Tools summary */} |
| 254 | {tools && tools.length > 0 && ( | 254 | {tools.length > 0 && ( |
| 255 | <Card className="bg-card border-border"> | 255 | <Card className="bg-card border-border"> |
| 256 | <CardHeader className="pb-3"> | 256 | <CardHeader className="pb-3"> |
| 257 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | 257 | <CardTitle className="text-sm font-medium flex items-center gap-2"> |
| @@ -304,24 +304,13 @@ function AddSourceDialog({ · 303 unmodified lines | |||
| 304 | config: Record<string, unknown>, | 304 | config: Record<string, unknown>, |
| 305 | ) => { | 305 | ) => { |
| 306 | if (!context) return; | 306 | if (!context) return; |
| 307 | // Write the tool source to the database | ||
| 308 | await upsertToolSource({ | 307 | await upsertToolSource({ |
| 309 | workspaceId: context.workspaceId, | 308 | workspaceId: context.workspaceId, |
| 310 | name: sourceName, | 309 | name: sourceName, |
| 311 | type: sourceType, | 310 | type: sourceType, |
| 312 | config, | 311 | config, |
| 313 | }); | 312 | }); |
| 314 | toast.success(`Source "${sourceName}" added`); | 313 | toast.success(`Source "${sourceName}" added — loading tools…`); |
| 315 | |||
| 316 | // Trigger a server-side tool refresh so the workspace tools inventory | ||
| 317 | // is updated immediately (instead of waiting for the worker poll). | ||
| 318 | // Fetching the tools list forces the server to load external sources | ||
| 319 | // and sync the workspaceTools table. | ||
| 320 | try { | ||
| 321 | await fetch(`/api/tools?workspaceId=${encodeURIComponent(context.workspaceId)}`); | ||
| 322 | } catch { | ||
| 323 | // Server-side refresh is best-effort; the worker will eventually sync. | ||
| 324 | } | ||
| 325 | }; | 314 | }; |
| 326 | 315 | ||
| 327 | const handlePresetAdd = async (preset: ApiPreset) => { | 316 | const handlePresetAdd = async (preset: ApiPreset) => { |
| @@ -763,11 +752,9 @@ export function ToolsView() { · 435 unmodified lines | |||
| 763 | </TabsTrigger> | 752 | </TabsTrigger> |
| 764 | <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background"> | 753 | <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background"> |
| 765 | Inventory | 754 | Inventory |
| 766 | {tools && ( | 755 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| 767 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> | 756 | {toolsLoading ? "…" : tools.length} |
| 768 | {tools.length} | 757 | </span> |
| 769 | </span> | ||
| 770 | )} | ||
| 771 | </TabsTrigger> | 758 | </TabsTrigger> |
| 772 | </TabsList> | 759 | </TabsList> |
| 773 | 760 | ||
| @@ -818,11 +805,9 @@ export function ToolsView() { · 44 unmodified lines | |||
| 818 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | 805 | <CardTitle className="text-sm font-medium flex items-center gap-2"> |
| 819 | <Zap className="h-4 w-4 text-primary" /> | 806 | <Zap className="h-4 w-4 text-primary" /> |
| 820 | Available Tools | 807 | Available Tools |
| 821 | {tools && ( | 808 | <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded"> |
| 822 | <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded"> | 809 | {toolsLoading ? "…" : tools.length} |
| 823 | {tools.length} | 810 | </span> |
| 824 | </span> | ||
| 825 | )} | ||
| 826 | </CardTitle> | 811 | </CardTitle> |
| 827 | </CardHeader> | 812 | </CardHeader> |
| 828 | <CardContent className="pt-0"> | 813 | <CardContent className="pt-0"> |
| @@ -833,7 +818,7 @@ export function ToolsView() { · 4 unmodified lines | |||
| 833 | ))} | 818 | ))} |
| 834 | </div> | 819 | </div> |
| 835 | ) : ( | 820 | ) : ( |
| 836 | <ToolInventory tools={tools ?? []} /> | 821 | <ToolInventory tools={tools} /> |
| 837 | )} | 822 | )} |
| 838 | </CardContent> | 823 | </CardContent> |
| 839 | </Card> | 824 | </Card> |
| @@ -1,7 +1,10 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useQuery } from "convex/react"; | 3 | import { useQuery as useTanstackQuery } from "@tanstack/react-query"; |
| 4 | import { convexApi } from "../lib/convex-api"; | 4 | import { useQuery as useConvexQuery } from "convex/react"; |
| 5 | import { convexApi } from "@/lib/convex-api"; | ||
| 6 | import { executor } from "@/lib/executor-client"; | ||
| 7 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 5 | 8 | ||
| 6 | interface WorkspaceContext { | 9 | interface WorkspaceContext { |
| 7 | workspaceId: string; | 10 | workspaceId: string; |
| @@ -9,20 +12,45 @@ interface WorkspaceContext { · 1 unmodified line | |||
| 9 | clientId?: string; | 12 | clientId?: string; |
| 10 | } | 13 | } |
| 11 | 14 | ||
| 15 | /** | ||
| 16 | * Fetches tool metadata from the server API (`GET /api/tools`) via Eden Treaty, | ||
| 17 | * cached and deduplicated by TanStack Query. | ||
| 18 | * | ||
| 19 | * Automatically re-fetches when the Convex `toolSources` subscription changes | ||
| 20 | * (the reactive value is included in the query key). | ||
| 21 | */ | ||
| 12 | export function useWorkspaceTools(context: WorkspaceContext | null) { | 22 | export function useWorkspaceTools(context: WorkspaceContext | null) { |
| 13 | const tools = useQuery( | 23 | // Watch tool sources reactively so we invalidate when sources change |
| 14 | convexApi.database.listWorkspaceToolsForContext, | 24 | const toolSources = useConvexQuery( |
| 15 | context | 25 | convexApi.database.listToolSources, |
| 16 | ? { | 26 | context ? { workspaceId: context.workspaceId } : "skip", |
| 17 | workspaceId: context.workspaceId, | ||
| 18 | actorId: context.actorId, | ||
| 19 | clientId: context.clientId, | ||
| 20 | } | ||
| 21 | : "skip", | ||
| 22 | ); | 27 | ); |
| 23 | 28 | ||
| 29 | const { data, isLoading } = useTanstackQuery({ | ||
| 30 | queryKey: [ | ||
| 31 | "workspace-tools", | ||
| 32 | context?.workspaceId, | ||
| 33 | context?.actorId, | ||
| 34 | context?.clientId, | ||
| 35 | toolSources, | ||
| 36 | ], | ||
| 37 | queryFn: async () => { | ||
| 38 | if (!context) return []; | ||
| 39 | const { data, error } = await executor.api.tools.get({ | ||
| 40 | query: { | ||
| 41 | workspaceId: context.workspaceId, | ||
| 42 | ...(context.actorId && { actorId: context.actorId }), | ||
| 43 | ...(context.clientId && { clientId: context.clientId }), | ||
| 44 | }, | ||
| 45 | }); | ||
| 46 | if (error) throw error; | ||
| 47 | return data as ToolDescriptor[]; | ||
| 48 | }, | ||
| 49 | enabled: !!context, | ||
| 50 | }); | ||
| 51 | |||
| 24 | return { | 52 | return { |
| 25 | tools, | 53 | tools: data ?? [], |
| 26 | loading: !!context && tools === undefined, | 54 | loading: !!context && isLoading, |
| 27 | }; | 55 | }; |
| 28 | } | 56 | } |
| @@ -0,0 +1,9 @@ | |||
| 1 | import { QueryClient } from "@tanstack/react-query"; | ||
| 2 | |||
| 3 | export const queryClient = new QueryClient({ | ||
| 4 | defaultOptions: { | ||
| 5 | queries: { | ||
| 6 | staleTime: 30_000, // tools don't change that often | ||
| 7 | }, | ||
| 8 | }, | ||
| 9 | }); | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { QueryClientProvider } from "@tanstack/react-query"; | ||
| 4 | import { queryClient } from "./query-client"; | ||
| 5 | import type { ReactNode } from "react"; | ||
| 6 | |||
| 7 | export function QueryProvider({ children }: { children: ReactNode }) { | ||
| 8 | return ( | ||
| 9 | <QueryClientProvider client={queryClient}> | ||
| 10 | {children} | ||
| 11 | </QueryClientProvider> | ||
| 12 | ); | ||
| 13 | } | ||