Track workspace tool-source updates in Convex and have the worker refresh cached tool inventory so the web UI stays live without calling /api/tools for warmup.
Unified · Split
| @@ -45,6 +45,7 @@ type QueryName = · 44 unmodified lines | ||
| 45 | 45 | | "database:listCredentials" |
| 46 | 46 | | "database:resolveCredential" |
| 47 | 47 | | "database:listToolSources" |
| 48 | + | "database:listToolSourceWorkspaceUpdates" | |
| 48 | 49 | | "database:listWorkspaceToolsForContext" |
| 49 | 50 | | "database:listTaskEvents"; |
| 50 | 51 | |
| @@ -210,6 +211,10 @@ export class ExecutorDatabase { · 159 unmodified lines | ||
| 210 | 211 | return await this.query("database:listToolSources", { workspaceId }); |
| 211 | 212 | } |
| 212 | 213 | |
| 214 | + async listToolSourceWorkspaceUpdates(): Promise<Array<{ workspaceId: string; updatedAt: number }>> { | |
| 215 | + return await this.query("database:listToolSourceWorkspaceUpdates", {}); | |
| 216 | + } | |
| 217 | + | |
| 213 | 218 | async syncWorkspaceTools(params: { |
| 214 | 219 | workspaceId: string; |
| 215 | 220 | tools: ToolDescriptor[]; |
| @@ -213,6 +213,14 @@ export class ExecutorService { · 212 unmodified lines | ||
| 213 | 213 | return await this.db.listToolSources(workspaceId); |
| 214 | 214 | } |
| 215 | 215 | |
| 216 | + async listToolSourceWorkspaceUpdates(): Promise<Array<{ workspaceId: string; updatedAt: number }>> { | |
| 217 | + return await this.db.listToolSourceWorkspaceUpdates(); | |
| 218 | + } | |
| 219 | + | |
| 220 | + async refreshWorkspaceTools(workspaceId: string): Promise<void> { | |
| 221 | + await this.getWorkspaceTools(workspaceId); | |
| 222 | + } | |
| 223 | + | |
| 216 | 224 | async upsertToolSource(input: { |
| 217 | 225 | id?: string; |
| 218 | 226 | workspaceId: string; |
| @@ -51,6 +51,7 @@ const convexClient = new ConvexClient(convexUrl, { · 50 unmodified lines | ||
| 51 | 51 | }); |
| 52 | 52 | |
| 53 | 53 | let draining = false; |
| 54 | +let syncingToolInventories = false; | |
| 54 | 55 | |
| 55 | 56 | async function drainQueue(trigger: string): Promise<void> { |
| 56 | 57 | if (draining) { |
| @@ -78,6 +79,30 @@ async function drainQueue(trigger: string): Promise<void> { · 21 unmodified lines | ||
| 78 | 79 | } |
| 79 | 80 | } |
| 80 | 81 | |
| 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 | + | |
| 81 | 106 | const queueSubscription = convexClient.onUpdate( |
| 82 | 107 | api.database.listQueuedTaskIds, |
| 83 | 108 | { limit: 1 }, |
| @@ -91,15 +116,31 @@ const queueSubscription = convexClient.onUpdate( · 7 unmodified lines | ||
| 91 | 116 | }, |
| 92 | 117 | ); |
| 93 | 118 | |
| 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 | + | |
| 94 | 132 | const interval = setInterval(() => { |
| 95 | 133 | void drainQueue("interval"); |
| 134 | + void syncWorkspaceToolInventories("interval"); | |
| 96 | 135 | }, pollMs); |
| 97 | 136 | |
| 98 | 137 | await drainQueue("startup"); |
| 138 | +await syncWorkspaceToolInventories("startup"); | |
| 99 | 139 | |
| 100 | 140 | function shutdown(signal: string): void { |
| 101 | 141 | console.log(`[worker] received ${signal}, shutting down...`); |
| 102 | 142 | queueSubscription.unsubscribe(); |
| 143 | + toolSourceSubscription.unsubscribe(); | |
| 103 | 144 | clearInterval(interval); |
| 104 | 145 | void convexClient.close(); |
| 105 | 146 | process.exit(0); |
| @@ -5,7 +5,6 @@ import { · 4 unmodified lines | ||
| 5 | 5 | Wrench, |
| 6 | 6 | Plus, |
| 7 | 7 | Trash2, |
| 8 | − RefreshCw, | |
| 9 | 8 | ShieldCheck, |
| 10 | 9 | Globe, |
| 11 | 10 | Server, |
| @@ -699,17 +698,7 @@ export function ToolsView() { · 687 unmodified lines | ||
| 699 | 698 | <PageHeader |
| 700 | 699 | title="Tools" |
| 701 | 700 | description="Manage tool sources and view available tools" |
| 702 | − > | |
| 703 | − <Button | |
| 704 | − variant="outline" | |
| 705 | − size="sm" | |
| 706 | − className="h-8 text-xs" | |
| 707 | − onClick={() => void refreshInventory()} | |
| 708 | − > | |
| 709 | − <RefreshCw className="h-3.5 w-3.5 mr-1.5" /> | |
| 710 | − Refresh | |
| 711 | − </Button> | |
| 712 | − </PageHeader> | |
| 701 | + /> | |
| 713 | 702 | |
| 714 | 703 | <Tabs defaultValue="sources" className="w-full"> |
| 715 | 704 | <TabsList className="bg-muted/50 h-9"> |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useCallback, useEffect } from "react"; | |
| 3 | +import { useCallback } from "react"; | |
| 4 | 4 | import { useQuery } from "convex/react"; |
| 5 | 5 | import { convexApi } from "../lib/convex-api"; |
| 6 | −import * as api from "../lib/api"; | |
| 7 | 6 | |
| 8 | 7 | interface WorkspaceContext { |
| 9 | 8 | workspaceId: string; |
| @@ -24,21 +23,8 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 14 unmodified lines | ||
| 24 | 23 | ); |
| 25 | 24 | |
| 26 | 25 | const refresh = useCallback(async () => { |
| 27 | − if (!context) return; | |
| 28 | − try { | |
| 29 | − await api.listToolsForContext({ | |
| 30 | − workspaceId: context.workspaceId, | |
| 31 | − actorId: context.actorId, | |
| 32 | − clientId: context.clientId, | |
| 33 | − }); | |
| 34 | − } catch { | |
| 35 | − // best effort warm-up for server-side tool discovery sync | |
| 36 | − } | |
| 37 | − }, [context]); | |
| 38 | − | |
| 39 | − useEffect(() => { | |
| 40 | − void refresh(); | |
| 41 | − }, [refresh]); | |
| 26 | + // Convex queries are live; manual refresh is not required. | |
| 27 | + }, []); | |
| 42 | 28 | |
| 43 | 29 | return { |
| 44 | 30 | tools, |
| @@ -763,6 +763,25 @@ export const listToolSources = query({ · 762 unmodified lines | ||
| 763 | 763 | }, |
| 764 | 764 | }); |
| 765 | 765 | |
| 766 | +export const listToolSourceWorkspaceUpdates = query({ | |
| 767 | + args: {}, | |
| 768 | + handler: async (ctx) => { | |
| 769 | + const docs = await ctx.db.query("toolSources").collect(); | |
| 770 | + const byWorkspace = new Map<string, number>(); | |
| 771 | + | |
| 772 | + for (const doc of docs) { | |
| 773 | + const existing = byWorkspace.get(doc.workspaceId) ?? 0; | |
| 774 | + if (doc.updatedAt > existing) { | |
| 775 | + byWorkspace.set(doc.workspaceId, doc.updatedAt); | |
| 776 | + } | |
| 777 | + } | |
| 778 | + | |
| 779 | + return [...byWorkspace.entries()] | |
| 780 | + .map(([workspaceId, updatedAt]) => ({ workspaceId, updatedAt })) | |
| 781 | + .sort((a, b) => a.workspaceId.localeCompare(b.workspaceId)); | |
| 782 | + }, | |
| 783 | +}); | |
| 784 | + | |
| 766 | 785 | export const syncWorkspaceTools = mutation({ |
| 767 | 786 | args: { |
| 768 | 787 | workspaceId: v.string(), |