Unified · Split
| @@ -79,11 +79,6 @@ bun run kill:all · 78 unmodified lines | ||
| 79 | 79 | # Development |
| 80 | 80 | bun run dev |
| 81 | 81 | bun run kill:all |
| 82 | −bun run dev:executor:convex | |
| 83 | −bun run dev:executor:web | |
| 84 | −bun run dev:executor:menubar | |
| 85 | −bun run dev:assistant | |
| 86 | −bun run dev:bot | |
| 87 | 82 | |
| 88 | 83 | # Quality |
| 89 | 84 | bun run test |
| @@ -4,8 +4,6 @@ · 3 unmodified lines | ||
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | 6 | "test": "bun test packages/core/src/", |
| 7 | − "dev:server": "bun run --filter @assistant/server dev", | |
| 8 | − "dev:bot": "bun run --filter @assistant/bot dev", | |
| 9 | 7 | "typecheck": "bun run --cwd packages/server typecheck && bun run --cwd packages/bot typecheck && bun run --cwd packages/core typecheck && bun run --cwd packages/reacord typecheck", |
| 10 | 8 | "typecheck:slow": "bun run --cwd packages/server typecheck:slow && bun run --cwd packages/bot typecheck:slow && bun run --cwd packages/core typecheck:slow && bun run --cwd packages/reacord typecheck:slow", |
| 11 | 9 | "clean": "rm -rf node_modules .turbo" |
| @@ -213,6 +213,11 @@ function buildSourceTreeWithLoading( · 212 unmodified lines | ||
| 213 | 213 | continue; |
| 214 | 214 | } |
| 215 | 215 | |
| 216 | + const hasVisibleChildren = existing.children.length > 0 || existing.childCount > 0; | |
| 217 | + if (hasVisibleChildren) { | |
| 218 | + continue; | |
| 219 | + } | |
| 220 | + | |
| 216 | 221 | groupsBySource.set(sourceName, { |
| 217 | 222 | ...existing, |
| 218 | 223 | loadingPlaceholderCount: existing.loadingPlaceholderCount ?? 3, |
| @@ -229,6 +229,7 @@ export function NavGroupNode({ · 228 unmodified lines | ||
| 229 | 229 | onFocusTool, |
| 230 | 230 | onSourceClick, |
| 231 | 231 | source, |
| 232 | + sourceState, | |
| 232 | 233 | }: { |
| 233 | 234 | group: ToolGroup; |
| 234 | 235 | depth: number; |
| @@ -239,14 +240,24 @@ export function NavGroupNode({ · 4 unmodified lines | ||
| 239 | 240 | onFocusTool: (tool: ToolDescriptor) => void; |
| 240 | 241 | onSourceClick?: (sourceName: string) => void; |
| 241 | 242 | source?: ToolSourceRecord; |
| 243 | + sourceState?: { | |
| 244 | + state: "queued" | "loading" | "indexing" | "ready" | "failed"; | |
| 245 | + toolCount: number; | |
| 246 | + processedTools?: number; | |
| 247 | + message?: string; | |
| 248 | + error?: string; | |
| 249 | + }; | |
| 242 | 250 | }) { |
| 243 | 251 | const isExpanded = expandedKeys.has(group.key); |
| 244 | 252 | const isSource = group.type === "source"; |
| 245 | 253 | const isSourceFocused = isSource && focusedSource === group.label; |
| 246 | − const isLoading = | |
| 254 | + const isLoadingFromTree = | |
| 247 | 255 | group.type === "source" && |
| 248 | 256 | typeof group.loadingPlaceholderCount === "number" && |
| 249 | 257 | group.loadingPlaceholderCount > 0; |
| 258 | + const isLoadingFromState = sourceState?.state === "queued" || sourceState?.state === "loading" || sourceState?.state === "indexing"; | |
| 259 | + const isLoading = isLoadingFromTree || isLoadingFromState; | |
| 260 | + const showLoadingRows = isLoading && group.children.length === 0; | |
| 250 | 261 | const sourceTypeFallback: ToolSourceRecord["type"] | "local" | "system" = source |
| 251 | 262 | ? source.type |
| 252 | 263 | : group.label === "system" |
| @@ -317,8 +328,22 @@ export function NavGroupNode({ · 64 unmodified lines | ||
| 317 | 328 | </span> |
| 318 | 329 | |
| 319 | 330 | <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground/60"> |
| 320 | − {isLoading ? ( | |
| 321 | − <Loader2 className="h-2.5 w-2.5 animate-spin" /> | |
| 331 | + {sourceState?.state === "failed" ? ( | |
| 332 | + <span className="inline-flex items-center gap-0.5 text-terminal-red" title={sourceState.error ?? "Source failed"}> | |
| 333 | + <AlertTriangle className="h-2.5 w-2.5" /> | |
| 334 | + </span> | |
| 335 | + ) : isLoading ? ( | |
| 336 | + <span | |
| 337 | + className="inline-flex items-center gap-1" | |
| 338 | + title={sourceState?.message} | |
| 339 | + > | |
| 340 | + <Loader2 className="h-2.5 w-2.5 animate-spin" /> | |
| 341 | + {sourceState?.state === "indexing" && typeof sourceState.processedTools === "number" && sourceState.toolCount > 0 ? ( | |
| 342 | + <span className="tabular-nums text-[10px] text-muted-foreground/70"> | |
| 343 | + {sourceState.processedTools}/{sourceState.toolCount} | |
| 344 | + </span> | |
| 345 | + ) : null} | |
| 346 | + </span> | |
| 322 | 347 | ) : ( |
| 323 | 348 | <> |
| 324 | 349 | {group.approvalCount > 0 && ( |
| @@ -327,7 +352,7 @@ export function NavGroupNode({ · 2 unmodified lines | ||
| 327 | 352 | {group.approvalCount} |
| 328 | 353 | </span> |
| 329 | 354 | )} |
| 330 | − <span className="tabular-nums">{group.childCount}</span> | |
| 355 | + <span className="tabular-nums">{sourceState?.toolCount ?? group.childCount}</span> | |
| 331 | 356 | </> |
| 332 | 357 | )} |
| 333 | 358 | </span> |
| @@ -388,6 +413,7 @@ export function NavGroupNode({ · 54 unmodified lines | ||
| 388 | 413 | focusedSource={focusedSource} |
| 389 | 414 | onFocusTool={onFocusTool} |
| 390 | 415 | onSourceClick={onSourceClick} |
| 416 | + sourceState={sourceState} | |
| 391 | 417 | /> |
| 392 | 418 | ); |
| 393 | 419 | } |
| @@ -405,7 +431,7 @@ export function NavGroupNode({ · 11 unmodified lines | ||
| 405 | 431 | }) |
| 406 | 432 | : null} |
| 407 | 433 | |
| 408 | − {isLoading ? ( | |
| 434 | + {showLoadingRows ? ( | |
| 409 | 435 | <ToolLoadingRows |
| 410 | 436 | source={displayLabel} |
| 411 | 437 | count={group.loadingPlaceholderCount ?? 3} |
| @@ -445,7 +471,7 @@ export function SourceSidebar({ · 33 unmodified lines | ||
| 445 | 471 | onSourceDeleted?: (sourceName: string) => void; |
| 446 | 472 | onRegenerate?: () => void; |
| 447 | 473 | isRebuilding?: boolean; |
| 448 | − inventoryState?: "initializing" | "ready" | "rebuilding" | "stale" | "failed"; | |
| 474 | + inventoryState?: "initializing" | "ready" | "rebuilding" | "failed"; | |
| 449 | 475 | inventoryError?: string; |
| 450 | 476 | }) { |
| 451 | 477 | const warningCountsBySource = useMemo(() => { |
| @@ -464,8 +490,6 @@ export function SourceSidebar({ · 12 unmodified lines | ||
| 464 | 490 | ); |
| 465 | 491 | const loadingSourceCount = loadingSources.size; |
| 466 | 492 | const regenerationInProgress = isRebuilding; |
| 467 | − const inventoryStale = inventoryState === "stale"; | |
| 468 | − | |
| 469 | 493 | const inventoryStatus = useMemo(() => { |
| 470 | 494 | if (!inventoryState) { |
| 471 | 495 | return { |
| @@ -494,13 +518,6 @@ export function SourceSidebar({ · 22 unmodified lines | ||
| 494 | 518 | }; |
| 495 | 519 | } |
| 496 | 520 | |
| 497 | − if (inventoryState === "stale") { | |
| 498 | − return { | |
| 499 | − label: "Inventory out of date", | |
| 500 | − tone: "muted" as const, | |
| 501 | − }; | |
| 502 | − } | |
| 503 | − | |
| 504 | 521 | if (inventoryState === "failed") { |
| 505 | 522 | return { |
| 506 | 523 | label: inventoryError ? `Refresh failed: ${inventoryError}` : "Refresh failed", |
| @@ -566,17 +583,13 @@ export function SourceSidebar({ · 59 unmodified lines | ||
| 566 | 583 | title={ |
| 567 | 584 | regenerationInProgress |
| 568 | 585 | ? "Rebuilding..." |
| 569 | − : inventoryStale | |
| 570 | − ? "Inventory is out of date" | |
| 571 | − : "Regenerate inventory" | |
| 586 | + : "Regenerate inventory" | |
| 572 | 587 | } |
| 573 | 588 | className={cn( |
| 574 | 589 | "p-0.5 rounded transition-colors", |
| 575 | 590 | regenerationInProgress |
| 576 | 591 | ? "text-terminal-amber cursor-not-allowed" |
| 577 | − : inventoryStale | |
| 578 | − ? "text-muted-foreground/70 hover:text-muted-foreground" | |
| 579 | − : "text-muted-foreground/40 hover:text-muted-foreground/70", | |
| 592 | + : "text-muted-foreground/40 hover:text-muted-foreground/70", | |
| 580 | 593 | )} |
| 581 | 594 | > |
| 582 | 595 | <RefreshCcw className={cn("h-3 w-3", regenerationInProgress && "animate-spin")} /> |
| @@ -18,7 +18,6 @@ import { · 17 unmodified lines | ||
| 18 | 18 | type FilterApproval, |
| 19 | 19 | } from "./explorer-derived"; |
| 20 | 20 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| 21 | −import { traverseSchema } from "@/lib/tool/schema-traverse"; | |
| 22 | 21 | import { |
| 23 | 22 | EmptyState, |
| 24 | 23 | LoadingState, |
| @@ -37,27 +36,6 @@ import { warningsBySourceName } from "@/lib/tools/source-helpers"; · 12 unmodified lines | ||
| 37 | 36 | |
| 38 | 37 | // ── Main Explorer ── |
| 39 | 38 | |
| 40 | −function asRecord(value: unknown): Record<string, unknown> { | |
| 41 | − return value && typeof value === "object" && !Array.isArray(value) | |
| 42 | − ? value as Record<string, unknown> | |
| 43 | − : {}; | |
| 44 | −} | |
| 45 | − | |
| 46 | −function parseSchemaJson(value: string): Record<string, unknown> { | |
| 47 | − if (!value) return {}; | |
| 48 | − | |
| 49 | − try { | |
| 50 | − const parsed = JSON.parse(value) as unknown; | |
| 51 | − return asRecord(parsed); | |
| 52 | − } catch { | |
| 53 | − return {}; | |
| 54 | − } | |
| 55 | −} | |
| 56 | − | |
| 57 | −function hasRenderableSchemaFields(schemaJson: string): boolean { | |
| 58 | − return traverseSchema(parseSchemaJson(schemaJson), { maxEntries: 1 }).entries.length > 0; | |
| 59 | −} | |
| 60 | − | |
| 61 | 39 | interface ToolExplorerProps { |
| 62 | 40 | tools: ToolDescriptor[]; |
| 63 | 41 | sources: ToolSourceRecord[]; |
| @@ -71,6 +49,13 @@ interface ToolExplorerProps { · 7 unmodified lines | ||
| 71 | 49 | onLoadMoreToolsForSource?: (source: { source: string; sourceName: string }) => Promise<void>; |
| 72 | 50 | loading?: boolean; |
| 73 | 51 | loadingSources?: string[]; |
| 52 | + sourceStates?: Record<string, { | |
| 53 | + state: "queued" | "loading" | "indexing" | "ready" | "failed"; | |
| 54 | + toolCount: number; | |
| 55 | + processedTools?: number; | |
| 56 | + message?: string; | |
| 57 | + error?: string; | |
| 58 | + }>; | |
| 74 | 59 | onLoadToolDetails?: (toolPaths: string[]) => Promise<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>; |
| 75 | 60 | warnings?: string[]; |
| 76 | 61 | activeSource: string | null; |
| @@ -89,7 +74,7 @@ interface ToolExplorerProps { · 12 unmodified lines | ||
| 89 | 74 | onSourceDeleted?: (sourceName: string) => void; |
| 90 | 75 | onRegenerate?: () => void; |
| 91 | 76 | isRebuilding?: boolean; |
| 92 | − inventoryState?: "initializing" | "ready" | "rebuilding" | "stale" | "failed"; | |
| 77 | + inventoryState?: "initializing" | "ready" | "rebuilding" | "failed"; | |
| 93 | 78 | inventoryError?: string; |
| 94 | 79 | } |
| 95 | 80 | |
| @@ -103,6 +88,7 @@ export function ToolExplorer({ · 7 unmodified lines | ||
| 103 | 88 | sourceLoadingMoreTools, |
| 104 | 89 | loading = false, |
| 105 | 90 | loadingSources = [], |
| 91 | + sourceStates = {}, | |
| 106 | 92 | onLoadToolDetails, |
| 107 | 93 | warnings = [], |
| 108 | 94 | activeSource, |
| @@ -124,21 +110,6 @@ export function ToolExplorer({ · 15 unmodified lines | ||
| 124 | 110 | inventoryState, |
| 125 | 111 | inventoryError, |
| 126 | 112 | }: ToolExplorerProps) { |
| 127 | − const hasRenderableToolDetails = useCallback((tool: Pick<ToolDescriptor, "description" | "display" | "typing">) => { | |
| 128 | − const description = tool.description?.trim() ?? ""; | |
| 129 | − const inputHint = tool.display?.input?.trim() ?? ""; | |
| 130 | − const outputHint = tool.display?.output?.trim() ?? ""; | |
| 131 | − const inputSchemaJson = tool.typing?.inputSchemaJson?.trim() ?? ""; | |
| 132 | − const outputSchemaJson = tool.typing?.outputSchemaJson?.trim() ?? ""; | |
| 133 | − | |
| 134 | − const hasInputHint = inputHint.length > 0 && inputHint !== "{}" && inputHint.toLowerCase() !== "unknown"; | |
| 135 | − const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown"; | |
| 136 | − const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}" && hasRenderableSchemaFields(inputSchemaJson); | |
| 137 | − const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}" && hasRenderableSchemaFields(outputSchemaJson); | |
| 138 | − | |
| 139 | − return description.length > 0 || hasInputHint || hasOutputHint || hasInputSchema || hasOutputSchema; | |
| 140 | − }, []); | |
| 141 | − | |
| 142 | 113 | const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({}); |
| 143 | 114 | const [loadingDetailPaths, setLoadingDetailPaths] = useState<Set<string>>(new Set()); |
| 144 | 115 | // "new" = add source form, ToolSourceRecord = edit/view existing source, null = no source panel |
| @@ -280,9 +251,7 @@ export function ToolExplorer({ · 135 unmodified lines | ||
| 280 | 251 | return; |
| 281 | 252 | } |
| 282 | 253 | |
| 283 | − const hasDetails = hasRenderableToolDetails(tool); | |
| 284 | − | |
| 285 | − if (hasDetails || toolDetailsByPath[tool.path]) { | |
| 254 | + if (toolDetailsByPath[tool.path]) { | |
| 286 | 255 | return; |
| 287 | 256 | } |
| 288 | 257 | |
| @@ -309,7 +278,7 @@ export function ToolExplorer({ · 20 unmodified lines | ||
| 309 | 278 | return next; |
| 310 | 279 | }); |
| 311 | 280 | } |
| 312 | − }, [hasRenderableToolDetails, loadingDetailPaths, onLoadToolDetails, toolDetailsByPath]); | |
| 281 | + }, [loadingDetailPaths, onLoadToolDetails, toolDetailsByPath]); | |
| 313 | 282 | |
| 314 | 283 | // ── Focus tool handler ────────────────────────────────────────────────── |
| 315 | 284 | |
| @@ -359,12 +328,16 @@ export function ToolExplorer({ · 43 unmodified lines | ||
| 359 | 328 | options?: SourceAddedOptions, |
| 360 | 329 | ) => { |
| 361 | 330 | onSourceAdded?.(source, options); |
| 362 | − if (options?.isNew) { | |
| 363 | − return; | |
| 364 | − } | |
| 365 | − | |
| 366 | − // After editing, keep the updated source open for quick follow-up. | |
| 367 | 331 | setSourceFocusState(source.name, source); |
| 332 | + const key = `source:${source.name}`; | |
| 333 | + setExpandedKeys((prev) => { | |
| 334 | + if (prev.has(key)) { | |
| 335 | + return prev; | |
| 336 | + } | |
| 337 | + const next = new Set(prev); | |
| 338 | + next.add(key); | |
| 339 | + return next; | |
| 340 | + }); | |
| 368 | 341 | }, [onSourceAdded, setSourceFocusState]); |
| 369 | 342 | |
| 370 | 343 | const focusedTool = useMemo(() => { |
| @@ -377,6 +350,20 @@ export function ToolExplorer({ · 6 unmodified lines | ||
| 377 | 350 | return sourceByName.get(focusedSourceName) ?? null; |
| 378 | 351 | }, [focusedSourceName, sourceByName]); |
| 379 | 352 | |
| 353 | + const selectedSourceState = resolvedActiveSource ? sourceStates[resolvedActiveSource] : undefined; | |
| 354 | + const selectedSourceWarnings = resolvedActiveSource ? (warningsBySrc[resolvedActiveSource] ?? []) : []; | |
| 355 | + const selectedSourceToolCount = resolvedActiveSource | |
| 356 | + ? (sourceCounts[resolvedActiveSource] ?? selectedSourceState?.toolCount ?? 0) | |
| 357 | + : 0; | |
| 358 | + const selectedSourceLoading = selectedSourceState | |
| 359 | + ? selectedSourceState.state === "queued" | |
| 360 | + || selectedSourceState.state === "loading" | |
| 361 | + || selectedSourceState.state === "indexing" | |
| 362 | + : false; | |
| 363 | + const formSourceProgress = formSource && formSource !== "new" | |
| 364 | + ? sourceStates[formSource.name] | |
| 365 | + : undefined; | |
| 366 | + | |
| 380 | 367 | useEffect(() => { |
| 381 | 368 | if (formSource !== null && focusedToolPath) { |
| 382 | 369 | setFocusedToolPath(null); |
| @@ -446,39 +433,49 @@ export function ToolExplorer({ · 63 unmodified lines | ||
| 446 | 433 | |
| 447 | 434 | const inventoryStatus = useMemo(() => { |
| 448 | 435 | const loadingSourceCount = loadingSourceSet.size; |
| 436 | + const sourceStateEntries = Object.entries(sourceStates); | |
| 437 | + const totalSourceCount = sourceStateEntries.length; | |
| 438 | + const readySourceCount = sourceStateEntries.filter(([, state]) => state.state === "ready").length; | |
| 439 | + const failedSourceCount = sourceStateEntries.filter(([, state]) => state.state === "failed").length; | |
| 449 | 440 | |
| 450 | 441 | if (!inventoryState) { |
| 451 | 442 | return { label: "Checking...", tone: "muted" as const }; |
| 452 | 443 | } |
| 453 | 444 | if (inventoryState === "initializing") { |
| 454 | − const w = loadingSourceCount === 1 ? "source" : "sources"; | |
| 445 | + const progress = totalSourceCount > 0 | |
| 446 | + ? `${readySourceCount}/${totalSourceCount}` | |
| 447 | + : `${loadingSourceCount}`; | |
| 455 | 448 | return { |
| 456 | − label: loadingSourceCount > 0 ? `Building (${loadingSourceCount} ${w})` : "Building...", | |
| 449 | + label: `Building ${progress}`, | |
| 457 | 450 | tone: "loading" as const, |
| 458 | 451 | }; |
| 459 | 452 | } |
| 460 | 453 | if (inventoryState === "rebuilding") { |
| 461 | − const w = loadingSourceCount === 1 ? "source" : "sources"; | |
| 454 | + const progress = totalSourceCount > 0 | |
| 455 | + ? `${readySourceCount}/${totalSourceCount}` | |
| 456 | + : `${loadingSourceCount}`; | |
| 462 | 457 | return { |
| 463 | − label: loadingSourceCount > 0 ? `Refreshing (${loadingSourceCount} ${w})` : "Refreshing...", | |
| 458 | + label: `Refreshing ${progress}`, | |
| 464 | 459 | tone: "loading" as const, |
| 465 | 460 | }; |
| 466 | 461 | } |
| 467 | − if (inventoryState === "stale") { | |
| 468 | − return { label: "Out of date", tone: "muted" as const }; | |
| 469 | − } | |
| 470 | 462 | if (inventoryState === "failed") { |
| 471 | 463 | return { |
| 472 | − label: inventoryError ? `Failed: ${inventoryError}` : "Failed", | |
| 464 | + label: inventoryError | |
| 465 | + ? `Failed: ${inventoryError}` | |
| 466 | + : failedSourceCount > 0 | |
| 467 | + ? `Failed (${failedSourceCount} sources)` | |
| 468 | + : "Failed", | |
| 473 | 469 | tone: "error" as const, |
| 474 | 470 | }; |
| 475 | 471 | } |
| 476 | − return { label: "Up to date", tone: "muted" as const }; | |
| 477 | − }, [inventoryError, inventoryState, loadingSourceSet.size]); | |
| 472 | + return { | |
| 473 | + label: totalSourceCount > 0 ? `Up to date (${readySourceCount}/${totalSourceCount})` : "Up to date", | |
| 474 | + tone: "muted" as const, | |
| 475 | + }; | |
| 476 | + }, [inventoryError, inventoryState, loadingSourceSet.size, sourceStates]); | |
| 478 | 477 | |
| 479 | 478 | const regenerationInProgress = isRebuilding; |
| 480 | − const inventoryStale = inventoryState === "stale"; | |
| 481 | − | |
| 482 | 479 | // ── Render ────────────────────────────────────────────────────────────── |
| 483 | 480 | |
| 484 | 481 | return ( |
| @@ -519,17 +516,13 @@ export function ToolExplorer({ · 34 unmodified lines | ||
| 519 | 516 | title={ |
| 520 | 517 | regenerationInProgress |
| 521 | 518 | ? "Rebuilding..." |
| 522 | − : inventoryStale | |
| 523 | − ? "Out of date — click to refresh" | |
| 524 | − : "Regenerate inventory" | |
| 519 | + : "Regenerate inventory" | |
| 525 | 520 | } |
| 526 | 521 | className={cn( |
| 527 | 522 | "p-0.5 rounded transition-colors", |
| 528 | 523 | regenerationInProgress |
| 529 | 524 | ? "text-terminal-amber cursor-not-allowed" |
| 530 | − : inventoryStale | |
| 531 | − ? "text-muted-foreground/70 hover:text-muted-foreground" | |
| 532 | − : "text-muted-foreground/30 hover:text-muted-foreground/60", | |
| 525 | + : "text-muted-foreground/30 hover:text-muted-foreground/60", | |
| 533 | 526 | )} |
| 534 | 527 | > |
| 535 | 528 | <RefreshCcw className={cn("h-3 w-3", regenerationInProgress && "animate-spin")} /> |
| @@ -599,6 +592,7 @@ export function ToolExplorer({ · 63 unmodified lines | ||
| 599 | 592 | onFocusTool={handleFocusTool} |
| 600 | 593 | onSourceClick={handleSourceClick} |
| 601 | 594 | source={sourceByName.get(group.label) ?? undefined} |
| 595 | + sourceState={sourceStates[group.label]} | |
| 602 | 596 | /> |
| 603 | 597 | ))} |
| 604 | 598 | |
| @@ -615,6 +609,7 @@ export function ToolExplorer({ · 10 unmodified lines | ||
| 615 | 609 | <SourceFormPanel |
| 616 | 610 | existingSourceNames={sidebarExistingSourceNames} |
| 617 | 611 | sourceToEdit={formSource === "new" ? undefined : formSource} |
| 612 | + sourceProgress={formSourceProgress} | |
| 618 | 613 | sourceDialogMeta={formSource !== "new" ? sourceDialogMeta?.[formSource.name] : undefined} |
| 619 | 614 | sourceAuthProfiles={sourceAuthProfiles} |
| 620 | 615 | onSourceAdded={handleSourceFormAdded} |
| @@ -626,6 +621,80 @@ export function ToolExplorer({ · 5 unmodified lines | ||
| 626 | 621 | tool={focusedTool} |
| 627 | 622 | loading={loadingDetailPaths.has(focusedTool.path)} |
| 628 | 623 | /> |
| 624 | + ) : resolvedActiveSource ? ( | |
| 625 | + <div className="h-full p-6"> | |
| 626 | + <div className="mx-auto w-full max-w-3xl space-y-4 rounded-xl border border-border/60 bg-card/40 p-5"> | |
| 627 | + <div className="flex items-start justify-between gap-3"> | |
| 628 | + <div> | |
| 629 | + <p className="text-xs uppercase tracking-wide text-muted-foreground/70">Selected source</p> | |
| 630 | + <h2 className="mt-1 text-xl font-semibold text-foreground">{resolvedActiveSource}</h2> | |
| 631 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 632 | + {selectedSourceToolCount} tool{selectedSourceToolCount === 1 ? "" : "s"} | |
| 633 | + </p> | |
| 634 | + </div> | |
| 635 | + <div className={cn( | |
| 636 | + "inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs", | |
| 637 | + selectedSourceState?.state === "failed" | |
| 638 | + ? "border-terminal-red/40 bg-terminal-red/5 text-terminal-red" | |
| 639 | + : selectedSourceLoading | |
| 640 | + ? "border-terminal-amber/40 bg-terminal-amber/5 text-terminal-amber" | |
| 641 | + : "border-border/60 bg-muted/30 text-muted-foreground", | |
| 642 | + )}> | |
| 643 | + {selectedSourceState?.state === "failed" ? ( | |
| 644 | + <AlertTriangle className="h-3.5 w-3.5" /> | |
| 645 | + ) : selectedSourceLoading ? ( | |
| 646 | + <Loader2 className="h-3.5 w-3.5 animate-spin" /> | |
| 647 | + ) : null} | |
| 648 | + <span> | |
| 649 | + {selectedSourceState?.state === "failed" | |
| 650 | + ? "Failed" | |
| 651 | + : selectedSourceLoading | |
| 652 | + ? "Loading" | |
| 653 | + : "Ready"} | |
| 654 | + </span> | |
| 655 | + </div> | |
| 656 | + </div> | |
| 657 | + | |
| 658 | + {selectedSourceState?.message ? ( | |
| 659 | + <p className="text-sm text-muted-foreground">{selectedSourceState.message}</p> | |
| 660 | + ) : null} | |
| 661 | + | |
| 662 | + {selectedSourceState?.state === "indexing" && typeof selectedSourceState.processedTools === "number" && selectedSourceState.toolCount > 0 ? ( | |
| 663 | + <div className="space-y-1.5"> | |
| 664 | + <div className="h-1.5 overflow-hidden rounded bg-muted"> | |
| 665 | + <div | |
| 666 | + className="h-full bg-terminal-amber transition-[width] duration-300" | |
| 667 | + style={{ width: `${Math.max(2, Math.min(100, (selectedSourceState.processedTools / selectedSourceState.toolCount) * 100))}%` }} | |
| 668 | + /> | |
| 669 | + </div> | |
| 670 | + <p className="text-xs text-muted-foreground/80 tabular-nums"> | |
| 671 | + Indexed {selectedSourceState.processedTools}/{selectedSourceState.toolCount} | |
| 672 | + </p> | |
| 673 | + </div> | |
| 674 | + ) : null} | |
| 675 | + | |
| 676 | + {selectedSourceState?.error ? ( | |
| 677 | + <p className="rounded-md border border-terminal-red/30 bg-terminal-red/5 px-3 py-2 text-sm text-terminal-red"> | |
| 678 | + {selectedSourceState.error} | |
| 679 | + </p> | |
| 680 | + ) : null} | |
| 681 | + | |
| 682 | + {selectedSourceWarnings.length > 0 ? ( | |
| 683 | + <div className="rounded-md border border-terminal-amber/30 bg-terminal-amber/5 px-3 py-2"> | |
| 684 | + <p className="text-xs font-medium text-terminal-amber/90"> | |
| 685 | + {selectedSourceWarnings.length} warning{selectedSourceWarnings.length !== 1 ? "s" : ""} | |
| 686 | + </p> | |
| 687 | + <div className="mt-1.5 space-y-1"> | |
| 688 | + {selectedSourceWarnings.map((warning, index) => ( | |
| 689 | + <p key={`${resolvedActiveSource}-${index}`} className="text-xs leading-4 text-muted-foreground"> | |
| 690 | + {warning} | |
| 691 | + </p> | |
| 692 | + ))} | |
| 693 | + </div> | |
| 694 | + </div> | |
| 695 | + ) : null} | |
| 696 | + </div> | |
| 697 | + </div> | |
| 629 | 698 | ) : ( |
| 630 | 699 | <ToolDetailEmpty /> |
| 631 | 700 | )} |
| @@ -7,6 +7,7 @@ import { toast } from "sonner"; · 6 unmodified lines | ||
| 7 | 7 | import { |
| 8 | 8 | AlertTriangle, |
| 9 | 9 | ArrowLeft, |
| 10 | + CheckCircle2, | |
| 10 | 11 | Loader2, |
| 11 | 12 | Trash2, |
| 12 | 13 | } from "lucide-react"; |
| @@ -49,11 +50,70 @@ function ownerScopeBadge(scopeType: ToolSourceRecord["scopeType"] | undefined): · 36 unmodified lines | ||
| 49 | 50 | return scopeType === "organization" ? "org shared" : "workspace only"; |
| 50 | 51 | } |
| 51 | 52 | |
| 53 | +type SourceProgressState = { | |
| 54 | + state: "queued" | "loading" | "indexing" | "ready" | "failed"; | |
| 55 | + toolCount: number; | |
| 56 | + processedTools?: number; | |
| 57 | + message?: string; | |
| 58 | + error?: string; | |
| 59 | +}; | |
| 60 | + | |
| 61 | +function progressStepStates(sourceType: ToolSourceRecord["type"], progress?: SourceProgressState) { | |
| 62 | + const isOpenApi = sourceType === "openapi"; | |
| 63 | + const labels = isOpenApi | |
| 64 | + ? ["Download spec", "Extract operations", "Index tools"] | |
| 65 | + : ["Connect", "Discover tools", "Index tools"]; | |
| 66 | + | |
| 67 | + const defaultSteps = labels.map((label, index) => ({ | |
| 68 | + id: index + 1, | |
| 69 | + label, | |
| 70 | + status: "pending" as "pending" | "active" | "done" | "failed", | |
| 71 | + })); | |
| 72 | + | |
| 73 | + if (!progress) { | |
| 74 | + return defaultSteps; | |
| 75 | + } | |
| 76 | + | |
| 77 | + if (progress.state === "ready") { | |
| 78 | + return defaultSteps.map((step) => ({ ...step, status: "done" as const })); | |
| 79 | + } | |
| 80 | + | |
| 81 | + if (progress.state === "indexing") { | |
| 82 | + return defaultSteps.map((step) => { | |
| 83 | + if (step.id < 3) return { ...step, status: "done" as const }; | |
| 84 | + return { ...step, status: "active" as const }; | |
| 85 | + }); | |
| 86 | + } | |
| 87 | + | |
| 88 | + if (progress.state === "loading") { | |
| 89 | + const message = progress.message?.toLowerCase() ?? ""; | |
| 90 | + const activeStep = message.includes("download") ? 1 : 2; | |
| 91 | + return defaultSteps.map((step) => { | |
| 92 | + if (step.id < activeStep) return { ...step, status: "done" as const }; | |
| 93 | + if (step.id === activeStep) return { ...step, status: "active" as const }; | |
| 94 | + return step; | |
| 95 | + }); | |
| 96 | + } | |
| 97 | + | |
| 98 | + if (progress.state === "failed") { | |
| 99 | + const message = progress.message?.toLowerCase() ?? ""; | |
| 100 | + const failedStep = message.includes("index") ? 3 : message.includes("download") ? 1 : 2; | |
| 101 | + return defaultSteps.map((step) => { | |
| 102 | + if (step.id < failedStep) return { ...step, status: "done" as const }; | |
| 103 | + if (step.id === failedStep) return { ...step, status: "failed" as const }; | |
| 104 | + return step; | |
| 105 | + }); | |
| 106 | + } | |
| 107 | + | |
| 108 | + return defaultSteps; | |
| 109 | +} | |
| 110 | + | |
| 52 | 111 | // ── Main Panel ────────────────────────────────────────────────────────────── |
| 53 | 112 | |
| 54 | 113 | export function SourceFormPanel({ |
| 55 | 114 | existingSourceNames, |
| 56 | 115 | sourceToEdit, |
| 116 | + sourceProgress, | |
| 57 | 117 | sourceDialogMeta, |
| 58 | 118 | sourceAuthProfiles, |
| 59 | 119 | onSourceAdded, |
| @@ -62,6 +122,7 @@ export function SourceFormPanel({ · 2 unmodified lines | ||
| 62 | 122 | }: { |
| 63 | 123 | existingSourceNames: Set<string>; |
| 64 | 124 | sourceToEdit?: ToolSourceRecord; |
| 125 | + sourceProgress?: SourceProgressState; | |
| 65 | 126 | sourceDialogMeta?: SourceDialogMeta; |
| 66 | 127 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; |
| 67 | 128 | onSourceAdded?: OnSourceAdded; |
| @@ -247,7 +308,6 @@ export function SourceFormPanel({ · 179 unmodified lines | ||
| 247 | 308 | if (!sourceToEdit) { |
| 248 | 309 | form.reserveSourceName(form.name.trim()); |
| 249 | 310 | } |
| 250 | − onClose(); | |
| 251 | 311 | }; |
| 252 | 312 | |
| 253 | 313 | const handleMcpOAuthConnect = async () => { |
| @@ -388,6 +448,66 @@ export function SourceFormPanel({ · 134 unmodified lines | ||
| 388 | 448 | </div> |
| 389 | 449 | </div> |
| 390 | 450 | |
| 451 | + {sourceToEdit ? ( | |
| 452 | + <div className="rounded-md border border-border/50 bg-background/40 px-3 py-2.5"> | |
| 453 | + <div className="flex items-center justify-between gap-2"> | |
| 454 | + <p className="text-[11px] font-medium text-muted-foreground/80">Source sync progress</p> | |
| 455 | + <span className="text-[10px] tabular-nums text-muted-foreground/70"> | |
| 456 | + {sourceProgress?.state === "indexing" && typeof sourceProgress?.processedTools === "number" | |
| 457 | + ? `${sourceProgress.processedTools}/${sourceProgress.toolCount}` | |
| 458 | + : `${sourceProgress?.toolCount ?? 0} tools`} | |
| 459 | + </span> | |
| 460 | + </div> | |
| 461 | + | |
| 462 | + <div className="mt-2 space-y-1.5"> | |
| 463 | + {progressStepStates(sourceToEdit.type, sourceProgress).map((step) => ( | |
| 464 | + <div key={step.id} className="flex items-center gap-2 text-[11px]"> | |
| 465 | + <span className={ | |
| 466 | + step.status === "done" | |
| 467 | + ? "text-terminal-green" | |
| 468 | + : step.status === "active" | |
| 469 | + ? "text-terminal-amber" | |
| 470 | + : step.status === "failed" | |
| 471 | + ? "text-terminal-red" | |
| 472 | + : "text-muted-foreground/40" | |
| 473 | + }> | |
| 474 | + {step.status === "done" ? ( | |
| 475 | + <CheckCircle2 className="h-3.5 w-3.5" /> | |
| 476 | + ) : step.status === "active" ? ( | |
| 477 | + <Loader2 className="h-3.5 w-3.5 animate-spin" /> | |
| 478 | + ) : step.status === "failed" ? ( | |
| 479 | + <AlertTriangle className="h-3.5 w-3.5" /> | |
| 480 | + ) : ( | |
| 481 | + <span className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-current text-[9px] font-semibold"> | |
| 482 | + {step.id} | |
| 483 | + </span> | |
| 484 | + )} | |
| 485 | + </span> | |
| 486 | + <span className={ | |
| 487 | + step.status === "active" | |
| 488 | + ? "text-foreground" | |
| 489 | + : step.status === "failed" | |
| 490 | + ? "text-terminal-red" | |
| 491 | + : "text-muted-foreground" | |
| 492 | + }> | |
| 493 | + {step.label} | |
| 494 | + </span> | |
| 495 | + </div> | |
| 496 | + ))} | |
| 497 | + </div> | |
| 498 | + | |
| 499 | + {sourceProgress?.message ? ( | |
| 500 | + <p className="mt-2 text-[11px] text-muted-foreground">{sourceProgress.message}</p> | |
| 501 | + ) : null} | |
| 502 | + | |
| 503 | + {sourceProgress?.error ? ( | |
| 504 | + <p className="mt-2 rounded-md border border-terminal-red/30 bg-terminal-red/5 px-2 py-1.5 text-[11px] text-terminal-red"> | |
| 505 | + {sourceProgress.error} | |
| 506 | + </p> | |
| 507 | + ) : null} | |
| 508 | + </div> | |
| 509 | + ) : null} | |
| 510 | + | |
| 391 | 511 | {editSourceWarnings.length > 0 ? ( |
| 392 | 512 | <div className="rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2 space-y-0.5"> |
| 393 | 513 | {editSourceWarnings.map((warning, i) => ( |
| @@ -397,10 +397,9 @@ function ToolDetailContent({ · 396 unmodified lines | ||
| 397 | 397 | || canRenderOutputSchema; |
| 398 | 398 | const showLoading = Boolean(loading); |
| 399 | 399 | |
| 400 | − return ( | |
| 401 | − <div className="pt-2 pb-4 flex flex-col gap-4"> | |
| 402 | − {/* Loading state */} | |
| 403 | − {showLoading ? ( | |
| 400 | + if (showLoading) { | |
| 401 | + return ( | |
| 402 | + <div className="pt-2 pb-4 flex flex-col gap-4"> | |
| 404 | 403 | <div className="space-y-3"> |
| 405 | 404 | <Skeleton className="h-3.5 w-72" /> |
| 406 | 405 | <div> |
| @@ -412,8 +411,12 @@ function ToolDetailContent({ · 5 unmodified lines | ||
| 412 | 411 | <Skeleton className="h-14 w-full rounded-md mt-1" /> |
| 413 | 412 | </div> |
| 414 | 413 | </div> |
| 415 | − ) : null} | |
| 414 | + </div> | |
| 415 | + ); | |
| 416 | + } | |
| 416 | 417 | |
| 418 | + return ( | |
| 419 | + <div className="pt-2 pb-4 flex flex-col gap-4"> | |
| 417 | 420 | {/* Description */} |
| 418 | 421 | {description ? ( |
| 419 | 422 | <div className="text-[12px] leading-relaxed text-muted-foreground"> |
| @@ -22,7 +22,6 @@ import type { · 21 unmodified lines | ||
| 22 | 22 | StorageScopeType, |
| 23 | 23 | } from "@/lib/types"; |
| 24 | 24 | import { |
| 25 | − parseWarningSourceName, | |
| 26 | 25 | warningsBySourceName, |
| 27 | 26 | } from "@/lib/tools/source-helpers"; |
| 28 | 27 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| @@ -121,6 +120,7 @@ export function ToolsView() { · 92 unmodified lines | ||
| 121 | 120 | sourceQuality, |
| 122 | 121 | sourceAuthProfiles, |
| 123 | 122 | inventoryStatus, |
| 123 | + inventorySourceStates, | |
| 124 | 124 | loadingSources, |
| 125 | 125 | loadingTools, |
| 126 | 126 | refreshingTools, |
| @@ -134,6 +134,7 @@ export function ToolsView() { · 7 unmodified lines | ||
| 134 | 134 | loadToolDetails, |
| 135 | 135 | } = useWorkspaceTools(context ?? null, { |
| 136 | 136 | includeDetails: false, |
| 137 | + sourceName: activeTab === "catalog" ? catalogSourceValue : null, | |
| 137 | 138 | }); |
| 138 | 139 | |
| 139 | 140 | const showRegenerationToast = useCallback((kind: "loading" | "success" | "error", message: string) => { |
| @@ -212,19 +213,6 @@ export function ToolsView() { · 72 unmodified lines | ||
| 212 | 213 | const storagePanelLoading = sessionLoading || storageLoading; |
| 213 | 214 | const shouldRenderShellSkeleton = sessionLoading && !TAB_USES_OWN_LOADING_STATE[activeTab]; |
| 214 | 215 | |
| 215 | − const globalWarnings = useMemo( | |
| 216 | − () => warnings.filter((warning) => !parseWarningSourceName(warning)), | |
| 217 | − [warnings], | |
| 218 | − ); | |
| 219 | − const hasGlobalInventoryWarning = useMemo( | |
| 220 | − () => globalWarnings.some((warning) => | |
| 221 | − warning.includes("Tool inventory is still loading") | |
| 222 | − || warning.includes("showing previous results while refreshing"), | |
| 223 | − ), | |
| 224 | − [globalWarnings], | |
| 225 | − ); | |
| 226 | − | |
| 227 | − // Merge loading sources with global inventory warnings | |
| 228 | 216 | const mergedLoadingSources = useMemo(() => { |
| 229 | 217 | const combined = new Set<string>(); |
| 230 | 218 | |
| @@ -234,17 +222,18 @@ export function ToolsView() { · 3 unmodified lines | ||
| 234 | 222 | } |
| 235 | 223 | } |
| 236 | 224 | |
| 237 | − if (hasGlobalInventoryWarning) { | |
| 238 | − for (const source of sourceItems) { | |
| 239 | − if (toolSourceNames.has(source.name)) { | |
| 240 | − continue; | |
| 241 | − } | |
| 242 | − combined.add(source.name); | |
| 225 | + for (const [sourceName, sourceState] of Object.entries(inventorySourceStates)) { | |
| 226 | + if ( | |
| 227 | + sourceState?.state === "queued" | |
| 228 | + || sourceState?.state === "loading" | |
| 229 | + || sourceState?.state === "indexing" | |
| 230 | + ) { | |
| 231 | + combined.add(sourceName); | |
| 243 | 232 | } |
| 244 | 233 | } |
| 245 | 234 | |
| 246 | 235 | return Array.from(combined); |
| 247 | − }, [hasGlobalInventoryWarning, loadingSources, sourceItems, toolSourceNames]); | |
| 236 | + }, [inventorySourceStates, loadingSources]); | |
| 248 | 237 | |
| 249 | 238 | const visibleLoadingSources = useMemo( |
| 250 | 239 | () => mergedLoadingSources.filter((name) => |
| @@ -474,6 +463,7 @@ export function ToolsView() { · 223 unmodified lines | ||
| 474 | 463 | tools={visibleTools} |
| 475 | 464 | sources={sourceItems} |
| 476 | 465 | loadingSources={visibleLoadingSources} |
| 466 | + sourceStates={inventorySourceStates} | |
| 477 | 467 | sourceCountsOverride={visibleSourceCounts} |
| 478 | 468 | totalTools={visibleTools.length} |
| 479 | 469 | hasMoreTools={hasMoreTools} |
| @@ -22,15 +22,10 @@ interface WorkspaceToolsQueryResult { · 21 unmodified lines | ||
| 22 | 22 | /** URL to a workspace-wide Monaco `.d.ts` bundle (may be undefined). */ |
| 23 | 23 | typesUrl?: string; |
| 24 | 24 | inventoryStatus: { |
| 25 | − state: "initializing" | "ready" | "rebuilding" | "stale" | "failed"; | |
| 26 | − readyBuildId?: string; | |
| 27 | − buildingBuildId?: string; | |
| 25 | + state: "initializing" | "ready" | "rebuilding" | "failed"; | |
| 28 | 26 | readyToolCount: number; |
| 29 | 27 | loadingSourceNames: string[]; |
| 30 | 28 | sourceToolCounts: Record<string, number>; |
| 31 | − lastBuildStartedAt?: number; | |
| 32 | − lastBuildCompletedAt?: number; | |
| 33 | − lastBuildFailedAt?: number; | |
| 34 | 29 | error?: string; |
| 35 | 30 | updatedAt?: number; |
| 36 | 31 | }; |
| @@ -40,6 +35,21 @@ interface WorkspaceToolsQueryResult { · 3 unmodified lines | ||
| 40 | 35 | |
| 41 | 36 | interface UseWorkspaceToolsOptions { |
| 42 | 37 | includeDetails?: boolean; |
| 38 | + sourceName?: string | null; | |
| 39 | +} | |
| 40 | + | |
| 41 | +interface WorkspaceInventoryProgress { | |
| 42 | + inventoryStatus: WorkspaceToolsQueryResult["inventoryStatus"]; | |
| 43 | + sourceStates: Record<string, { | |
| 44 | + state: "queued" | "loading" | "indexing" | "ready" | "failed"; | |
| 45 | + toolCount: number; | |
| 46 | + processedTools?: number; | |
| 47 | + message?: string; | |
| 48 | + error?: string; | |
| 49 | + updatedAt?: number; | |
| 50 | + }>; | |
| 51 | + warnings: string[]; | |
| 52 | + reactiveKey: string; | |
| 43 | 53 | } |
| 44 | 54 | |
| 45 | 55 | type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "display" | "typing">; |
| @@ -61,7 +71,6 @@ type ListToolsWithWarningsAction = (args: { · 15 unmodified lines | ||
| 61 | 71 | sourceName?: string; |
| 62 | 72 | cursor?: string; |
| 63 | 73 | limit?: number; |
| 64 | − buildId?: string; | |
| 65 | 74 | fetchAll?: boolean; |
| 66 | 75 | rebuildInventory?: boolean; |
| 67 | 76 | }) => Promise<WorkspaceToolsQueryResult>; |
| @@ -77,6 +86,7 @@ export function useWorkspaceTools( · 9 unmodified lines | ||
| 77 | 86 | options: UseWorkspaceToolsOptions = {}, |
| 78 | 87 | ) { |
| 79 | 88 | const includeDetails = options.includeDetails ?? false; |
| 89 | + const sourceName = options.sourceName?.trim() ? options.sourceName.trim() : undefined; | |
| 80 | 90 | const listToolsWithWarningsRaw = useAction(convexApi.executorNode.listToolsWithWarnings); |
| 81 | 91 | const listToolsWithWarnings = listToolsWithWarningsRaw as ListToolsWithWarningsAction; |
| 82 | 92 | const listToolDetailsRaw = useMutation(convexApi.workspace.getToolDetails); |
| @@ -87,7 +97,7 @@ export function useWorkspaceTools( · 4 unmodified lines | ||
| 87 | 97 | const inventoryProgress = useConvexQuery( |
| 88 | 98 | convexApi.workspace.getToolInventoryProgress, |
| 89 | 99 | context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", |
| 90 | − ); | |
| 100 | + ) as WorkspaceInventoryProgress | undefined; | |
| 91 | 101 | |
| 92 | 102 | const inventoryQuery = useQuery<WorkspaceToolsQueryResult, Error>({ |
| 93 | 103 | queryKey: [ |
| @@ -96,6 +106,7 @@ export function useWorkspaceTools( · 2 unmodified lines | ||
| 96 | 106 | context?.accountId, |
| 97 | 107 | context?.clientId, |
| 98 | 108 | includeDetails, |
| 109 | + sourceName, | |
| 99 | 110 | inventoryProgress?.reactiveKey, |
| 100 | 111 | ], |
| 101 | 112 | queryFn: async (): Promise<WorkspaceToolsQueryResult> => { |
| @@ -123,6 +134,7 @@ export function useWorkspaceTools( · 21 unmodified lines | ||
| 123 | 134 | ...(context.sessionId && { sessionId: context.sessionId }), |
| 124 | 135 | includeDetails, |
| 125 | 136 | fetchAll: true, |
| 137 | + ...(sourceName ? { sourceName } : {}), | |
| 126 | 138 | }); |
| 127 | 139 | }, |
| 128 | 140 | enabled: !!context, |
| @@ -131,6 +143,7 @@ export function useWorkspaceTools( · 2 unmodified lines | ||
| 131 | 143 | |
| 132 | 144 | const inventoryData = inventoryQuery.data; |
| 133 | 145 | const inventoryStatus = inventoryProgress?.inventoryStatus ?? inventoryData?.inventoryStatus; |
| 146 | + const inventorySourceStates: WorkspaceInventoryProgress["sourceStates"] = inventoryProgress?.sourceStates ?? {}; | |
| 134 | 147 | const tools = inventoryData?.tools ?? []; |
| 135 | 148 | |
| 136 | 149 | const loadToolDetails = useCallback(async (toolPaths: string[]): Promise<Record<string, ToolDetailDescriptor>> => { |
| @@ -171,6 +184,10 @@ export function useWorkspaceTools( · 34 unmodified lines | ||
| 171 | 184 | detailsCacheRef.current.clear(); |
| 172 | 185 | }, [context?.workspaceId, context?.accountId, context?.clientId, context?.sessionId]); |
| 173 | 186 | |
| 187 | + useEffect(() => { | |
| 188 | + detailsCacheRef.current.clear(); | |
| 189 | + }, [inventoryProgress?.reactiveKey]); | |
| 190 | + | |
| 174 | 191 | useEffect(() => { |
| 175 | 192 | if (!inventoryData || !includeDetails) { |
| 176 | 193 | return; |
| @@ -219,7 +236,7 @@ export function useWorkspaceTools( · 42 unmodified lines | ||
| 219 | 236 | sourceQuality: inventoryData?.sourceQuality ?? {}, |
| 220 | 237 | sourceAuthProfiles: inventoryData?.sourceAuthProfiles ?? {}, |
| 221 | 238 | inventoryStatus, |
| 222 | − inventorySourceStates: inventoryProgress?.sourceStates ?? {}, | |
| 239 | + inventorySourceStates, | |
| 223 | 240 | loadingSources: inventoryStatus?.loadingSourceNames ?? [], |
| 224 | 241 | loadingTools: !!context && inventoryQuery.isLoading, |
| 225 | 242 | refreshingTools: !!context && inventoryQuery.isFetching, |
| @@ -27,6 +27,58 @@ export const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [ · 26 unmodified lines | ||
| 27 | 27 | rank: 0, |
| 28 | 28 | addedAt: "2026-02-14", |
| 29 | 29 | }, |
| 30 | + { | |
| 31 | + id: "beeper-mcp", | |
| 32 | + name: "Beeper MCP", | |
| 33 | + summary: "Connect to your local Beeper Desktop MCP endpoint for chats, messages, and contacts.", | |
| 34 | + specUrl: "http://localhost:23373/v0/mcp", | |
| 35 | + sourceType: "mcp", | |
| 36 | + originUrl: "https://developers.beeper.com/desktop-api/", | |
| 37 | + providerName: "Beeper", | |
| 38 | + categories: "communications", | |
| 39 | + version: "latest", | |
| 40 | + rank: 0.1, | |
| 41 | + addedAt: "2026-02-20", | |
| 42 | + }, | |
| 43 | + { | |
| 44 | + id: "neon-mcp", | |
| 45 | + name: "Neon MCP", | |
| 46 | + summary: "Create and manage Postgres branches, projects, and roles with Neon MCP tools.", | |
| 47 | + specUrl: "https://mcp.neon.tech/mcp", | |
| 48 | + sourceType: "mcp", | |
| 49 | + originUrl: "https://neon.com", | |
| 50 | + providerName: "Neon", | |
| 51 | + categories: "infrastructure", | |
| 52 | + version: "latest", | |
| 53 | + rank: 0.2, | |
| 54 | + addedAt: "2026-02-20", | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + id: "neon-openapi", | |
| 58 | + name: "Neon API", | |
| 59 | + summary: "Manage Neon projects, branches, and organization resources via REST API.", | |
| 60 | + specUrl: "https://neon.com/api_spec/release/v2.json", | |
| 61 | + sourceType: "openapi", | |
| 62 | + originUrl: "https://neon.com/docs/manage/projects", | |
| 63 | + providerName: "Neon", | |
| 64 | + categories: "infrastructure", | |
| 65 | + version: "v2", | |
| 66 | + rank: 0.3, | |
| 67 | + addedAt: "2026-02-20", | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + id: "linear-graphql", | |
| 71 | + name: "Linear GraphQL", | |
| 72 | + summary: "Query issues, teams, and workflow data from Linear's GraphQL API.", | |
| 73 | + specUrl: "https://api.linear.app/graphql", | |
| 74 | + sourceType: "graphql", | |
| 75 | + originUrl: "https://linear.app", | |
| 76 | + providerName: "Linear", | |
| 77 | + categories: "project-management", | |
| 78 | + version: "latest", | |
| 79 | + rank: 0.4, | |
| 80 | + addedAt: "2026-02-20", | |
| 81 | + }, | |
| 30 | 82 | { |
| 31 | 83 | id: "github-rest", |
| 32 | 84 | name: "GitHub REST API", |
| @@ -177,11 +177,14 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 176 unmodified lines | ||
| 177 | 177 | return resolveActiveWorkspaceId(workspaces, activeWorkspaceId, accountStoredWorkspace); |
| 178 | 178 | }, [workspaces, activeWorkspaceId, account]); |
| 179 | 179 | |
| 180 | + const profileName = workosAuthProfile?.name?.trim(); | |
| 181 | + | |
| 180 | 182 | const bootstrapWorkosAccountQuery = useTanstackQuery({ |
| 181 | 183 | queryKey: [ |
| 182 | 184 | "workos-account-bootstrap", |
| 183 | 185 | storedSessionId ?? "none", |
| 184 | 186 | workosAuthenticated ? "signed-in" : "signed-out", |
| 187 | + profileName ?? "no-profile-name", | |
| 185 | 188 | ], |
| 186 | 189 | enabled: |
| 187 | 190 | workosEnabled |
| @@ -192,6 +195,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 4 unmodified lines | ||
| 192 | 195 | staleTime: Number.POSITIVE_INFINITY, |
| 193 | 196 | queryFn: async () => bootstrapCurrentWorkosAccount({ |
| 194 | 197 | sessionId: storedSessionId ?? undefined, |
| 198 | + profileName: profileName && profileName.length > 0 ? profileName : undefined, | |
| 195 | 199 | }), |
| 196 | 200 | }); |
| 197 | 201 | |
| @@ -9,6 +9,7 @@ import type { PreparedOpenApiSpec } from "./tool/source-types"; · 8 unmodified lines | ||
| 9 | 9 | import { toPlainObject } from "./utils"; |
| 10 | 10 | |
| 11 | 11 | const unknownRecordSchema = z.record(z.unknown()); |
| 12 | +const OPENAPI_JSON_FETCH_TIMEOUT_MS = 45_000; | |
| 12 | 13 | |
| 13 | 14 | const openApiDocumentSchema = z.object({ |
| 14 | 15 | openapi: z.string().optional(), |
| @@ -55,6 +56,66 @@ function toOpenApiTsInput(spec: Record<string, unknown>): Parameters<typeof open · 40 unmodified lines | ||
| 55 | 56 | return parsed.data as Parameters<typeof openapiTS>[0] & Record<string, unknown>; |
| 56 | 57 | } |
| 57 | 58 | |
| 59 | +function isHttpUrl(value: string): boolean { | |
| 60 | + try { | |
| 61 | + const parsed = new URL(value); | |
| 62 | + return parsed.protocol === "http:" || parsed.protocol === "https:"; | |
| 63 | + } catch { | |
| 64 | + return false; | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +function looksLikeJsonSpecUrl(value: string): boolean { | |
| 69 | + if (!isHttpUrl(value)) { | |
| 70 | + return false; | |
| 71 | + } | |
| 72 | + | |
| 73 | + return /\.json($|[?#])/i.test(value); | |
| 74 | +} | |
| 75 | + | |
| 76 | +async function fetchJsonOpenApiSpec(url: string, sourceName: string): Promise<Record<string, unknown>> { | |
| 77 | + const controller = new AbortController(); | |
| 78 | + const timeout = setTimeout(() => { | |
| 79 | + controller.abort(); | |
| 80 | + }, OPENAPI_JSON_FETCH_TIMEOUT_MS); | |
| 81 | + | |
| 82 | + try { | |
| 83 | + const response = await fetch(url, { | |
| 84 | + signal: controller.signal, | |
| 85 | + redirect: "follow", | |
| 86 | + }); | |
| 87 | + | |
| 88 | + if (!response.ok) { | |
| 89 | + throw new Error(`HTTP ${response.status} ${response.statusText}`); | |
| 90 | + } | |
| 91 | + | |
| 92 | + const body = await response.text(); | |
| 93 | + if (!body.trim()) { | |
| 94 | + throw new Error("empty response body"); | |
| 95 | + } | |
| 96 | + | |
| 97 | + let parsed: unknown; | |
| 98 | + try { | |
| 99 | + parsed = JSON.parse(body); | |
| 100 | + } catch (error) { | |
| 101 | + const msg = error instanceof Error ? error.message : String(error); | |
| 102 | + throw new Error(`invalid JSON payload: ${msg}`); | |
| 103 | + } | |
| 104 | + | |
| 105 | + const asRecord = toRecordResult(parsed, `Fetched OpenAPI source '${sourceName}'`); | |
| 106 | + if (asRecord.isErr()) { | |
| 107 | + throw asRecord.error; | |
| 108 | + } | |
| 109 | + | |
| 110 | + return asRecord.value; | |
| 111 | + } catch (error) { | |
| 112 | + const msg = error instanceof Error ? error.message : String(error); | |
| 113 | + throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`); | |
| 114 | + } finally { | |
| 115 | + clearTimeout(timeout); | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 58 | 119 | function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null { |
| 59 | 120 | let strippedCount = 0; |
| 60 | 121 | |
| @@ -157,6 +218,7 @@ async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string · 96 unmodified lines | ||
| 157 | 218 | export interface PrepareOpenApiSpecOptions { |
| 158 | 219 | includeDts?: boolean; |
| 159 | 220 | profile?: "full" | "inventory"; |
| 221 | + resolveSchemaRefs?: boolean; | |
| 160 | 222 | } |
| 161 | 223 | |
| 162 | 224 | export async function prepareOpenApiSpec( |
| @@ -168,26 +230,31 @@ export async function prepareOpenApiSpec( · 5 unmodified lines | ||
| 168 | 230 | const includeDts = options.includeDts ?? true; |
| 169 | 231 | const profile = options.profile ?? (includeDts ? "full" : "inventory"); |
| 170 | 232 | const shouldGenerateDts = includeDts && profile === "full"; |
| 171 | − const shouldBundle = profile === "full"; | |
| 233 | + const shouldBundle = profile === "full" && includeDts; | |
| 234 | + const resolveSchemaRefs = options.resolveSchemaRefs ?? (profile === "full"); | |
| 172 | 235 | |
| 173 | 236 | const warnings: string[] = []; |
| 174 | 237 | |
| 175 | 238 | let parsed: Record<string, unknown>; |
| 176 | 239 | if (typeof spec === "string") { |
| 177 | − let parsedValue: unknown; | |
| 178 | − try { | |
| 179 | − parsedValue = await parser.parse(spec); | |
| 180 | − } catch (error) { | |
| 181 | − const msg = error instanceof Error ? error.message : String(error); | |
| 182 | − throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`); | |
| 183 | − } | |
| 240 | + if (looksLikeJsonSpecUrl(spec)) { | |
| 241 | + parsed = await fetchJsonOpenApiSpec(spec, sourceName); | |
| 242 | + } else { | |
| 243 | + let parsedValue: unknown; | |
| 244 | + try { | |
| 245 | + parsedValue = await parser.parse(spec); | |
| 246 | + } catch (error) { | |
| 247 | + const msg = error instanceof Error ? error.message : String(error); | |
| 248 | + throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`); | |
| 249 | + } | |
| 184 | 250 | |
| 185 | − const parsedRecord = toRecordResult(parsedValue, `Parsed OpenAPI source '${sourceName}'`); | |
| 186 | − if (parsedRecord.isErr()) { | |
| 187 | − throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`); | |
| 188 | − } | |
| 251 | + const parsedRecord = toRecordResult(parsedValue, `Parsed OpenAPI source '${sourceName}'`); | |
| 252 | + if (parsedRecord.isErr()) { | |
| 253 | + throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`); | |
| 254 | + } | |
| 189 | 255 | |
| 190 | − parsed = parsedRecord.value; | |
| 256 | + parsed = parsedRecord.value; | |
| 257 | + } | |
| 191 | 258 | } else { |
| 192 | 259 | parsed = spec; |
| 193 | 260 | } |
| @@ -234,7 +301,7 @@ export async function prepareOpenApiSpec( · 40 unmodified lines | ||
| 234 | 301 | includeSchemas: profile === "full", |
| 235 | 302 | includeTypeHints: true, |
| 236 | 303 | includeParameterSchemas: true, |
| 237 | − resolveSchemaRefs: profile === "full", | |
| 304 | + resolveSchemaRefs, | |
| 238 | 305 | }, |
| 239 | 306 | ); |
| 240 | 307 | |
| @@ -659,6 +659,74 @@ describe("real-world OpenAPI specs", () => { · 658 unmodified lines | ||
| 659 | 659 | 300_000, |
| 660 | 660 | ); |
| 661 | 661 | |
| 662 | + test( | |
| 663 | + "stripe: extraction keeps concrete schemas for forwarding + verification report list outputs", | |
| 664 | + async () => { | |
| 665 | + const stripeUrl = "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"; | |
| 666 | + const prepared = await prepareOpenApiSpec(stripeUrl, "stripe", { | |
| 667 | + includeDts: false, | |
| 668 | + profile: "full", | |
| 669 | + }); | |
| 670 | + | |
| 671 | + const tools = buildOpenApiToolsFromPrepared( | |
| 672 | + { | |
| 673 | + type: "openapi", | |
| 674 | + name: "stripe", | |
| 675 | + spec: stripeUrl, | |
| 676 | + baseUrl: prepared.servers[0] || "https://api.stripe.com", | |
| 677 | + }, | |
| 678 | + prepared, | |
| 679 | + ); | |
| 680 | + | |
| 681 | + const forwarding = tools.find((tool) => tool.path === "stripe.get_forwarding_requests_id"); | |
| 682 | + expect(forwarding).toBeDefined(); | |
| 683 | + const forwardingOutputSchema = toRecord(forwarding?.typing?.outputSchema); | |
| 684 | + const forwardingProperties = toRecord(forwardingOutputSchema.properties); | |
| 685 | + expect(forwardingOutputSchema.type).toBe("object"); | |
| 686 | + expect(toRecord(forwardingProperties.id).type).toBe("string"); | |
| 687 | + expect(toRecord(forwardingProperties.created).type).toBe("integer"); | |
| 688 | + | |
| 689 | + const verificationList = tools.find((tool) => tool.path === "stripe.get_identity_verification_reports"); | |
| 690 | + expect(verificationList).toBeDefined(); | |
| 691 | + const verificationOutputSchema = toRecord(verificationList?.typing?.outputSchema); | |
| 692 | + const verificationProperties = toRecord(verificationOutputSchema.properties); | |
| 693 | + const dataSchema = toRecord(verificationProperties.data); | |
| 694 | + const dataItems = toRecord(dataSchema.items); | |
| 695 | + expect(verificationOutputSchema.type).toBe("object"); | |
| 696 | + expect(dataSchema.type).toBe("array"); | |
| 697 | + expect(dataItems.type).toBe("object"); | |
| 698 | + expect(toRecord(toRecord(dataItems.properties).id).type).toBe("string"); | |
| 699 | + | |
| 700 | + const toolsWithUnknownDataHint = tools | |
| 701 | + .filter((tool) => (tool.typing?.outputHint ?? "").includes("data: unknown[]")); | |
| 702 | + | |
| 703 | + for (const tool of toolsWithUnknownDataHint) { | |
| 704 | + const outputSchema = toRecord(tool.typing?.outputSchema); | |
| 705 | + const outputProperties = toRecord(outputSchema.properties); | |
| 706 | + const objectSchema = toRecord(outputProperties.object); | |
| 707 | + const outputData = toRecord(outputProperties.data); | |
| 708 | + const outputDataItems = toRecord(outputData.items); | |
| 709 | + const objectEnum = Array.isArray(objectSchema.enum) ? objectSchema.enum : []; | |
| 710 | + const isListEnvelope = objectEnum.includes("list"); | |
| 711 | + if (!isListEnvelope) { | |
| 712 | + continue; | |
| 713 | + } | |
| 714 | + | |
| 715 | + const hasDetailedDataSchema = outputData.type === "array" | |
| 716 | + && ( | |
| 717 | + outputDataItems.type === "object" | |
| 718 | + || Array.isArray(outputDataItems.anyOf) | |
| 719 | + || Array.isArray(outputDataItems.oneOf) | |
| 720 | + || Array.isArray(outputDataItems.allOf) | |
| 721 | + ); | |
| 722 | + if (!hasDetailedDataSchema) { | |
| 723 | + throw new Error(`stripe regression: ${tool.path} has list output with unknown[] hint but no detailed data schema`); | |
| 724 | + } | |
| 725 | + } | |
| 726 | + }, | |
| 727 | + 300_000, | |
| 728 | + ); | |
| 729 | + | |
| 662 | 730 | test( |
| 663 | 731 | "OpenAPI inventory mode still yields usable schemas", |
| 664 | 732 | async () => { |
| @@ -685,6 +753,49 @@ describe("real-world OpenAPI specs", () => { · 20 unmodified lines | ||
| 685 | 753 | 300_000, |
| 686 | 754 | ); |
| 687 | 755 | |
| 756 | + test( | |
| 757 | + "stripe: full profile keeps structured output schemas for large operations", | |
| 758 | + async () => { | |
| 759 | + const stripeUrl = "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"; | |
| 760 | + | |
| 761 | + const prepared = await prepareOpenApiSpec(stripeUrl, "stripe", { | |
| 762 | + includeDts: false, | |
| 763 | + profile: "full", | |
| 764 | + }); | |
| 765 | + | |
| 766 | + const tools = buildOpenApiToolsFromPrepared( | |
| 767 | + { | |
| 768 | + type: "openapi", | |
| 769 | + name: "stripe", | |
| 770 | + spec: stripeUrl, | |
| 771 | + baseUrl: prepared.servers[0] || "https://api.stripe.com", | |
| 772 | + }, | |
| 773 | + prepared, | |
| 774 | + ); | |
| 775 | + | |
| 776 | + const forwardingRequest = tools.find((tool) => tool.path === "stripe.get_forwarding_requests_id"); | |
| 777 | + expect(forwardingRequest).toBeDefined(); | |
| 778 | + | |
| 779 | + const forwardingOutputSchema = toRecord(forwardingRequest?.typing?.outputSchema); | |
| 780 | + expect(forwardingOutputSchema.type).toBe("object"); | |
| 781 | + expect(Object.keys(toRecord(forwardingOutputSchema.properties)).length).toBeGreaterThan(5); | |
| 782 | + expect(toRecord(forwardingOutputSchema.properties).id).toBeDefined(); | |
| 783 | + | |
| 784 | + const verificationReports = tools.find((tool) => tool.path === "stripe.get_identity_verification_reports"); | |
| 785 | + expect(verificationReports).toBeDefined(); | |
| 786 | + | |
| 787 | + const verificationOutputSchema = toRecord(verificationReports?.typing?.outputSchema); | |
| 788 | + const verificationDataSchema = toRecord(toRecord(verificationOutputSchema.properties).data); | |
| 789 | + expect(verificationDataSchema.type).toBe("array"); | |
| 790 | + const verificationDataItems = toRecord(verificationDataSchema.items); | |
| 791 | + expect(Object.keys(toRecord(verificationDataItems.properties)).length).toBeGreaterThan(5); | |
| 792 | + | |
| 793 | + const outputHint = verificationReports?.typing?.outputHint ?? ""; | |
| 794 | + expect(outputHint.includes("data: unknown[]")).toBe(false); | |
| 795 | + }, | |
| 796 | + 300_000, | |
| 797 | + ); | |
| 798 | + | |
| 688 | 799 | test( |
| 689 | 800 | "openai: create batch hints stay non-lossy in inventory mode", |
| 690 | 801 | async () => { |
| @@ -45,6 +45,7 @@ export const bootstrapCurrentWorkosAccount = customMutation({ · 44 unmodified lines | ||
| 45 | 45 | method: "POST", |
| 46 | 46 | args: { |
| 47 | 47 | sessionId: v.optional(v.string()), |
| 48 | + profileName: v.optional(v.string()), | |
| 48 | 49 | }, |
| 49 | 50 | handler: async (ctx, args) => { |
| 50 | 51 | return await bootstrapCurrentWorkosAccountImpl(ctx, args); |
| @@ -113,7 +113,6 @@ export const listToolsWithWarnings = workspaceAction({ · 112 unmodified lines | ||
| 113 | 113 | sourceName: v.optional(v.string()), |
| 114 | 114 | cursor: v.optional(v.string()), |
| 115 | 115 | limit: v.optional(v.number()), |
| 116 | − buildId: v.optional(v.string()), | |
| 117 | 116 | fetchAll: v.optional(v.boolean()), |
| 118 | 117 | rebuildInventory: v.optional(v.boolean()), |
| 119 | 118 | }, |
| @@ -156,7 +155,6 @@ export const listToolsWithWarnings = workspaceAction({ · 36 unmodified lines | ||
| 156 | 155 | sourceName: args.sourceName, |
| 157 | 156 | cursor: args.cursor, |
| 158 | 157 | limit: args.limit, |
| 159 | − buildId: args.buildId, | |
| 160 | 158 | fetchAll: args.fetchAll, |
| 161 | 159 | }); |
| 162 | 160 | |
| @@ -44,6 +44,7 @@ export const prepareOpenApiSpec = internalAction({ · 43 unmodified lines | ||
| 44 | 44 | const prepared = await prepareOpenApiSpecInNode(args.specUrl, args.sourceName, { |
| 45 | 45 | includeDts: args.includeDts, |
| 46 | 46 | profile: "full", |
| 47 | + resolveSchemaRefs: args.includeDts === true, | |
| 47 | 48 | }); |
| 48 | 49 | |
| 49 | 50 | const json = JSON.stringify(prepared); |
| @@ -422,17 +422,13 @@ export default defineSchema({ · 421 unmodified lines | ||
| 422 | 422 | .index("by_spec_url_version", ["specUrl", "version"]), |
| 423 | 423 | |
| 424 | 424 | // Workspace tool registry state. |
| 425 | − // Stores the currently "ready" build id for search + invocation. | |
| 425 | + // Stores active incremental materialization state for search + invocation. | |
| 426 | 426 | workspaceToolRegistryState: defineTable({ |
| 427 | 427 | workspaceId: v.id("workspaces"), |
| 428 | 428 | signature: v.optional(v.string()), |
| 429 | − readyBuildId: v.optional(v.string()), | |
| 430 | − buildingBuildId: v.optional(v.string()), | |
| 431 | − buildingSignature: v.optional(v.string()), | |
| 432 | − buildingStartedAt: v.optional(v.number()), | |
| 433 | − lastBuildCompletedAt: v.optional(v.number()), | |
| 434 | − lastBuildFailedAt: v.optional(v.number()), | |
| 435 | − lastBuildError: v.optional(v.string()), | |
| 429 | + lastRefreshCompletedAt: v.optional(v.number()), | |
| 430 | + lastRefreshFailedAt: v.optional(v.number()), | |
| 431 | + lastRefreshError: v.optional(v.string()), | |
| 436 | 432 | typesStorageId: v.optional(v.id("_storage")), |
| 437 | 433 | warnings: v.optional(v.array(v.string())), |
| 438 | 434 | toolCount: v.optional(v.number()), |
| @@ -440,11 +436,7 @@ export default defineSchema({ · 1 unmodified line | ||
| 440 | 436 | sourceName: v.string(), |
| 441 | 437 | toolCount: v.number(), |
| 442 | 438 | }))), |
| 443 | − sourceVersions: v.optional(v.array(v.object({ | |
| 444 | − sourceId: v.string(), | |
| 445 | − sourceName: v.string(), | |
| 446 | − updatedAt: v.number(), | |
| 447 | − }))), | |
| 439 | + sourceStates: v.optional(v.array(jsonObjectValidator)), | |
| 448 | 440 | sourceQuality: v.optional(v.array(v.object({ |
| 449 | 441 | sourceKey: v.string(), |
| 450 | 442 | toolCount: v.number(), |
| @@ -479,7 +471,6 @@ export default defineSchema({ · 28 unmodified lines | ||
| 479 | 471 | // NOTE: We avoid storing raw JSON Schemas here because Convex forbids `$`-prefixed keys. |
| 480 | 472 | workspaceToolRegistry: defineTable({ |
| 481 | 473 | workspaceId: v.id("workspaces"), |
| 482 | − buildId: v.string(), | |
| 483 | 474 | path: v.string(), |
| 484 | 475 | preferredPath: v.string(), |
| 485 | 476 | namespace: v.string(), |
| @@ -500,37 +491,35 @@ export default defineSchema({ · 14 unmodified lines | ||
| 500 | 491 | })), |
| 501 | 492 | createdAt: v.number(), |
| 502 | 493 | }) |
| 503 | − .index("by_workspace_build_path", ["workspaceId", "buildId", "path"]) | |
| 504 | − .index("by_workspace_build_normalized", ["workspaceId", "buildId", "normalizedPath"]) | |
| 505 | − .index("by_workspace_build_source", ["workspaceId", "buildId", "source"]) | |
| 506 | − .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]) | |
| 507 | − .index("by_workspace_build", ["workspaceId", "buildId"]) | |
| 494 | + .index("by_workspace_path", ["workspaceId", "path"]) | |
| 495 | + .index("by_workspace_normalized", ["workspaceId", "normalizedPath"]) | |
| 496 | + .index("by_workspace_source", ["workspaceId", "source"]) | |
| 497 | + .index("by_workspace_namespace", ["workspaceId", "namespace"]) | |
| 498 | + .index("by_workspace", ["workspaceId"]) | |
| 508 | 499 | .searchIndex("search_text", { |
| 509 | 500 | searchField: "searchText", |
| 510 | − filterFields: ["workspaceId", "buildId"], | |
| 501 | + filterFields: ["workspaceId"], | |
| 511 | 502 | }), |
| 512 | 503 | |
| 513 | 504 | // Heavy per-tool payloads kept separate from searchable metadata. |
| 514 | 505 | workspaceToolRegistryPayloads: defineTable({ |
| 515 | 506 | workspaceId: v.id("workspaces"), |
| 516 | − buildId: v.string(), | |
| 517 | 507 | path: v.string(), |
| 518 | 508 | serializedToolJson: v.string(), |
| 519 | 509 | createdAt: v.number(), |
| 520 | 510 | }) |
| 521 | − .index("by_workspace_build_path", ["workspaceId", "buildId", "path"]) | |
| 522 | − .index("by_workspace_build", ["workspaceId", "buildId"]), | |
| 511 | + .index("by_workspace_path", ["workspaceId", "path"]) | |
| 512 | + .index("by_workspace", ["workspaceId"]), | |
| 523 | 513 | |
| 524 | 514 | // Precomputed namespace summaries for fast catalog.namespaces. |
| 525 | 515 | workspaceToolNamespaces: defineTable({ |
| 526 | 516 | workspaceId: v.id("workspaces"), |
| 527 | − buildId: v.string(), | |
| 528 | 517 | namespace: v.string(), |
| 529 | 518 | toolCount: v.number(), |
| 530 | 519 | samplePaths: v.array(v.string()), |
| 531 | 520 | createdAt: v.number(), |
| 532 | 521 | }) |
| 533 | − .index("by_workspace_build", ["workspaceId", "buildId"]), | |
| 522 | + .index("by_workspace", ["workspaceId"]), | |
| 534 | 523 | |
| 535 | 524 | // Durable and ephemeral storage instances used by filesystem/kv/sql tools. |
| 536 | 525 | // Rows are scope-aware and can be shared across account/workspace/org contexts. |
| @@ -0,0 +1,99 @@ | ||
| 1 | +import { describe, expect, test } from "bun:test"; | |
| 2 | +import { convexTest } from "convex-test"; | |
| 3 | +import { internal } from "./_generated/api"; | |
| 4 | +import schema from "./schema"; | |
| 5 | +import { registerRateLimiterComponent } from "./testHelpers"; | |
| 6 | + | |
| 7 | +function setup() { | |
| 8 | + const t = convexTest(schema, { | |
| 9 | + "./database.ts": () => import("./database"), | |
| 10 | + "./toolRegistry.ts": () => import("./toolRegistry"), | |
| 11 | + "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), | |
| 12 | + "./_generated/api.js": () => import("./_generated/api.js"), | |
| 13 | + }); | |
| 14 | + | |
| 15 | + registerRateLimiterComponent(t); | |
| 16 | + return t; | |
| 17 | +} | |
| 18 | + | |
| 19 | +function makeRegistryTool(path: string, source = "openapi:bulk") { | |
| 20 | + return { | |
| 21 | + path, | |
| 22 | + preferredPath: path, | |
| 23 | + namespace: path.split(".")[0] ?? "bulk", | |
| 24 | + normalizedPath: path.toLowerCase(), | |
| 25 | + aliases: [], | |
| 26 | + description: `Tool ${path}`, | |
| 27 | + approval: "auto" as const, | |
| 28 | + source, | |
| 29 | + searchText: `${path} ${source}`, | |
| 30 | + displayInput: "{}", | |
| 31 | + displayOutput: "{ ok: boolean }", | |
| 32 | + requiredInputKeys: [], | |
| 33 | + previewInputKeys: [], | |
| 34 | + serializedToolJson: JSON.stringify({ | |
| 35 | + path, | |
| 36 | + description: `Tool ${path}`, | |
| 37 | + approval: "auto", | |
| 38 | + source, | |
| 39 | + }), | |
| 40 | + }; | |
| 41 | +} | |
| 42 | + | |
| 43 | +describe("toolRegistry cleanup", () => { | |
| 44 | + test("putToolsBatch keeps a single row per workspace/path", async () => { | |
| 45 | + const t = setup(); | |
| 46 | + const session = await t.mutation(internal.database.bootstrapAnonymousSession, {}); | |
| 47 | + | |
| 48 | + await t.mutation(internal.toolRegistry.putToolsBatch, { | |
| 49 | + workspaceId: session.workspaceId, | |
| 50 | + tools: [makeRegistryTool("bulk.same_path")], | |
| 51 | + }); | |
| 52 | + | |
| 53 | + await t.mutation(internal.toolRegistry.putToolsBatch, { | |
| 54 | + workspaceId: session.workspaceId, | |
| 55 | + tools: [makeRegistryTool("bulk.same_path")], | |
| 56 | + }); | |
| 57 | + | |
| 58 | + const page = await t.query(internal.toolRegistry.listToolsPage, { | |
| 59 | + workspaceId: session.workspaceId, | |
| 60 | + limit: 50, | |
| 61 | + }); | |
| 62 | + | |
| 63 | + expect(page.items.filter((item) => item.path === "bulk.same_path").length).toBe(1); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + test("deleteToolsBySource action pages through large source inventories", async () => { | |
| 67 | + const t = setup(); | |
| 68 | + const session = await t.mutation(internal.database.bootstrapAnonymousSession, {}); | |
| 69 | + | |
| 70 | + const total = 1600; | |
| 71 | + const batchSize = 100; | |
| 72 | + for (let i = 0; i < total; i += batchSize) { | |
| 73 | + const tools = Array.from({ length: Math.min(batchSize, total - i) }, (_, offset) => | |
| 74 | + makeRegistryTool(`bulk.tool_${i + offset}`, "openapi:bulk"), | |
| 75 | + ); | |
| 76 | + | |
| 77 | + await t.mutation(internal.toolRegistry.putToolsBatch, { | |
| 78 | + workspaceId: session.workspaceId, | |
| 79 | + tools, | |
| 80 | + }); | |
| 81 | + } | |
| 82 | + | |
| 83 | + const removed = await t.action(internal.toolRegistry.deleteToolsBySource, { | |
| 84 | + workspaceId: session.workspaceId, | |
| 85 | + source: "openapi:bulk", | |
| 86 | + }); | |
| 87 | + | |
| 88 | + expect(removed.removed).toBe(total); | |
| 89 | + | |
| 90 | + const remaining = await t.query(internal.toolRegistry.listToolsBySourcePage, { | |
| 91 | + workspaceId: session.workspaceId, | |
| 92 | + source: "openapi:bulk", | |
| 93 | + limit: 50, | |
| 94 | + }); | |
| 95 | + | |
| 96 | + expect(remaining.items.length).toBe(0); | |
| 97 | + expect(remaining.continueCursor).toBeNull(); | |
| 98 | + }, 120_000); | |
| 99 | +}); | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { v } from "convex/values"; |
| 2 | −import { internal } from "./_generated/api"; | |
| 3 | 2 | import { internalAction, internalMutation, internalQuery } from "./_generated/server"; |
| 3 | +import { internal } from "./_generated/api"; | |
| 4 | 4 | import { vv } from "./typedV"; |
| 5 | 5 | |
| 6 | 6 | export const getState = internalQuery({ |
| @@ -16,21 +16,20 @@ export const getState = internalQuery({ · 9 unmodified lines | ||
| 16 | 16 | if (!entry) return null; |
| 17 | 17 | |
| 18 | 18 | const stateEntry = entry as Record<string, unknown>; |
| 19 | + const sourceStates = Array.isArray(stateEntry.sourceStates) | |
| 20 | + ? stateEntry.sourceStates.filter((value): value is Record<string, unknown> => Boolean(value && typeof value === "object")) | |
| 21 | + : []; | |
| 19 | 22 | |
| 20 | 23 | return { |
| 21 | 24 | signature: entry.signature, |
| 22 | − readyBuildId: entry.readyBuildId, | |
| 23 | − buildingBuildId: entry.buildingBuildId, | |
| 24 | − buildingSignature: entry.buildingSignature, | |
| 25 | − buildingStartedAt: entry.buildingStartedAt, | |
| 26 | − lastBuildCompletedAt: entry.lastBuildCompletedAt, | |
| 27 | − lastBuildFailedAt: entry.lastBuildFailedAt, | |
| 28 | − lastBuildError: entry.lastBuildError, | |
| 25 | + lastRefreshCompletedAt: entry.lastRefreshCompletedAt, | |
| 26 | + lastRefreshFailedAt: entry.lastRefreshFailedAt, | |
| 27 | + lastRefreshError: entry.lastRefreshError, | |
| 29 | 28 | typesStorageId: entry.typesStorageId, |
| 30 | 29 | warnings: entry.warnings ?? [], |
| 31 | 30 | toolCount: entry.toolCount, |
| 32 | 31 | sourceToolCounts: entry.sourceToolCounts ?? [], |
| 33 | − sourceVersions: entry.sourceVersions ?? [], | |
| 32 | + sourceStates, | |
| 34 | 33 | sourceQuality: entry.sourceQuality ?? [], |
| 35 | 34 | sourceAuthProfiles: entry.sourceAuthProfiles ?? [], |
| 36 | 35 | openApiRefHintTables: Array.isArray(stateEntry.openApiRefHintTables) ? stateEntry.openApiRefHintTables : [], |
| @@ -39,58 +38,9 @@ export const getState = internalQuery({ · 2 unmodified lines | ||
| 39 | 38 | }, |
| 40 | 39 | }); |
| 41 | 40 | |
| 42 | −export const beginBuild = internalMutation({ | |
| 43 | − args: { | |
| 44 | − workspaceId: vv.id("workspaces"), | |
| 45 | − signature: v.string(), | |
| 46 | − buildId: v.string(), | |
| 47 | − }, | |
| 48 | − handler: async (ctx, args) => { | |
| 49 | − const now = Date.now(); | |
| 50 | − const existing = await ctx.db | |
| 51 | − .query("workspaceToolRegistryState") | |
| 52 | − .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 53 | − .unique(); | |
| 54 | − | |
| 55 | − if (existing) { | |
| 56 | − await ctx.db.patch(existing._id, { | |
| 57 | − buildingBuildId: args.buildId, | |
| 58 | − buildingSignature: args.signature, | |
| 59 | − buildingStartedAt: now, | |
| 60 | − lastBuildError: undefined, | |
| 61 | − updatedAt: now, | |
| 62 | − }); | |
| 63 | − return; | |
| 64 | − } | |
| 65 | − | |
| 66 | − await ctx.db.insert("workspaceToolRegistryState", { | |
| 67 | − workspaceId: args.workspaceId, | |
| 68 | − signature: args.signature, | |
| 69 | − readyBuildId: undefined, | |
| 70 | − buildingBuildId: args.buildId, | |
| 71 | − buildingSignature: args.signature, | |
| 72 | − buildingStartedAt: now, | |
| 73 | − lastBuildCompletedAt: undefined, | |
| 74 | − lastBuildFailedAt: undefined, | |
| 75 | − lastBuildError: undefined, | |
| 76 | − typesStorageId: undefined, | |
| 77 | − warnings: [], | |
| 78 | − toolCount: 0, | |
| 79 | − sourceToolCounts: [], | |
| 80 | − sourceVersions: [], | |
| 81 | − sourceQuality: [], | |
| 82 | − sourceAuthProfiles: [], | |
| 83 | − openApiRefHintTables: [], | |
| 84 | − createdAt: now, | |
| 85 | − updatedAt: now, | |
| 86 | − } as never); | |
| 87 | − }, | |
| 88 | −}); | |
| 89 | − | |
| 90 | 41 | export const putToolsBatch = internalMutation({ |
| 91 | 42 | args: { |
| 92 | 43 | workspaceId: vv.id("workspaces"), |
| 93 | − buildId: v.string(), | |
| 94 | 44 | tools: v.array( |
| 95 | 45 | v.object({ |
| 96 | 46 | path: v.string(), |
| @@ -120,9 +70,43 @@ export const putToolsBatch = internalMutation({ · 23 unmodified lines | ||
| 120 | 70 | handler: async (ctx, args) => { |
| 121 | 71 | const now = Date.now(); |
| 122 | 72 | for (const tool of args.tools) { |
| 73 | + // Enforce a single active row per tool path for the workspace. | |
| 74 | + while (true) { | |
| 75 | + const existingEntries = await ctx.db | |
| 76 | + .query("workspaceToolRegistry") | |
| 77 | + .withIndex("by_workspace_path", (q) => | |
| 78 | + q.eq("workspaceId", args.workspaceId).eq("path", tool.path), | |
| 79 | + ) | |
| 80 | + .take(20); | |
| 81 | + | |
| 82 | + if (existingEntries.length === 0) { | |
| 83 | + break; | |
| 84 | + } | |
| 85 | + | |
| 86 | + for (const entry of existingEntries) { | |
| 87 | + await ctx.db.delete(entry._id); | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + while (true) { | |
| 92 | + const existingPayloads = await ctx.db | |
| 93 | + .query("workspaceToolRegistryPayloads") | |
| 94 | + .withIndex("by_workspace_path", (q) => | |
| 95 | + q.eq("workspaceId", args.workspaceId).eq("path", tool.path), | |
| 96 | + ) | |
| 97 | + .take(20); | |
| 98 | + | |
| 99 | + if (existingPayloads.length === 0) { | |
| 100 | + break; | |
| 101 | + } | |
| 102 | + | |
| 103 | + for (const payload of existingPayloads) { | |
| 104 | + await ctx.db.delete(payload._id); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 123 | 108 | await ctx.db.insert("workspaceToolRegistry", { |
| 124 | 109 | workspaceId: args.workspaceId, |
| 125 | − buildId: args.buildId, | |
| 126 | 110 | path: tool.path, |
| 127 | 111 | preferredPath: tool.preferredPath, |
| 128 | 112 | namespace: tool.namespace, |
| @@ -142,7 +126,6 @@ export const putToolsBatch = internalMutation({ · 13 unmodified lines | ||
| 142 | 126 | |
| 143 | 127 | await ctx.db.insert("workspaceToolRegistryPayloads", { |
| 144 | 128 | workspaceId: args.workspaceId, |
| 145 | − buildId: args.buildId, | |
| 146 | 129 | path: tool.path, |
| 147 | 130 | serializedToolJson: tool.serializedToolJson, |
| 148 | 131 | createdAt: now, |
| @@ -154,7 +137,6 @@ export const putToolsBatch = internalMutation({ · 5 unmodified lines | ||
| 154 | 137 | export const putNamespacesBatch = internalMutation({ |
| 155 | 138 | args: { |
| 156 | 139 | workspaceId: vv.id("workspaces"), |
| 157 | − buildId: v.string(), | |
| 158 | 140 | namespaces: v.array( |
| 159 | 141 | v.object({ |
| 160 | 142 | namespace: v.string(), |
| @@ -168,7 +150,6 @@ export const putNamespacesBatch = internalMutation({ · 7 unmodified lines | ||
| 168 | 150 | for (const ns of args.namespaces) { |
| 169 | 151 | await ctx.db.insert("workspaceToolNamespaces", { |
| 170 | 152 | workspaceId: args.workspaceId, |
| 171 | − buildId: args.buildId, | |
| 172 | 153 | namespace: ns.namespace, |
| 173 | 154 | toolCount: ns.toolCount, |
| 174 | 155 | samplePaths: ns.samplePaths, |
| @@ -178,66 +159,9 @@ export const putNamespacesBatch = internalMutation({ · 3 unmodified lines | ||
| 178 | 159 | }, |
| 179 | 160 | }); |
| 180 | 161 | |
| 181 | −export const finishBuild = internalMutation({ | |
| 182 | − args: { | |
| 183 | − workspaceId: vv.id("workspaces"), | |
| 184 | − buildId: v.string(), | |
| 185 | − signature: v.string(), | |
| 186 | − }, | |
| 187 | − handler: async (ctx, args) => { | |
| 188 | − const now = Date.now(); | |
| 189 | − const state = await ctx.db | |
| 190 | − .query("workspaceToolRegistryState") | |
| 191 | − .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 192 | − .unique(); | |
| 193 | − | |
| 194 | − if (!state) { | |
| 195 | − await ctx.db.insert("workspaceToolRegistryState", { | |
| 196 | − workspaceId: args.workspaceId, | |
| 197 | − signature: args.signature, | |
| 198 | − readyBuildId: args.buildId, | |
| 199 | − buildingBuildId: undefined, | |
| 200 | − buildingSignature: undefined, | |
| 201 | − buildingStartedAt: undefined, | |
| 202 | − lastBuildCompletedAt: now, | |
| 203 | − lastBuildFailedAt: undefined, | |
| 204 | − lastBuildError: undefined, | |
| 205 | − typesStorageId: undefined, | |
| 206 | − warnings: [], | |
| 207 | − toolCount: 0, | |
| 208 | − sourceToolCounts: [], | |
| 209 | − sourceVersions: [], | |
| 210 | − sourceQuality: [], | |
| 211 | − sourceAuthProfiles: [], | |
| 212 | − openApiRefHintTables: [], | |
| 213 | − createdAt: now, | |
| 214 | − updatedAt: now, | |
| 215 | − } as never); | |
| 216 | − return; | |
| 217 | − } | |
| 218 | − | |
| 219 | − if (state.buildingBuildId !== args.buildId) { | |
| 220 | − // Another build started; ignore finishing this one. | |
| 221 | − return; | |
| 222 | − } | |
| 223 | − | |
| 224 | − await ctx.db.patch(state._id, { | |
| 225 | − readyBuildId: args.buildId, | |
| 226 | − signature: args.signature, | |
| 227 | − buildingBuildId: undefined, | |
| 228 | − buildingSignature: undefined, | |
| 229 | − buildingStartedAt: undefined, | |
| 230 | − lastBuildCompletedAt: now, | |
| 231 | − lastBuildError: undefined, | |
| 232 | − updatedAt: now, | |
| 233 | − }); | |
| 234 | − }, | |
| 235 | −}); | |
| 236 | − | |
| 237 | −export const updateBuildMetadata = internalMutation({ | |
| 162 | +export const updateRegistryMetadata = internalMutation({ | |
| 238 | 163 | args: { |
| 239 | 164 | workspaceId: vv.id("workspaces"), |
| 240 | − buildId: v.string(), | |
| 241 | 165 | typesStorageId: v.optional(v.id("_storage")), |
| 242 | 166 | warnings: v.array(v.string()), |
| 243 | 167 | toolCount: v.number(), |
| @@ -245,11 +169,6 @@ export const updateBuildMetadata = internalMutation({ · 1 unmodified line | ||
| 245 | 169 | sourceName: v.string(), |
| 246 | 170 | toolCount: v.number(), |
| 247 | 171 | })), |
| 248 | − sourceVersions: v.array(v.object({ | |
| 249 | − sourceId: v.string(), | |
| 250 | − sourceName: v.string(), | |
| 251 | − updatedAt: v.number(), | |
| 252 | − })), | |
| 253 | 172 | sourceQuality: v.array(v.object({ |
| 254 | 173 | sourceKey: v.string(), |
| 255 | 174 | toolCount: v.number(), |
| @@ -282,9 +201,23 @@ export const updateBuildMetadata = internalMutation({ · 26 unmodified lines | ||
| 282 | 201 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 283 | 202 | .unique(); |
| 284 | 203 | if (!state) { |
| 285 | − return; | |
| 286 | − } | |
| 287 | − if (state.readyBuildId !== args.buildId) { | |
| 204 | + await ctx.db.insert("workspaceToolRegistryState", { | |
| 205 | + workspaceId: args.workspaceId, | |
| 206 | + signature: undefined, | |
| 207 | + lastRefreshCompletedAt: Date.now(), | |
| 208 | + lastRefreshFailedAt: undefined, | |
| 209 | + lastRefreshError: undefined, | |
| 210 | + typesStorageId: args.typesStorageId, | |
| 211 | + warnings: args.warnings, | |
| 212 | + toolCount: args.toolCount, | |
| 213 | + sourceToolCounts: args.sourceToolCounts, | |
| 214 | + sourceStates: [], | |
| 215 | + sourceQuality: args.sourceQuality, | |
| 216 | + sourceAuthProfiles: args.sourceAuthProfiles, | |
| 217 | + openApiRefHintTables: args.openApiRefHintTables, | |
| 218 | + createdAt: Date.now(), | |
| 219 | + updatedAt: Date.now(), | |
| 220 | + } as never); | |
| 288 | 221 | return; |
| 289 | 222 | } |
| 290 | 223 | |
| @@ -293,251 +226,234 @@ export const updateBuildMetadata = internalMutation({ · 2 unmodified lines | ||
| 293 | 226 | warnings: args.warnings, |
| 294 | 227 | toolCount: args.toolCount, |
| 295 | 228 | sourceToolCounts: args.sourceToolCounts, |
| 296 | − sourceVersions: args.sourceVersions, | |
| 297 | 229 | sourceQuality: args.sourceQuality, |
| 298 | 230 | sourceAuthProfiles: args.sourceAuthProfiles, |
| 299 | 231 | openApiRefHintTables: args.openApiRefHintTables, |
| 232 | + lastRefreshCompletedAt: Date.now(), | |
| 233 | + lastRefreshError: undefined, | |
| 300 | 234 | updatedAt: Date.now(), |
| 301 | 235 | } as never); |
| 302 | 236 | }, |
| 303 | 237 | }); |
| 304 | 238 | |
| 305 | −export const failBuild = internalMutation({ | |
| 239 | +const sourceStateValidator = v.any(); | |
| 240 | + | |
| 241 | +export const setSourceStates = internalMutation({ | |
| 306 | 242 | args: { |
| 307 | 243 | workspaceId: vv.id("workspaces"), |
| 308 | − buildId: v.string(), | |
| 309 | − error: v.optional(v.string()), | |
| 244 | + sourceStates: v.array(sourceStateValidator), | |
| 245 | + signature: v.optional(v.string()), | |
| 310 | 246 | }, |
| 311 | 247 | handler: async (ctx, args) => { |
| 248 | + const now = Date.now(); | |
| 312 | 249 | const state = await ctx.db |
| 313 | 250 | .query("workspaceToolRegistryState") |
| 314 | 251 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 315 | 252 | .unique(); |
| 253 | + | |
| 316 | 254 | if (!state) { |
| 317 | − return; | |
| 318 | − } | |
| 319 | − if (state.buildingBuildId !== args.buildId) { | |
| 255 | + await ctx.db.insert("workspaceToolRegistryState", { | |
| 256 | + workspaceId: args.workspaceId, | |
| 257 | + signature: args.signature, | |
| 258 | + lastRefreshCompletedAt: undefined, | |
| 259 | + lastRefreshFailedAt: undefined, | |
| 260 | + lastRefreshError: undefined, | |
| 261 | + typesStorageId: undefined, | |
| 262 | + warnings: [], | |
| 263 | + toolCount: 0, | |
| 264 | + sourceToolCounts: [], | |
| 265 | + sourceStates: args.sourceStates, | |
| 266 | + sourceQuality: [], | |
| 267 | + sourceAuthProfiles: [], | |
| 268 | + openApiRefHintTables: [], | |
| 269 | + createdAt: now, | |
| 270 | + updatedAt: now, | |
| 271 | + } as never); | |
| 320 | 272 | return; |
| 321 | 273 | } |
| 322 | 274 | |
| 323 | 275 | await ctx.db.patch(state._id, { |
| 324 | − buildingBuildId: undefined, | |
| 325 | − buildingSignature: undefined, | |
| 326 | − buildingStartedAt: undefined, | |
| 327 | − lastBuildFailedAt: Date.now(), | |
| 328 | − lastBuildError: args.error, | |
| 329 | − updatedAt: Date.now(), | |
| 276 | + ...(args.signature ? { signature: args.signature } : {}), | |
| 277 | + sourceStates: args.sourceStates, | |
| 278 | + updatedAt: now, | |
| 330 | 279 | }); |
| 331 | 280 | }, |
| 332 | 281 | }); |
| 333 | 282 | |
| 334 | −const PRUNE_DELETE_PAGE_SIZE = 10; | |
| 335 | −const PRUNE_NAMESPACE_SCAN_PAGE_SIZE = 250; | |
| 336 | − | |
| 337 | −export const scanNamespaceBuildsForPrune = internalQuery({ | |
| 283 | +export const setRefreshError = internalMutation({ | |
| 338 | 284 | args: { |
| 339 | 285 | workspaceId: vv.id("workspaces"), |
| 340 | − cursor: v.optional(v.string()), | |
| 286 | + error: v.string(), | |
| 341 | 287 | }, |
| 342 | 288 | handler: async (ctx, args) => { |
| 343 | − const page = await ctx.db | |
| 344 | − .query("workspaceToolNamespaces") | |
| 345 | − .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 346 | − .paginate({ numItems: PRUNE_NAMESPACE_SCAN_PAGE_SIZE, cursor: args.cursor ?? null }); | |
| 289 | + const now = Date.now(); | |
| 290 | + const state = await ctx.db | |
| 291 | + .query("workspaceToolRegistryState") | |
| 292 | + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 293 | + .unique(); | |
| 347 | 294 | |
| 348 | − return { | |
| 349 | − items: page.page.map((entry) => ({ | |
| 350 | − buildId: entry.buildId, | |
| 351 | − createdAt: entry.createdAt, | |
| 352 | − })), | |
| 353 | − continueCursor: page.isDone ? null : page.continueCursor, | |
| 354 | − }; | |
| 295 | + if (!state) { | |
| 296 | + await ctx.db.insert("workspaceToolRegistryState", { | |
| 297 | + workspaceId: args.workspaceId, | |
| 298 | + signature: undefined, | |
| 299 | + lastRefreshCompletedAt: undefined, | |
| 300 | + lastRefreshFailedAt: now, | |
| 301 | + lastRefreshError: args.error, | |
| 302 | + typesStorageId: undefined, | |
| 303 | + warnings: [], | |
| 304 | + toolCount: 0, | |
| 305 | + sourceToolCounts: [], | |
| 306 | + sourceStates: [], | |
| 307 | + sourceQuality: [], | |
| 308 | + sourceAuthProfiles: [], | |
| 309 | + openApiRefHintTables: [], | |
| 310 | + createdAt: now, | |
| 311 | + updatedAt: now, | |
| 312 | + } as never); | |
| 313 | + return; | |
| 314 | + } | |
| 315 | + | |
| 316 | + await ctx.db.patch(state._id, { | |
| 317 | + lastRefreshFailedAt: now, | |
| 318 | + lastRefreshError: args.error, | |
| 319 | + updatedAt: now, | |
| 320 | + }); | |
| 355 | 321 | }, |
| 356 | 322 | }); |
| 357 | 323 | |
| 358 | −export const deleteToolRegistryToolsPage = internalMutation({ | |
| 324 | +export const deleteToolsBySourcePage = internalMutation({ | |
| 359 | 325 | args: { |
| 360 | 326 | workspaceId: vv.id("workspaces"), |
| 361 | − buildId: v.string(), | |
| 362 | − cursor: v.optional(v.string()), | |
| 327 | + source: v.string(), | |
| 363 | 328 | }, |
| 364 | 329 | handler: async (ctx, args) => { |
| 365 | − const page = await ctx.db | |
| 330 | + const source = args.source.trim(); | |
| 331 | + if (!source) { | |
| 332 | + return { removed: 0 }; | |
| 333 | + } | |
| 334 | + | |
| 335 | + const PAGE_SIZE = 100; | |
| 336 | + let removed = 0; | |
| 337 | + | |
| 338 | + const entries = await ctx.db | |
| 366 | 339 | .query("workspaceToolRegistry") |
| 367 | − .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId)) | |
| 368 | − .paginate({ numItems: PRUNE_DELETE_PAGE_SIZE, cursor: args.cursor ?? null }); | |
| 340 | + .withIndex("by_workspace_source", (q) => | |
| 341 | + q.eq("workspaceId", args.workspaceId).eq("source", source), | |
| 342 | + ) | |
| 343 | + .take(PAGE_SIZE); | |
| 369 | 344 | |
| 370 | − for (const entry of page.page) { | |
| 345 | + for (const entry of entries) { | |
| 371 | 346 | await ctx.db.delete(entry._id); |
| 347 | + removed += 1; | |
| 348 | + | |
| 349 | + // Payload table is keyed by path; delete all matching payload rows in small slices. | |
| 350 | + while (true) { | |
| 351 | + const payloads = await ctx.db | |
| 352 | + .query("workspaceToolRegistryPayloads") | |
| 353 | + .withIndex("by_workspace_path", (q) => | |
| 354 | + q.eq("workspaceId", args.workspaceId).eq("path", entry.path), | |
| 355 | + ) | |
| 356 | + .take(20); | |
| 357 | + | |
| 358 | + if (payloads.length === 0) { | |
| 359 | + break; | |
| 360 | + } | |
| 361 | + | |
| 362 | + for (const payload of payloads) { | |
| 363 | + await ctx.db.delete(payload._id); | |
| 364 | + } | |
| 365 | + } | |
| 372 | 366 | } |
| 373 | 367 | |
| 374 | − return { continueCursor: page.isDone ? null : page.continueCursor }; | |
| 368 | + return { | |
| 369 | + removed, | |
| 370 | + hasMore: entries.length >= PAGE_SIZE, | |
| 371 | + }; | |
| 375 | 372 | }, |
| 376 | 373 | }); |
| 377 | 374 | |
| 378 | −export const deleteToolRegistryPayloadsPage = internalMutation({ | |
| 375 | +export const deleteToolsBySource = internalAction({ | |
| 379 | 376 | args: { |
| 380 | 377 | workspaceId: vv.id("workspaces"), |
| 381 | − buildId: v.string(), | |
| 382 | − cursor: v.optional(v.string()), | |
| 378 | + source: v.string(), | |
| 383 | 379 | }, |
| 384 | 380 | handler: async (ctx, args) => { |
| 385 | − const page = await ctx.db | |
| 386 | − .query("workspaceToolRegistryPayloads") | |
| 387 | − .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId)) | |
| 388 | − .paginate({ numItems: PRUNE_DELETE_PAGE_SIZE, cursor: args.cursor ?? null }); | |
| 381 | + let removed = 0; | |
| 389 | 382 | |
| 390 | − for (const entry of page.page) { | |
| 391 | − await ctx.db.delete(entry._id); | |
| 383 | + while (true) { | |
| 384 | + const page: { removed: number; hasMore: boolean } = await ctx.runMutation( | |
| 385 | + internal.toolRegistry.deleteToolsBySourcePage, | |
| 386 | + { | |
| 387 | + workspaceId: args.workspaceId, | |
| 388 | + source: args.source, | |
| 389 | + }, | |
| 390 | + ); | |
| 391 | + | |
| 392 | + removed += page.removed; | |
| 393 | + if (!page.hasMore || page.removed === 0) { | |
| 394 | + break; | |
| 395 | + } | |
| 392 | 396 | } |
| 393 | 397 | |
| 394 | − return { continueCursor: page.isDone ? null : page.continueCursor }; | |
| 398 | + return { removed }; | |
| 395 | 399 | }, |
| 396 | 400 | }); |
| 397 | 401 | |
| 398 | −export const deleteToolRegistryNamespacesPage = internalMutation({ | |
| 402 | +export const listSerializedToolsPage = internalQuery({ | |
| 399 | 403 | args: { |
| 400 | 404 | workspaceId: vv.id("workspaces"), |
| 401 | − buildId: v.string(), | |
| 402 | 405 | cursor: v.optional(v.string()), |
| 406 | + limit: v.number(), | |
| 403 | 407 | }, |
| 404 | 408 | handler: async (ctx, args) => { |
| 409 | + const limit = Math.max(1, Math.min(250, Math.floor(args.limit))); | |
| 405 | 410 | const page = await ctx.db |
| 406 | − .query("workspaceToolNamespaces") | |
| 407 | − .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId)) | |
| 408 | − .paginate({ numItems: PRUNE_DELETE_PAGE_SIZE, cursor: args.cursor ?? null }); | |
| 409 | − | |
| 410 | − for (const entry of page.page) { | |
| 411 | − await ctx.db.delete(entry._id); | |
| 412 | − } | |
| 411 | + .query("workspaceToolRegistryPayloads") | |
| 412 | + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 413 | + .paginate({ | |
| 414 | + numItems: limit, | |
| 415 | + cursor: args.cursor ?? null, | |
| 416 | + }); | |
| 413 | 417 | |
| 414 | − return { continueCursor: page.isDone ? null : page.continueCursor }; | |
| 418 | + return { | |
| 419 | + continueCursor: page.isDone ? null : page.continueCursor, | |
| 420 | + items: page.page.map((entry) => ({ | |
| 421 | + path: entry.path, | |
| 422 | + serializedToolJson: entry.serializedToolJson, | |
| 423 | + })), | |
| 424 | + }; | |
| 415 | 425 | }, |
| 416 | 426 | }); |
| 417 | 427 | |
| 418 | −export const pruneBuilds = internalAction({ | |
| 428 | +export const deleteToolRegistryNamespacesPage = internalMutation({ | |
| 419 | 429 | args: { |
| 420 | 430 | workspaceId: vv.id("workspaces"), |
| 421 | − maxRetainedBuilds: v.optional(v.number()), | |
| 431 | + cursor: v.optional(v.string()), | |
| 422 | 432 | }, |
| 423 | 433 | handler: async (ctx, args) => { |
| 424 | − const maxRetainedBuilds = Math.max(1, Math.min(10, Math.floor(args.maxRetainedBuilds ?? 2))); | |
| 425 | − const state = await ctx.runQuery(internal.toolRegistry.getState, { | |
| 426 | − workspaceId: args.workspaceId, | |
| 427 | − }); | |
| 428 | − | |
| 429 | − const protectedBuildIds = new Set<string>(); | |
| 430 | − if (state?.readyBuildId) protectedBuildIds.add(state.readyBuildId); | |
| 431 | − if (state?.buildingBuildId) protectedBuildIds.add(state.buildingBuildId); | |
| 432 | − | |
| 433 | − const latestCreatedAtByBuild = new Map<string, number>(); | |
| 434 | − let namespaceCursor: string | undefined = undefined; | |
| 435 | − | |
| 436 | − while (true) { | |
| 437 | − const namespacePage: { | |
| 438 | − continueCursor: string | null; | |
| 439 | − items: Array<{ buildId: string; createdAt: number }>; | |
| 440 | − } = await ctx.runQuery(internal.toolRegistry.scanNamespaceBuildsForPrune, { | |
| 441 | − workspaceId: args.workspaceId, | |
| 442 | − cursor: namespaceCursor, | |
| 443 | − }); | |
| 444 | − | |
| 445 | − for (const item of namespacePage.items) { | |
| 446 | − const current = latestCreatedAtByBuild.get(item.buildId) ?? 0; | |
| 447 | − if (item.createdAt > current) { | |
| 448 | − latestCreatedAtByBuild.set(item.buildId, item.createdAt); | |
| 449 | − } | |
| 450 | − } | |
| 451 | − | |
| 452 | − if (namespacePage.continueCursor === null) { | |
| 453 | − break; | |
| 454 | − } | |
| 455 | − | |
| 456 | − namespaceCursor = namespacePage.continueCursor; | |
| 457 | − } | |
| 458 | − | |
| 459 | − const buildIdsByRecency = [...latestCreatedAtByBuild.entries()] | |
| 460 | − .sort((a, b) => b[1] - a[1]) | |
| 461 | − .map(([buildId]) => buildId); | |
| 434 | + const page = await ctx.db | |
| 435 | + .query("workspaceToolNamespaces") | |
| 436 | + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 437 | + .paginate({ numItems: 100, cursor: args.cursor ?? null }); | |
| 462 | 438 | |
| 463 | − const retained = new Set<string>(buildIdsByRecency.slice(0, maxRetainedBuilds)); | |
| 464 | − for (const protectedBuildId of protectedBuildIds) { | |
| 465 | − retained.add(protectedBuildId); | |
| 439 | + for (const entry of page.page) { | |
| 440 | + await ctx.db.delete(entry._id); | |
| 466 | 441 | } |
| 467 | 442 | |
| 468 | − for (const buildId of buildIdsByRecency) { | |
| 469 | − if (retained.has(buildId)) { | |
| 470 | − continue; | |
| 471 | − } | |
| 472 | − | |
| 473 | − let cursor: string | undefined = undefined; | |
| 474 | − while (true) { | |
| 475 | − const page: { continueCursor: string | null } = await ctx.runMutation( | |
| 476 | − internal.toolRegistry.deleteToolRegistryToolsPage, | |
| 477 | − { | |
| 478 | − workspaceId: args.workspaceId, | |
| 479 | − buildId, | |
| 480 | − cursor, | |
| 481 | − }, | |
| 482 | − ); | |
| 483 | − | |
| 484 | − if (page.continueCursor === null) { | |
| 485 | − break; | |
| 486 | − } | |
| 487 | − | |
| 488 | − cursor = page.continueCursor; | |
| 489 | − } | |
| 490 | − | |
| 491 | − let payloadCursor: string | undefined = undefined; | |
| 492 | − while (true) { | |
| 493 | − const payloadPage: { continueCursor: string | null } = await ctx.runMutation( | |
| 494 | − internal.toolRegistry.deleteToolRegistryPayloadsPage, | |
| 495 | − { | |
| 496 | − workspaceId: args.workspaceId, | |
| 497 | − buildId, | |
| 498 | − cursor: payloadCursor, | |
| 499 | − }, | |
| 500 | − ); | |
| 501 | − | |
| 502 | − if (payloadPage.continueCursor === null) { | |
| 503 | − break; | |
| 504 | − } | |
| 505 | − | |
| 506 | − payloadCursor = payloadPage.continueCursor; | |
| 507 | − } | |
| 508 | − | |
| 509 | − let namespacePageCursor: string | undefined = undefined; | |
| 510 | − while (true) { | |
| 511 | − const namespaceDeletePage: { continueCursor: string | null } = await ctx.runMutation( | |
| 512 | − internal.toolRegistry.deleteToolRegistryNamespacesPage, | |
| 513 | − { | |
| 514 | − workspaceId: args.workspaceId, | |
| 515 | − buildId, | |
| 516 | − cursor: namespacePageCursor, | |
| 517 | − }, | |
| 518 | − ); | |
| 519 | − | |
| 520 | − if (namespaceDeletePage.continueCursor === null) { | |
| 521 | − break; | |
| 522 | − } | |
| 523 | − | |
| 524 | − namespacePageCursor = namespaceDeletePage.continueCursor; | |
| 525 | − } | |
| 526 | − } | |
| 443 | + return { continueCursor: page.isDone ? null : page.continueCursor }; | |
| 527 | 444 | }, |
| 528 | 445 | }); |
| 529 | 446 | |
| 530 | 447 | export const getToolByPath = internalQuery({ |
| 531 | 448 | args: { |
| 532 | 449 | workspaceId: vv.id("workspaces"), |
| 533 | − buildId: v.string(), | |
| 534 | 450 | path: v.string(), |
| 535 | 451 | }, |
| 536 | 452 | handler: async (ctx, args) => { |
| 537 | 453 | const entry = await ctx.db |
| 538 | 454 | .query("workspaceToolRegistry") |
| 539 | − .withIndex("by_workspace_build_path", (q) => | |
| 540 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", args.path), | |
| 455 | + .withIndex("by_workspace_path", (q) => | |
| 456 | + q.eq("workspaceId", args.workspaceId).eq("path", args.path), | |
| 541 | 457 | ) |
| 542 | 458 | .first(); |
| 543 | 459 | |
| @@ -545,8 +461,8 @@ export const getToolByPath = internalQuery({ · 1 unmodified line | ||
| 545 | 461 | |
| 546 | 462 | const payload = await ctx.db |
| 547 | 463 | .query("workspaceToolRegistryPayloads") |
| 548 | − .withIndex("by_workspace_build_path", (q) => | |
| 549 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", entry.path), | |
| 464 | + .withIndex("by_workspace_path", (q) => | |
| 465 | + q.eq("workspaceId", args.workspaceId).eq("path", entry.path), | |
| 550 | 466 | ) |
| 551 | 467 | .first(); |
| 552 | 468 | |
| @@ -571,7 +487,6 @@ export const getToolByPath = internalQuery({ · 18 unmodified lines | ||
| 571 | 487 | export const getSerializedToolsByPaths = internalQuery({ |
| 572 | 488 | args: { |
| 573 | 489 | workspaceId: vv.id("workspaces"), |
| 574 | − buildId: v.string(), | |
| 575 | 490 | paths: v.array(v.string()), |
| 576 | 491 | }, |
| 577 | 492 | handler: async (ctx, args) => { |
| @@ -584,8 +499,8 @@ export const getSerializedToolsByPaths = internalQuery({ · 6 unmodified lines | ||
| 584 | 499 | const payloads = await Promise.all(paths.map(async (path) => { |
| 585 | 500 | const payload = await ctx.db |
| 586 | 501 | .query("workspaceToolRegistryPayloads") |
| 587 | − .withIndex("by_workspace_build_path", (q) => | |
| 588 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", path), | |
| 502 | + .withIndex("by_workspace_path", (q) => | |
| 503 | + q.eq("workspaceId", args.workspaceId).eq("path", path), | |
| 589 | 504 | ) |
| 590 | 505 | .first(); |
| 591 | 506 | |
| @@ -603,7 +518,6 @@ export const getSerializedToolsByPaths = internalQuery({ · 11 unmodified lines | ||
| 603 | 518 | export const listToolsByNamespace = internalQuery({ |
| 604 | 519 | args: { |
| 605 | 520 | workspaceId: vv.id("workspaces"), |
| 606 | − buildId: v.string(), | |
| 607 | 521 | namespace: v.string(), |
| 608 | 522 | limit: v.number(), |
| 609 | 523 | }, |
| @@ -614,8 +528,8 @@ export const listToolsByNamespace = internalQuery({ · 4 unmodified lines | ||
| 614 | 528 | |
| 615 | 529 | const entries = await ctx.db |
| 616 | 530 | .query("workspaceToolRegistry") |
| 617 | − .withIndex("by_workspace_build_namespace", (q) => | |
| 618 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("namespace", namespace), | |
| 531 | + .withIndex("by_workspace_namespace", (q) => | |
| 532 | + q.eq("workspaceId", args.workspaceId).eq("namespace", namespace), | |
| 619 | 533 | ) |
| 620 | 534 | .take(limit); |
| 621 | 535 | |
| @@ -638,7 +552,6 @@ export const listToolsByNamespace = internalQuery({ · 16 unmodified lines | ||
| 638 | 552 | export const listToolsPage = internalQuery({ |
| 639 | 553 | args: { |
| 640 | 554 | workspaceId: vv.id("workspaces"), |
| 641 | − buildId: v.string(), | |
| 642 | 555 | cursor: v.optional(v.string()), |
| 643 | 556 | limit: v.number(), |
| 644 | 557 | }, |
| @@ -646,9 +559,7 @@ export const listToolsPage = internalQuery({ · 1 unmodified line | ||
| 646 | 559 | const limit = Math.max(1, Math.min(250, Math.floor(args.limit))); |
| 647 | 560 | const page = await ctx.db |
| 648 | 561 | .query("workspaceToolRegistry") |
| 649 | − .withIndex("by_workspace_build", (q) => | |
| 650 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId), | |
| 651 | − ) | |
| 562 | + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 652 | 563 | .paginate({ |
| 653 | 564 | numItems: limit, |
| 654 | 565 | cursor: args.cursor ?? null, |
| @@ -676,7 +587,6 @@ export const listToolsPage = internalQuery({ · 21 unmodified lines | ||
| 676 | 587 | export const listToolsBySourcePage = internalQuery({ |
| 677 | 588 | args: { |
| 678 | 589 | workspaceId: vv.id("workspaces"), |
| 679 | − buildId: v.string(), | |
| 680 | 590 | source: v.string(), |
| 681 | 591 | cursor: v.optional(v.string()), |
| 682 | 592 | limit: v.number(), |
| @@ -709,8 +619,8 @@ export const listToolsBySourcePage = internalQuery({ · 26 unmodified lines | ||
| 709 | 619 | const limit = Math.max(1, Math.min(250, Math.floor(args.limit))); |
| 710 | 620 | const page = await ctx.db |
| 711 | 621 | .query("workspaceToolRegistry") |
| 712 | − .withIndex("by_workspace_build_source", (q) => | |
| 713 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("source", source), | |
| 622 | + .withIndex("by_workspace_source", (q) => | |
| 623 | + q.eq("workspaceId", args.workspaceId).eq("source", source), | |
| 714 | 624 | ) |
| 715 | 625 | .paginate({ |
| 716 | 626 | numItems: limit, |
| @@ -739,7 +649,6 @@ export const listToolsBySourcePage = internalQuery({ · 22 unmodified lines | ||
| 739 | 649 | export const getToolsByNormalizedPath = internalQuery({ |
| 740 | 650 | args: { |
| 741 | 651 | workspaceId: vv.id("workspaces"), |
| 742 | − buildId: v.string(), | |
| 743 | 652 | normalizedPath: v.string(), |
| 744 | 653 | limit: v.number(), |
| 745 | 654 | }, |
| @@ -750,16 +659,16 @@ export const getToolsByNormalizedPath = internalQuery({ · 4 unmodified lines | ||
| 750 | 659 | |
| 751 | 660 | const entries = await ctx.db |
| 752 | 661 | .query("workspaceToolRegistry") |
| 753 | − .withIndex("by_workspace_build_normalized", (q) => | |
| 754 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("normalizedPath", normalized), | |
| 662 | + .withIndex("by_workspace_normalized", (q) => | |
| 663 | + q.eq("workspaceId", args.workspaceId).eq("normalizedPath", normalized), | |
| 755 | 664 | ) |
| 756 | 665 | .take(limit); |
| 757 | 666 | |
| 758 | 667 | const payloads = await Promise.all(entries.map(async (entry) => { |
| 759 | 668 | const payload = await ctx.db |
| 760 | 669 | .query("workspaceToolRegistryPayloads") |
| 761 | − .withIndex("by_workspace_build_path", (q) => | |
| 762 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", entry.path), | |
| 670 | + .withIndex("by_workspace_path", (q) => | |
| 671 | + q.eq("workspaceId", args.workspaceId).eq("path", entry.path), | |
| 763 | 672 | ) |
| 764 | 673 | .first(); |
| 765 | 674 | if (!payload) return null; |
| @@ -783,7 +692,6 @@ export const getToolsByNormalizedPath = internalQuery({ · 17 unmodified lines | ||
| 783 | 692 | export const searchTools = internalQuery({ |
| 784 | 693 | args: { |
| 785 | 694 | workspaceId: vv.id("workspaces"), |
| 786 | − buildId: v.string(), | |
| 787 | 695 | query: v.string(), |
| 788 | 696 | limit: v.number(), |
| 789 | 697 | }, |
| @@ -796,8 +704,7 @@ export const searchTools = internalQuery({ · 6 unmodified lines | ||
| 796 | 704 | .query("workspaceToolRegistry") |
| 797 | 705 | .withSearchIndex("search_text", (q) => |
| 798 | 706 | q.search("searchText", term) |
| 799 | − .eq("workspaceId", args.workspaceId) | |
| 800 | − .eq("buildId", args.buildId), | |
| 707 | + .eq("workspaceId", args.workspaceId), | |
| 801 | 708 | ) |
| 802 | 709 | .take(limit); |
| 803 | 710 | |
| @@ -820,16 +727,13 @@ export const searchTools = internalQuery({ · 16 unmodified lines | ||
| 820 | 727 | export const listNamespaces = internalQuery({ |
| 821 | 728 | args: { |
| 822 | 729 | workspaceId: vv.id("workspaces"), |
| 823 | − buildId: v.string(), | |
| 824 | 730 | limit: v.number(), |
| 825 | 731 | }, |
| 826 | 732 | handler: async (ctx, args) => { |
| 827 | 733 | const limit = Math.max(1, Math.min(200, Math.floor(args.limit))); |
| 828 | 734 | const entries = await ctx.db |
| 829 | 735 | .query("workspaceToolNamespaces") |
| 830 | − .withIndex("by_workspace_build", (q) => | |
| 831 | − q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId), | |
| 832 | − ) | |
| 736 | + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 833 | 737 | .take(limit); |
| 834 | 738 | |
| 835 | 739 | return entries.map((entry) => ({ |
| @@ -108,7 +108,7 @@ async function deleteWorkspaceDataBatch( · 107 unmodified lines | ||
| 108 | 108 | const remaining = maxDeletes - deleted; |
| 109 | 109 | const docs = await ctx.db |
| 110 | 110 | .query(table) |
| 111 | − .withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId)) | |
| 111 | + .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) | |
| 112 | 112 | .take(remaining); |
| 113 | 113 | if (docs.length === 0) { |
| 114 | 114 | return false; |
| @@ -149,9 +149,9 @@ async function deleteWorkspaceDataBatch( · 34 unmodified lines | ||
| 149 | 149 | ctx.db.query("toolSources").withIndex("by_workspace_updated", (q) => q.eq("workspaceId", workspaceId)).first(), |
| 150 | 150 | ctx.db.query("anonymousSessions").withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId)).first(), |
| 151 | 151 | ctx.db.query("storageInstances").withIndex("by_workspace_updated", (q) => q.eq("workspaceId", workspaceId)).first(), |
| 152 | − ctx.db.query("workspaceToolRegistry").withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId)).first(), | |
| 153 | − ctx.db.query("workspaceToolRegistryPayloads").withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId)).first(), | |
| 154 | − ctx.db.query("workspaceToolNamespaces").withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId)).first(), | |
| 152 | + ctx.db.query("workspaceToolRegistry").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(), | |
| 153 | + ctx.db.query("workspaceToolRegistryPayloads").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(), | |
| 154 | + ctx.db.query("workspaceToolNamespaces").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(), | |
| 155 | 155 | ctx.db.query("workspaceToolRegistryState").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(), |
| 156 | 156 | ]); |
| 157 | 157 | |
| @@ -7,6 +7,50 @@ import { claimAnonymousSessionToWorkosAccount } from "./account_links"; · 6 unmodified lines | ||
| 7 | 7 | import { getOrCreatePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace"; |
| 8 | 8 | import type { AccountId } from "./types"; |
| 9 | 9 | |
| 10 | +function readNonEmptyTrimmed(value: string | undefined): string | undefined { | |
| 11 | + const normalized = value?.trim(); | |
| 12 | + return normalized && normalized.length > 0 ? normalized : undefined; | |
| 13 | +} | |
| 14 | + | |
| 15 | +function isGeneratedWorkosLabel(value: string, workosUserId: string): boolean { | |
| 16 | + const normalizedValue = value.trim(); | |
| 17 | + if (!normalizedValue) { | |
| 18 | + return false; | |
| 19 | + } | |
| 20 | + | |
| 21 | + const fallbackSuffix = workosUserId.trim().slice(-6); | |
| 22 | + if (fallbackSuffix && normalizedValue.toLowerCase() === `user ${fallbackSuffix}`.toLowerCase()) { | |
| 23 | + return true; | |
| 24 | + } | |
| 25 | + | |
| 26 | + return /^user\s+[a-z0-9]{6,}$/i.test(normalizedValue); | |
| 27 | +} | |
| 28 | + | |
| 29 | +function withProfileNameHint( | |
| 30 | + identityProfile: ReturnType<typeof resolveIdentityProfile>, | |
| 31 | + workosUserId: string, | |
| 32 | + profileNameHint: string | undefined, | |
| 33 | +): ReturnType<typeof resolveIdentityProfile> { | |
| 34 | + const profileName = readNonEmptyTrimmed(profileNameHint); | |
| 35 | + if (!profileName) { | |
| 36 | + return identityProfile; | |
| 37 | + } | |
| 38 | + | |
| 39 | + if (!isGeneratedWorkosLabel(identityProfile.fullName, workosUserId)) { | |
| 40 | + return identityProfile; | |
| 41 | + } | |
| 42 | + | |
| 43 | + const [hintFirstName, ...hintRest] = profileName.split(/\s+/); | |
| 44 | + const hintLastName = hintRest.length > 0 ? hintRest.join(" ") : undefined; | |
| 45 | + | |
| 46 | + return { | |
| 47 | + ...identityProfile, | |
| 48 | + fullName: profileName, | |
| 49 | + firstName: identityProfile.firstName ?? hintFirstName, | |
| 50 | + lastName: identityProfile.lastName ?? hintLastName, | |
| 51 | + }; | |
| 52 | +} | |
| 53 | + | |
| 10 | 54 | async function seedHintedOrganizationMembership( |
| 11 | 55 | ctx: MutationCtx, |
| 12 | 56 | args: { |
| @@ -56,7 +100,7 @@ async function hasActiveWorkspaceAccess(ctx: MutationCtx, args: { accountId: Acc · 43 unmodified lines | ||
| 56 | 100 | |
| 57 | 101 | export async function bootstrapCurrentWorkosAccountImpl( |
| 58 | 102 | ctx: MutationCtx, |
| 59 | − args?: { sessionId?: string }, | |
| 103 | + args?: { sessionId?: string; profileName?: string }, | |
| 60 | 104 | ) { |
| 61 | 105 | const identity = await ctx.auth.getUserIdentity(); |
| 62 | 106 | if (!identity) return null; |
| @@ -64,18 +108,22 @@ export async function bootstrapCurrentWorkosAccountImpl( · 1 unmodified line | ||
| 64 | 108 | const now = Date.now(); |
| 65 | 109 | const subject = identity.subject; |
| 66 | 110 | const authKitProfile = await getAuthKitUserProfile(ctx, subject); |
| 67 | − const identityProfile = resolveIdentityProfile({ | |
| 68 | − identity: { ...identity, subject }, | |
| 69 | − authKitProfile, | |
| 70 | − }); | |
| 111 | + const resolvedIdentityProfile = withProfileNameHint( | |
| 112 | + resolveIdentityProfile({ | |
| 113 | + identity: { ...identity, subject }, | |
| 114 | + authKitProfile, | |
| 115 | + }), | |
| 116 | + subject, | |
| 117 | + args?.profileName, | |
| 118 | + ); | |
| 71 | 119 | |
| 72 | 120 | const account = await upsertWorkosAccount(ctx, { |
| 73 | 121 | workosUserId: subject, |
| 74 | − email: identityProfile.email, | |
| 75 | − fullName: identityProfile.fullName, | |
| 76 | − firstName: identityProfile.firstName, | |
| 77 | − lastName: identityProfile.lastName, | |
| 78 | − avatarUrl: identityProfile.avatarUrl, | |
| 122 | + email: resolvedIdentityProfile.email, | |
| 123 | + fullName: resolvedIdentityProfile.fullName, | |
| 124 | + firstName: resolvedIdentityProfile.firstName, | |
| 125 | + lastName: resolvedIdentityProfile.lastName, | |
| 126 | + avatarUrl: resolvedIdentityProfile.avatarUrl, | |
| 79 | 127 | now, |
| 80 | 128 | includeLastLoginAt: true, |
| 81 | 129 | }); |
| @@ -88,17 +136,17 @@ export async function bootstrapCurrentWorkosAccountImpl( · 6 unmodified lines | ||
| 88 | 136 | }); |
| 89 | 137 | |
| 90 | 138 | await refreshGeneratedPersonalWorkspaceNames(ctx, account._id, { |
| 91 | − email: identityProfile.email, | |
| 92 | − firstName: identityProfile.firstName, | |
| 93 | − fullName: identityProfile.fullName, | |
| 139 | + email: resolvedIdentityProfile.email, | |
| 140 | + firstName: resolvedIdentityProfile.firstName, | |
| 141 | + fullName: resolvedIdentityProfile.fullName, | |
| 94 | 142 | workosUserId: subject, |
| 95 | 143 | now, |
| 96 | 144 | }); |
| 97 | 145 | |
| 98 | 146 | await seedHintedOrganizationMembership(ctx, { |
| 99 | 147 | accountId: account._id, |
| 100 | − hintedWorkosOrgId: identityProfile.hintedWorkosOrgId, | |
| 101 | − email: identityProfile.email, | |
| 148 | + hintedWorkosOrgId: resolvedIdentityProfile.hintedWorkosOrgId, | |
| 149 | + email: resolvedIdentityProfile.email, | |
| 102 | 150 | now, |
| 103 | 151 | }); |
| 104 | 152 | |
| @@ -108,9 +156,9 @@ export async function bootstrapCurrentWorkosAccountImpl( · 3 unmodified lines | ||
| 108 | 156 | |
| 109 | 157 | if (!hasWorkspaceMembership) { |
| 110 | 158 | await getOrCreatePersonalWorkspace(ctx, account._id, { |
| 111 | − email: identityProfile.email, | |
| 112 | − firstName: identityProfile.firstName, | |
| 113 | − fullName: identityProfile.fullName, | |
| 159 | + email: resolvedIdentityProfile.email, | |
| 160 | + firstName: resolvedIdentityProfile.firstName, | |
| 161 | + fullName: resolvedIdentityProfile.fullName, | |
| 114 | 162 | workosUserId: subject, |
| 115 | 163 | now, |
| 116 | 164 | }); |
| @@ -6,7 +6,7 @@ import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation- · 5 unmodified lines | ||
| 6 | 6 | import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 7 | 7 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 8 | 8 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 9 | −import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; | |
| 9 | +import { getRegistryReadyResult } from "./tool_registry_state"; | |
| 10 | 10 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| 11 | 11 | import { baseTools } from "./base_tools"; |
| 12 | 12 | |
| @@ -43,7 +43,7 @@ function getGraphqlQueryFromInput(input: unknown): string { · 30 unmodified lines | ||
| 43 | 43 | |
| 44 | 44 | async function searchRegistryEntries( |
| 45 | 45 | ctx: ActionCtx, |
| 46 | − args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number }, | |
| 46 | + args: { workspaceId: TaskRecord["workspaceId"]; query: string; limit: number }, | |
| 47 | 47 | ): Promise<RegistrySearchEntry[]> { |
| 48 | 48 | const entries = await ctx.runQuery(internal.toolRegistry.searchTools, args); |
| 49 | 49 | const parsed = z.array(registrySearchEntrySchema).safeParse(entries); |
| @@ -52,7 +52,7 @@ async function searchRegistryEntries( · 2 unmodified lines | ||
| 52 | 52 | |
| 53 | 53 | async function getRegistryToolByPath( |
| 54 | 54 | ctx: ActionCtx, |
| 55 | − args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; path: string }, | |
| 55 | + args: { workspaceId: TaskRecord["workspaceId"]; path: string }, | |
| 56 | 56 | ): Promise<RegistrySerializedToolEntry | null> { |
| 57 | 57 | const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, args); |
| 58 | 58 | const parsed = registrySerializedToolEntrySchema.safeParse(entry); |
| @@ -63,7 +63,6 @@ async function getRegistryToolsByNormalizedPath( · 4 unmodified lines | ||
| 63 | 63 | ctx: ActionCtx, |
| 64 | 64 | args: { |
| 65 | 65 | workspaceId: TaskRecord["workspaceId"]; |
| 66 | − buildId: string; | |
| 67 | 66 | normalizedPath: string; |
| 68 | 67 | limit: number; |
| 69 | 68 | }, |
| @@ -139,13 +138,11 @@ export function getGraphqlDecision( · 69 unmodified lines | ||
| 139 | 138 | async function suggestFromRegistry( |
| 140 | 139 | ctx: ActionCtx, |
| 141 | 140 | workspaceId: TaskRecord["workspaceId"], |
| 142 | − buildId: string, | |
| 143 | 141 | toolPath: string, |
| 144 | 142 | ): Promise<string[]> { |
| 145 | 143 | const term = toolPath.split(".").filter(Boolean).join(" "); |
| 146 | 144 | const hits = await searchRegistryEntries(ctx, { |
| 147 | 145 | workspaceId, |
| 148 | − buildId, | |
| 149 | 146 | query: term, |
| 150 | 147 | limit: 3, |
| 151 | 148 | }); |
| @@ -185,20 +182,15 @@ export async function resolveToolForCall( · 33 unmodified lines | ||
| 185 | 182 | return Result.ok({ kind: "builtin", tool: builtin, resolvedToolPath: toolPath }); |
| 186 | 183 | } |
| 187 | 184 | |
| 188 | − const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { | |
| 185 | + const readyResult = await getRegistryReadyResult(ctx, { | |
| 189 | 186 | workspaceId: task.workspaceId, |
| 190 | − accountId: task.accountId, | |
| 191 | − clientId: task.clientId, | |
| 192 | 187 | }); |
| 193 | − if (buildIdResult.isErr()) { | |
| 194 | − return Result.err(buildIdResult.error); | |
| 188 | + if (readyResult.isErr()) { | |
| 189 | + return Result.err(readyResult.error); | |
| 195 | 190 | } |
| 196 | − const buildId = buildIdResult.value; | |
| 197 | − | |
| 198 | 191 | let resolvedToolPath = toolPath; |
| 199 | 192 | let entry = await getRegistryToolByPath(ctx, { |
| 200 | 193 | workspaceId: task.workspaceId, |
| 201 | − buildId, | |
| 202 | 194 | path: toolPath, |
| 203 | 195 | }); |
| 204 | 196 | |
| @@ -206,7 +198,6 @@ export async function resolveToolForCall( · 1 unmodified line | ||
| 206 | 198 | const normalized = normalizeToolPathForLookup(toolPath); |
| 207 | 199 | const hits = await getRegistryToolsByNormalizedPath(ctx, { |
| 208 | 200 | workspaceId: task.workspaceId, |
| 209 | − buildId, | |
| 210 | 201 | normalizedPath: normalized, |
| 211 | 202 | limit: 5, |
| 212 | 203 | }); |
| @@ -224,7 +215,7 @@ export async function resolveToolForCall( · 11 unmodified lines | ||
| 224 | 215 | } |
| 225 | 216 | |
| 226 | 217 | if (!entry) { |
| 227 | − const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath); | |
| 218 | + const suggestions = await suggestFromRegistry(ctx, task.workspaceId, toolPath); | |
| 228 | 219 | return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions))); |
| 229 | 220 | } |
| 230 | 221 | |
| @@ -27,7 +27,7 @@ import { publishTaskEvent } from "./events"; · 26 unmodified lines | ||
| 27 | 27 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; |
| 28 | 28 | import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials"; |
| 29 | 29 | import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; |
| 30 | −import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; | |
| 30 | +import { getRegistryReadyResult } from "./tool_registry_state"; | |
| 31 | 31 | import { |
| 32 | 32 | catalogNamespaceSchema, |
| 33 | 33 | catalogNamespacesInputSchema, |
| @@ -214,7 +214,7 @@ async function listWorkspaceToolPolicies( · 180 unmodified lines | ||
| 214 | 214 | |
| 215 | 215 | async function listRegistryNamespaces( |
| 216 | 216 | ctx: ActionCtx, |
| 217 | − args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; limit: number }, | |
| 217 | + args: { workspaceId: TaskRecord["workspaceId"]; limit: number }, | |
| 218 | 218 | ): Promise<CatalogNamespace[]> { |
| 219 | 219 | const namespaces = await ctx.runQuery(internal.toolRegistry.listNamespaces, args); |
| 220 | 220 | const parsed = z.array(catalogNamespaceSchema).safeParse(namespaces); |
| @@ -272,7 +272,6 @@ async function searchRegistryTools( · 51 unmodified lines | ||
| 272 | 272 | ctx: ActionCtx, |
| 273 | 273 | args: { |
| 274 | 274 | workspaceId: TaskRecord["workspaceId"]; |
| 275 | − buildId: string; | |
| 276 | 275 | query: string; |
| 277 | 276 | limit: number; |
| 278 | 277 | includeSchemas?: boolean; |
| @@ -280,7 +279,6 @@ async function searchRegistryTools( · 1 unmodified line | ||
| 280 | 279 | ): Promise<RegistryToolEntry[]> { |
| 281 | 280 | const entries = await ctx.runQuery(internal.toolRegistry.searchTools, { |
| 282 | 281 | workspaceId: args.workspaceId, |
| 283 | − buildId: args.buildId, | |
| 284 | 282 | query: args.query, |
| 285 | 283 | limit: args.limit, |
| 286 | 284 | }); |
| @@ -290,7 +288,6 @@ async function searchRegistryTools( · 3 unmodified lines | ||
| 290 | 288 | |
| 291 | 289 | const payloads = await ctx.runQuery(internal.toolRegistry.getSerializedToolsByPaths, { |
| 292 | 290 | workspaceId: args.workspaceId, |
| 293 | − buildId: args.buildId, | |
| 294 | 291 | paths: parsed.data.map((entry) => entry.path), |
| 295 | 292 | }); |
| 296 | 293 | const parsedPayloads = z.array(registryToolPayloadEntrySchema).safeParse(payloads); |
| @@ -310,7 +307,6 @@ async function listRegistryToolsByNamespace( · 13 unmodified lines | ||
| 310 | 307 | ctx: ActionCtx, |
| 311 | 308 | args: { |
| 312 | 309 | workspaceId: TaskRecord["workspaceId"]; |
| 313 | − buildId: string; | |
| 314 | 310 | namespace: string; |
| 315 | 311 | limit: number; |
| 316 | 312 | includeSchemas?: boolean; |
| @@ -318,7 +314,6 @@ async function listRegistryToolsByNamespace( · 1 unmodified line | ||
| 318 | 314 | ): Promise<RegistryToolEntry[]> { |
| 319 | 315 | const entries = await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, { |
| 320 | 316 | workspaceId: args.workspaceId, |
| 321 | − buildId: args.buildId, | |
| 322 | 317 | namespace: args.namespace, |
| 323 | 318 | limit: args.limit, |
| 324 | 319 | }); |
| @@ -328,7 +323,6 @@ async function listRegistryToolsByNamespace( · 3 unmodified lines | ||
| 328 | 323 | |
| 329 | 324 | const payloads = await ctx.runQuery(internal.toolRegistry.getSerializedToolsByPaths, { |
| 330 | 325 | workspaceId: args.workspaceId, |
| 331 | − buildId: args.buildId, | |
| 332 | 326 | paths: parsed.data.map((entry) => entry.path), |
| 333 | 327 | }); |
| 334 | 328 | const parsedPayloads = z.array(registryToolPayloadEntrySchema).safeParse(payloads); |
| @@ -539,13 +533,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 204 unmodified lines | ||
| 539 | 533 | return output; |
| 540 | 534 | } |
| 541 | 535 | |
| 542 | − const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { | |
| 536 | + const readyResult = await getRegistryReadyResult(ctx, { | |
| 543 | 537 | workspaceId: task.workspaceId, |
| 544 | − accountId: task.accountId, | |
| 545 | − clientId: task.clientId, | |
| 546 | 538 | }); |
| 547 | − const buildId = buildIdResult.isErr() ? undefined : buildIdResult.value; | |
| 548 | − const state = buildId | |
| 539 | + const registryReady = readyResult.isOk(); | |
| 540 | + const state = registryReady | |
| 549 | 541 | ? await ctx.runQuery(internal.toolRegistry.getState, { |
| 550 | 542 | workspaceId: task.workspaceId, |
| 551 | 543 | }) as unknown as { openApiRefHintTables?: unknown } | null |
| @@ -579,10 +571,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 27 unmodified lines | ||
| 579 | 571 | const parsedInput = catalogNamespacesInputSchema.safeParse(payload); |
| 580 | 572 | const limitInput = parsedInput.success ? parsedInput.data.limit : undefined; |
| 581 | 573 | const limit = Math.max(1, Math.min(200, Number(limitInput ?? 200))); |
| 582 | − const registryNamespaces = buildId | |
| 574 | + const registryNamespaces = registryReady | |
| 583 | 575 | ? await listRegistryNamespaces(ctx, { |
| 584 | 576 | workspaceId: task.workspaceId, |
| 585 | − buildId, | |
| 586 | 577 | limit: 200, |
| 587 | 578 | }) |
| 588 | 579 | : []; |
| @@ -633,12 +624,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 44 unmodified lines | ||
| 633 | 624 | const limit = Math.max(1, Math.min(200, Number(limitInput ?? 50))); |
| 634 | 625 | const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false; |
| 635 | 626 | |
| 636 | − const raw = !buildId | |
| 627 | + const raw = !registryReady | |
| 637 | 628 | ? [] |
| 638 | 629 | : query |
| 639 | 630 | ? await searchRegistryTools(ctx, { |
| 640 | 631 | workspaceId: task.workspaceId, |
| 641 | − buildId, | |
| 642 | 632 | query, |
| 643 | 633 | limit, |
| 644 | 634 | includeSchemas, |
| @@ -646,7 +636,6 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 1 unmodified line | ||
| 646 | 636 | : namespace |
| 647 | 637 | ? await listRegistryToolsByNamespace(ctx, { |
| 648 | 638 | workspaceId: task.workspaceId, |
| 649 | − buildId, | |
| 650 | 639 | namespace, |
| 651 | 640 | limit, |
| 652 | 641 | includeSchemas, |
| @@ -739,10 +728,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 86 unmodified lines | ||
| 739 | 728 | const limit = Math.max(1, Math.min(50, Number(limitInput ?? 8))); |
| 740 | 729 | const compact = parsedInput.success ? (parsedInput.data.compact ?? true) : true; |
| 741 | 730 | const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false; |
| 742 | − const hits = buildId | |
| 731 | + const hits = registryReady | |
| 743 | 732 | ? await searchRegistryTools(ctx, { |
| 744 | 733 | workspaceId: task.workspaceId, |
| 745 | − buildId, | |
| 746 | 734 | query, |
| 747 | 735 | limit: Math.max(limit * 2, limit), |
| 748 | 736 | includeSchemas, |
| @@ -17,8 +17,17 @@ export function registrySignatureForWorkspace( · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | const registryStateSchema = z.object({ |
| 19 | 19 | signature: z.string().optional(), |
| 20 | − readyBuildId: z.string().optional(), | |
| 21 | − buildingBuildId: z.string().optional(), | |
| 20 | + sourceStates: z.array(z.object({ | |
| 21 | + sourceId: z.string(), | |
| 22 | + signature: z.string(), | |
| 23 | + state: z.union([ | |
| 24 | + z.literal("queued"), | |
| 25 | + z.literal("loading"), | |
| 26 | + z.literal("indexing"), | |
| 27 | + z.literal("ready"), | |
| 28 | + z.literal("failed"), | |
| 29 | + ]), | |
| 30 | + })).optional(), | |
| 22 | 31 | }); |
| 23 | 32 | |
| 24 | 33 | type RegistryState = z.infer<typeof registryStateSchema> | null; |
| @@ -70,7 +79,7 @@ function toToolSourceStateList(value: unknown): ToolSourceState[] { · 45 unmodified lines | ||
| 70 | 79 | async function readRegistryState( |
| 71 | 80 | ctx: Pick<ActionCtx, "runQuery">, |
| 72 | 81 | workspaceId: Id<"workspaces">, |
| 73 | −): Promise<{ buildId?: string; isReady: boolean }> { | |
| 82 | +): Promise<{ isReady: boolean }> { | |
| 74 | 83 | const [rawState, rawSources] = await Promise.all([ |
| 75 | 84 | ctx.runQuery(internal.toolRegistry.getState, { workspaceId }), |
| 76 | 85 | ctx.runQuery(internal.database.listToolSources, { workspaceId }), |
| @@ -79,44 +88,44 @@ async function readRegistryState( · 2 unmodified lines | ||
| 79 | 88 | const sources = toToolSourceStateList(rawSources); |
| 80 | 89 | |
| 81 | 90 | const expectedSignature = registrySignatureForWorkspace(workspaceId, sources); |
| 82 | − const buildId = state?.readyBuildId; | |
| 91 | + const sourceStateById = new Map((state?.sourceStates ?? []).map((item) => [item.sourceId, item])); | |
| 92 | + const hasPendingOrMismatchedSource = sources.some((source) => { | |
| 93 | + if (source.enabled === false) { | |
| 94 | + return false; | |
| 95 | + } | |
| 96 | + | |
| 97 | + const sourceState = sourceStateById.get(source.id); | |
| 98 | + const expectedSourceSignature = registrySignatureForWorkspace(workspaceId, [{ | |
| 99 | + id: source.id, | |
| 100 | + updatedAt: source.updatedAt, | |
| 101 | + enabled: source.enabled, | |
| 102 | + }]); | |
| 103 | + if (!sourceState) { | |
| 104 | + return true; | |
| 105 | + } | |
| 106 | + if (sourceState.signature !== expectedSourceSignature) { | |
| 107 | + return true; | |
| 108 | + } | |
| 109 | + return sourceState.state !== "ready"; | |
| 110 | + }); | |
| 83 | 111 | |
| 84 | 112 | return { |
| 85 | − buildId, | |
| 86 | − isReady: Boolean(buildId && !state?.buildingBuildId && state?.signature === expectedSignature), | |
| 113 | + isReady: Boolean(state?.signature === expectedSignature && !hasPendingOrMismatchedSource), | |
| 87 | 114 | }; |
| 88 | 115 | } |
| 89 | 116 | |
| 90 | −export async function getReadyRegistryBuildIdResult( | |
| 91 | − ctx: Pick<ActionCtx, "runQuery" | "runAction">, | |
| 117 | +export async function getRegistryReadyResult( | |
| 118 | + ctx: Pick<ActionCtx, "runQuery">, | |
| 92 | 119 | args: { |
| 93 | 120 | workspaceId: Id<"workspaces">; |
| 94 | − accountId?: Id<"accounts">; | |
| 95 | − clientId?: string; | |
| 96 | 121 | }, |
| 97 | −): Promise<Result<string, Error>> { | |
| 122 | +): Promise<Result<void, Error>> { | |
| 98 | 123 | const initial = await readRegistryState(ctx, args.workspaceId); |
| 99 | − if (initial.isReady && initial.buildId) { | |
| 100 | − return Result.ok(initial.buildId); | |
| 124 | + if (initial.isReady) { | |
| 125 | + return Result.ok(undefined); | |
| 101 | 126 | } |
| 102 | 127 | |
| 103 | 128 | return Result.err( |
| 104 | − new Error("Tool registry is not ready (or is stale). Rebuild after changing sources or credentials."), | |
| 129 | + new Error("Tool registry is not ready yet. Rebuild after changing sources or credentials."), | |
| 105 | 130 | ); |
| 106 | 131 | } |
| 107 | − | |
| 108 | −export async function getReadyRegistryBuildId( | |
| 109 | − ctx: Pick<ActionCtx, "runQuery" | "runAction">, | |
| 110 | − args: { | |
| 111 | − workspaceId: Id<"workspaces">; | |
| 112 | − accountId?: Id<"accounts">; | |
| 113 | − clientId?: string; | |
| 114 | − }, | |
| 115 | −): Promise<string> { | |
| 116 | − const buildIdResult = await getReadyRegistryBuildIdResult(ctx, args); | |
| 117 | − if (buildIdResult.isErr()) { | |
| 118 | − throw buildIdResult.error; | |
| 119 | − } | |
| 120 | − | |
| 121 | − return buildIdResult.value; | |
| 122 | −} | |
| @@ -28,8 +28,10 @@ import { · 27 unmodified lines | ||
| 28 | 28 | } from "./tool_source_artifact"; |
| 29 | 29 | |
| 30 | 30 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 31 | +const OPENAPI_PREPARE_MAX_ATTEMPTS = 3; | |
| 32 | +const OPENAPI_PREPARE_RETRY_BASE_DELAY_MS = 1_500; | |
| 31 | 33 | |
| 32 | −const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v8"; | |
| 34 | +const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v9"; | |
| 33 | 35 | |
| 34 | 36 | const openApiAuthModeSchema = z.enum(["account", "workspace", "organization"]); |
| 35 | 37 | |
| @@ -329,11 +331,34 @@ async function loadCachedOpenApiSpec( · 293 unmodified lines | ||
| 329 | 331 | console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`); |
| 330 | 332 | } |
| 331 | 333 | |
| 332 | − const preparedResponse = await ctx.runAction(internal.runtimeNode.prepareOpenApiSpec, { | |
| 333 | − specUrl, | |
| 334 | − sourceName, | |
| 335 | − includeDts, | |
| 336 | − }); | |
| 334 | + let preparedResponse: unknown = undefined; | |
| 335 | + let lastPrepareError: string | undefined; | |
| 336 | + for (let attempt = 1; attempt <= OPENAPI_PREPARE_MAX_ATTEMPTS; attempt += 1) { | |
| 337 | + try { | |
| 338 | + preparedResponse = await ctx.runAction(internal.runtimeNode.prepareOpenApiSpec, { | |
| 339 | + specUrl, | |
| 340 | + sourceName, | |
| 341 | + includeDts, | |
| 342 | + }); | |
| 343 | + lastPrepareError = undefined; | |
| 344 | + break; | |
| 345 | + } catch (error) { | |
| 346 | + const message = error instanceof Error ? error.message : String(error); | |
| 347 | + lastPrepareError = message; | |
| 348 | + if (attempt >= OPENAPI_PREPARE_MAX_ATTEMPTS) { | |
| 349 | + break; | |
| 350 | + } | |
| 351 | + | |
| 352 | + const delayMs = OPENAPI_PREPARE_RETRY_BASE_DELAY_MS * attempt; | |
| 353 | + await new Promise((resolve) => setTimeout(resolve, delayMs)); | |
| 354 | + } | |
| 355 | + } | |
| 356 | + | |
| 357 | + if (lastPrepareError) { | |
| 358 | + throw new Error( | |
| 359 | + `Failed to prepare OpenAPI source '${sourceName}' after ${OPENAPI_PREPARE_MAX_ATTEMPTS} attempts: ${lastPrepareError}`, | |
| 360 | + ); | |
| 361 | + } | |
| 337 | 362 | |
| 338 | 363 | let preparedPayload: unknown; |
| 339 | 364 | let preparedStorageId: Id<"_storage"> | undefined; |
| @@ -37,7 +37,6 @@ function clampPreview(items: string[]): { items: string[]; truncated: boolean } · 36 unmodified lines | ||
| 37 | 37 | async function listCurrentSourceToolMap( |
| 38 | 38 | ctx: ActionCtx, |
| 39 | 39 | workspaceId: Id<"workspaces">, |
| 40 | − buildId: string, | |
| 41 | 40 | sourceLabel: string, |
| 42 | 41 | ): Promise<Map<string, string>> { |
| 43 | 42 | const tools = new Map<string, string>(); |
| @@ -49,7 +48,6 @@ async function listCurrentSourceToolMap( · 5 unmodified lines | ||
| 49 | 48 | items: Array<{ path: string }>; |
| 50 | 49 | } = await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { |
| 51 | 50 | workspaceId, |
| 52 | − buildId, | |
| 53 | 51 | source: sourceLabel, |
| 54 | 52 | cursor, |
| 55 | 53 | limit: 500, |
| @@ -59,7 +57,6 @@ async function listCurrentSourceToolMap( · 3 unmodified lines | ||
| 59 | 57 | internal.toolRegistry.getSerializedToolsByPaths, |
| 60 | 58 | { |
| 61 | 59 | workspaceId, |
| 62 | − buildId, | |
| 63 | 60 | paths: page.items.map((item) => item.path), |
| 64 | 61 | }, |
| 65 | 62 | ); |
| @@ -134,12 +131,11 @@ export async function previewOpenApiSourceUpgradeForContext( · 68 unmodified lines | ||
| 134 | 131 | proposedByPath.set(tool.path, JSON.stringify(tool)); |
| 135 | 132 | } |
| 136 | 133 | |
| 137 | − const state = await ctx.runQuery(internal.toolRegistry.getState, { | |
| 138 | − workspaceId: context.workspaceId, | |
| 139 | − }); | |
| 140 | − const currentByPath = state?.readyBuildId | |
| 141 | − ? await listCurrentSourceToolMap(ctx, context.workspaceId, state.readyBuildId, `openapi:${existing.name}`) | |
| 142 | − : new Map<string, string>(); | |
| 134 | + const currentByPath = await listCurrentSourceToolMap( | |
| 135 | + ctx, | |
| 136 | + context.workspaceId, | |
| 137 | + `openapi:${existing.name}`, | |
| 138 | + ); | |
| 143 | 139 | |
| 144 | 140 | const added: string[] = []; |
| 145 | 141 | const removed: string[] = []; |
| @@ -12,10 +12,9 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "../../../core/src/type-hints"; |
| 13 | 13 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils"; |
| 14 | 14 | import { |
| 15 | − materializeCompiledToolSource, | |
| 16 | 15 | type CompiledToolSourceArtifact, |
| 17 | 16 | } from "./tool_source_artifact"; |
| 18 | −import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; | |
| 17 | +import { parseSerializedTool, rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization"; | |
| 19 | 18 | import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types"; |
| 20 | 19 | import type { |
| 21 | 20 | ToolPolicyRecord, |
| @@ -75,35 +74,17 @@ async function listWorkspaceToolPolicies( · 53 unmodified lines | ||
| 75 | 74 | return policies; |
| 76 | 75 | } |
| 77 | 76 | |
| 78 | −interface WorkspaceToolsResult { | |
| 79 | − tools: Map<string, ToolDefinition>; | |
| 80 | − warnings: string[]; | |
| 81 | − typesStorageId?: Id<"_storage">; | |
| 82 | − timedOutSources: string[]; | |
| 83 | −} | |
| 84 | − | |
| 85 | −export type ToolInventoryState = "initializing" | "ready" | "rebuilding" | "stale" | "failed"; | |
| 77 | +export type ToolInventoryState = "initializing" | "ready" | "rebuilding" | "failed"; | |
| 86 | 78 | |
| 87 | 79 | export interface ToolInventoryStatus { |
| 88 | 80 | state: ToolInventoryState; |
| 89 | − readyBuildId?: string; | |
| 90 | − buildingBuildId?: string; | |
| 91 | 81 | readyToolCount: number; |
| 92 | 82 | loadingSourceNames: string[]; |
| 93 | 83 | sourceToolCounts: Record<string, number>; |
| 94 | − lastBuildStartedAt?: number; | |
| 95 | − lastBuildCompletedAt?: number; | |
| 96 | − lastBuildFailedAt?: number; | |
| 97 | 84 | error?: string; |
| 98 | 85 | updatedAt?: number; |
| 99 | 86 | } |
| 100 | 87 | |
| 101 | −interface GetWorkspaceToolsOptions { | |
| 102 | − sourceTimeoutMs?: number; | |
| 103 | − accountId?: Id<"accounts">; | |
| 104 | − includeDts?: boolean; | |
| 105 | −} | |
| 106 | − | |
| 107 | 88 | interface WorkspaceToolInventory { |
| 108 | 89 | tools: ToolDescriptor[]; |
| 109 | 90 | warnings: string[]; |
| @@ -119,10 +100,10 @@ export type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | · 9 unmodified lines | ||
| 119 | 100 | |
| 120 | 101 | const MAX_TOOLS_IN_ACTION_RESULT = 8_000; |
| 121 | 102 | const MAX_TOOL_DETAILS_LOOKUP_PATHS = 100; |
| 122 | −const REGISTRY_BUILD_STALE_MS = 2 * 60_000; | |
| 123 | 103 | const MAX_REF_HINTS_PER_SOURCE = 300; |
| 124 | 104 | const MAX_REF_HINT_HINT_CHARS = 240; |
| 125 | 105 | const MAX_REF_HINT_METADATA_BYTES = 220_000; |
| 106 | +const SOURCE_LOAD_HEARTBEAT_MS = 5_000; | |
| 126 | 107 | |
| 127 | 108 | function truncateToolsForActionResult( |
| 128 | 109 | tools: ToolDescriptor[], |
| @@ -355,20 +336,20 @@ function toDescriptorFromRegistryEntry( · 226 unmodified lines | ||
| 355 | 336 | source: entry.source, |
| 356 | 337 | ...(includeDetails |
| 357 | 338 | ? { |
| 358 | − typing: { | |
| 359 | − requiredInputKeys: entry.requiredInputKeys, | |
| 360 | − previewInputKeys: entry.previewInputKeys, | |
| 361 | − ...(inputSchemaJson ? { inputSchemaJson } : {}), | |
| 362 | − ...(outputSchemaJson ? { outputSchemaJson } : {}), | |
| 363 | − ...(refHintResolution.refHintKeys.length > 0 ? { refHintKeys: refHintResolution.refHintKeys } : {}), | |
| 364 | − ...(Object.keys(refHintResolution.refHints).length > 0 ? { refHints: refHintResolution.refHints } : {}), | |
| 365 | − typedRef: entry.typedRef, | |
| 366 | − }, | |
| 367 | − display: { | |
| 368 | − input: resolvedDisplayInput, | |
| 369 | − output: resolvedDisplayOutput, | |
| 370 | − }, | |
| 371 | − } | |
| 339 | + typing: { | |
| 340 | + requiredInputKeys: entry.requiredInputKeys, | |
| 341 | + previewInputKeys: entry.previewInputKeys, | |
| 342 | + ...(inputSchemaJson ? { inputSchemaJson } : {}), | |
| 343 | + ...(outputSchemaJson ? { outputSchemaJson } : {}), | |
| 344 | + ...(refHintResolution.refHintKeys.length > 0 ? { refHintKeys: refHintResolution.refHintKeys } : {}), | |
| 345 | + ...(Object.keys(refHintResolution.refHints).length > 0 ? { refHints: refHintResolution.refHints } : {}), | |
| 346 | + typedRef: entry.typedRef, | |
| 347 | + }, | |
| 348 | + display: { | |
| 349 | + input: resolvedDisplayInput, | |
| 350 | + output: resolvedDisplayOutput, | |
| 351 | + }, | |
| 352 | + } | |
| 372 | 353 | : {}), |
| 373 | 354 | }; |
| 374 | 355 | } |
| @@ -626,299 +607,625 @@ function getPathAliases(path: string): string[] { · 251 unmodified lines | ||
| 626 | 607 | return [...aliases].slice(0, 4); |
| 627 | 608 | } |
| 628 | 609 | |
| 610 | +type RegistryWriteEntry = { | |
| 611 | + path: string; | |
| 612 | + preferredPath: string; | |
| 613 | + namespace: string; | |
| 614 | + normalizedPath: string; | |
| 615 | + aliases: string[]; | |
| 616 | + description: string; | |
| 617 | + approval: "auto" | "required"; | |
| 618 | + source?: string; | |
| 619 | + searchText: string; | |
| 620 | + displayInput?: string; | |
| 621 | + displayOutput?: string; | |
| 622 | + requiredInputKeys?: string[]; | |
| 623 | + previewInputKeys?: string[]; | |
| 624 | + typedRef?: { | |
| 625 | + kind: "openapi_operation"; | |
| 626 | + sourceKey: string; | |
| 627 | + operationId: string; | |
| 628 | + }; | |
| 629 | + serializedToolJson: string; | |
| 630 | +}; | |
| 631 | + | |
| 632 | +type MaterializedSourceStateRecord = { | |
| 633 | + sourceId: string; | |
| 634 | + sourceName: string; | |
| 635 | + sourceKey: string; | |
| 636 | + signature: string; | |
| 637 | + state: "queued" | "loading" | "indexing" | "ready" | "failed"; | |
| 638 | + toolCount: number; | |
| 639 | + processedTools?: number; | |
| 640 | + message?: string; | |
| 641 | + error?: string; | |
| 642 | + startedAt?: number; | |
| 643 | + completedAt?: number; | |
| 644 | + updatedAt: number; | |
| 645 | + sourceQuality?: OpenApiSourceQuality; | |
| 646 | + openApiRefHints?: Array<{ key: string; hint: string }>; | |
| 647 | +}; | |
| 648 | + | |
| 649 | +function sourceStateSignature(workspaceId: Id<"workspaces">, source: ToolSourceRecord): string { | |
| 650 | + return registrySignatureForWorkspace(workspaceId, [{ | |
| 651 | + id: source.id, | |
| 652 | + updatedAt: source.updatedAt, | |
| 653 | + enabled: source.enabled, | |
| 654 | + }]); | |
| 655 | +} | |
| 629 | 656 | |
| 630 | −async function buildWorkspaceToolRegistry( | |
| 631 | − ctx: ActionCtx, | |
| 632 | − args: { | |
| 633 | − workspaceId: Id<"workspaces">; | |
| 634 | − registrySignature: string; | |
| 635 | − serializedTools: SerializedTool[]; | |
| 636 | − }, | |
| 637 | −): Promise<{ buildId: string }> { | |
| 638 | − const buildId = `toolreg_${crypto.randomUUID()}`; | |
| 639 | − await ctx.runMutation(internal.toolRegistry.beginBuild, { | |
| 640 | − workspaceId: args.workspaceId, | |
| 641 | − signature: args.registrySignature, | |
| 642 | − buildId, | |
| 643 | − }); | |
| 657 | +function sourceStateSourceKey(source: Pick<ToolSourceRecord, "type" | "name">): string { | |
| 658 | + return `${source.type}:${source.name}`; | |
| 659 | +} | |
| 644 | 660 | |
| 645 | − try { | |
| 646 | − const entries = args.serializedTools.map((st) => { | |
| 647 | − if (st.path === "discover" || st.path.startsWith("catalog.")) { | |
| 648 | − return null; | |
| 661 | +function sourceLoadingMessage(source: ExternalToolSourceConfig, elapsedSeconds: number): string { | |
| 662 | + if (source.type === "openapi") { | |
| 663 | + let hostSuffix = ""; | |
| 664 | + if (typeof source.spec === "string") { | |
| 665 | + try { | |
| 666 | + const host = new URL(source.spec).host; | |
| 667 | + if (host) { | |
| 668 | + hostSuffix = ` from ${host}`; | |
| 669 | + } | |
| 670 | + } catch { | |
| 671 | + hostSuffix = ""; | |
| 649 | 672 | } |
| 650 | − const preferredPath = preferredToolPath(st.path); | |
| 651 | − const aliases = getPathAliases(st.path); | |
| 652 | − const namespace = (preferredPath.split(".")[0] ?? "default").toLowerCase(); | |
| 653 | − const normalizedPath = normalizeToolPathForLookup(st.path); | |
| 654 | − const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase(); | |
| 655 | − | |
| 656 | − const inputSchema = toJsonSchema(st.typing?.inputSchema); | |
| 657 | − const outputSchema = toJsonSchema(st.typing?.outputSchema); | |
| 658 | − const parsedTyping = toolHintSchema.safeParse(st.typing); | |
| 659 | − const typing = parsedTyping.success ? parsedTyping.data : {}; | |
| 660 | − | |
| 661 | − const requiredInputKeys = typing.requiredInputKeys ?? extractTopLevelRequiredKeys(inputSchema); | |
| 662 | − const previewInputKeys = typing.previewInputKeys ?? buildPreviewKeys(inputSchema); | |
| 663 | − const inputHint = typing.inputHint?.trim(); | |
| 664 | − const outputHint = typing.outputHint?.trim(); | |
| 665 | − const hasInputSchema = Object.keys(inputSchema).length > 0; | |
| 666 | − const hasOutputSchema = Object.keys(outputSchema).length > 0; | |
| 667 | − const useInputHint = Boolean(inputHint && (!isLossyTypeHint(inputHint) || !hasInputSchema)); | |
| 668 | − const useOutputHint = Boolean(outputHint && (!isLossyTypeHint(outputHint) || !hasOutputSchema)); | |
| 673 | + } | |
| 669 | 674 | |
| 670 | − const displayInput = useInputHint && inputHint | |
| 671 | − ? displayArgTypeHint(inputHint) | |
| 672 | − : compactArgTypeHintFromSchema(inputSchema); | |
| 675 | + if (elapsedSeconds < 10) { | |
| 676 | + return `Downloading OpenAPI document${hostSuffix}`; | |
| 677 | + } | |
| 678 | + if (elapsedSeconds < 40) { | |
| 679 | + return `Parsing and resolving $ref links${hostSuffix}`; | |
| 680 | + } | |
| 681 | + return `Resolving a large OpenAPI document${hostSuffix} (can take 1-2m)`; | |
| 682 | + } | |
| 673 | 683 | |
| 674 | − const displayOutput = useOutputHint && outputHint | |
| 675 | − ? displayReturnTypeHint(outputHint) | |
| 676 | − : compactReturnTypeHintFromSchema(outputSchema); | |
| 684 | + if (source.type === "graphql") { | |
| 685 | + return elapsedSeconds < 12 | |
| 686 | + ? "Loading GraphQL schema" | |
| 687 | + : "Building GraphQL operations"; | |
| 688 | + } | |
| 677 | 689 | |
| 678 | − const typedRef = st.typing?.typedRef && st.typing.typedRef.kind === "openapi_operation" | |
| 679 | − ? { | |
| 680 | − kind: "openapi_operation" as const, | |
| 681 | − sourceKey: st.typing.typedRef.sourceKey, | |
| 682 | − operationId: st.typing.typedRef.operationId, | |
| 683 | − } | |
| 684 | − : undefined; | |
| 690 | + return elapsedSeconds < 12 | |
| 691 | + ? "Connecting to MCP source" | |
| 692 | + : "Discovering MCP tools"; | |
| 693 | +} | |
| 685 | 694 | |
| 686 | − return { | |
| 687 | − path: st.path, | |
| 688 | − preferredPath, | |
| 689 | − namespace, | |
| 690 | − normalizedPath, | |
| 691 | − aliases, | |
| 692 | − description: st.description, | |
| 693 | − approval: st.approval, | |
| 694 | − source: st.source, | |
| 695 | − searchText, | |
| 696 | − displayInput, | |
| 697 | − displayOutput, | |
| 698 | − requiredInputKeys, | |
| 699 | − previewInputKeys, | |
| 700 | − typedRef, | |
| 701 | − serializedToolJson: JSON.stringify(st), | |
| 702 | − }; | |
| 703 | − }); | |
| 695 | +function sleep(ms: number): Promise<void> { | |
| 696 | + return new Promise((resolve) => { | |
| 697 | + setTimeout(resolve, ms); | |
| 698 | + }); | |
| 699 | +} | |
| 704 | 700 | |
| 705 | − const filteredEntries = entries.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)); | |
| 701 | +function registryWriteEntriesFromSerializedTools(serializedTools: SerializedTool[]): RegistryWriteEntry[] { | |
| 702 | + const entries: RegistryWriteEntry[] = []; | |
| 706 | 703 | |
| 707 | − const namespaceMap = new Map<string, { toolCount: number; samplePaths: string[] }>(); | |
| 708 | − for (const entry of filteredEntries) { | |
| 709 | − const current = namespaceMap.get(entry.namespace) ?? { toolCount: 0, samplePaths: [] }; | |
| 710 | − current.toolCount += 1; | |
| 711 | − if (current.samplePaths.length < 6) { | |
| 712 | − current.samplePaths.push(entry.preferredPath); | |
| 704 | + for (const st of serializedTools) { | |
| 705 | + if (st.path === "discover" || st.path.startsWith("catalog.")) { | |
| 706 | + continue; | |
| 707 | + } | |
| 708 | + const preferredPath = preferredToolPath(st.path); | |
| 709 | + const aliases = getPathAliases(st.path); | |
| 710 | + const namespace = (preferredPath.split(".")[0] ?? "default").toLowerCase(); | |
| 711 | + const normalizedPath = normalizeToolPathForLookup(st.path); | |
| 712 | + const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase(); | |
| 713 | + | |
| 714 | + const inputSchema = toJsonSchema(st.typing?.inputSchema); | |
| 715 | + const outputSchema = toJsonSchema(st.typing?.outputSchema); | |
| 716 | + const parsedTyping = toolHintSchema.safeParse(st.typing); | |
| 717 | + const typing = parsedTyping.success ? parsedTyping.data : {}; | |
| 718 | + | |
| 719 | + const requiredInputKeys = typing.requiredInputKeys ?? extractTopLevelRequiredKeys(inputSchema); | |
| 720 | + const previewInputKeys = typing.previewInputKeys ?? buildPreviewKeys(inputSchema); | |
| 721 | + const inputHint = typing.inputHint?.trim(); | |
| 722 | + const outputHint = typing.outputHint?.trim(); | |
| 723 | + const hasInputSchema = Object.keys(inputSchema).length > 0; | |
| 724 | + const hasOutputSchema = Object.keys(outputSchema).length > 0; | |
| 725 | + const useInputHint = Boolean(inputHint && (!isLossyTypeHint(inputHint) || !hasInputSchema)); | |
| 726 | + const useOutputHint = Boolean(outputHint && (!isLossyTypeHint(outputHint) || !hasOutputSchema)); | |
| 727 | + | |
| 728 | + const displayInput = useInputHint && inputHint | |
| 729 | + ? displayArgTypeHint(inputHint) | |
| 730 | + : compactArgTypeHintFromSchema(inputSchema); | |
| 731 | + | |
| 732 | + const displayOutput = useOutputHint && outputHint | |
| 733 | + ? displayReturnTypeHint(outputHint) | |
| 734 | + : compactReturnTypeHintFromSchema(outputSchema); | |
| 735 | + | |
| 736 | + const typedRef = st.typing?.typedRef && st.typing.typedRef.kind === "openapi_operation" | |
| 737 | + ? { | |
| 738 | + kind: "openapi_operation" as const, | |
| 739 | + sourceKey: st.typing.typedRef.sourceKey, | |
| 740 | + operationId: st.typing.typedRef.operationId, | |
| 713 | 741 | } |
| 714 | − namespaceMap.set(entry.namespace, current); | |
| 742 | + : undefined; | |
| 743 | + | |
| 744 | + entries.push({ | |
| 745 | + path: st.path, | |
| 746 | + preferredPath, | |
| 747 | + namespace, | |
| 748 | + normalizedPath, | |
| 749 | + aliases, | |
| 750 | + description: st.description, | |
| 751 | + approval: st.approval, | |
| 752 | + source: st.source, | |
| 753 | + searchText, | |
| 754 | + displayInput, | |
| 755 | + displayOutput, | |
| 756 | + requiredInputKeys, | |
| 757 | + previewInputKeys, | |
| 758 | + typedRef, | |
| 759 | + serializedToolJson: JSON.stringify(st), | |
| 760 | + }); | |
| 761 | + } | |
| 762 | + | |
| 763 | + return entries; | |
| 764 | +} | |
| 765 | + | |
| 766 | + | |
| 767 | +function toSourceStateRows( | |
| 768 | + items: Iterable<MaterializedSourceStateRecord>, | |
| 769 | +): MaterializedSourceStateRecord[] { | |
| 770 | + return [...items] | |
| 771 | + .sort((left, right) => left.sourceName.localeCompare(right.sourceName)) | |
| 772 | + .map((item) => ({ ...item })); | |
| 773 | +} | |
| 774 | + | |
| 775 | +async function persistSourceStates( | |
| 776 | + ctx: ActionCtx, | |
| 777 | + workspaceId: Id<"workspaces">, | |
| 778 | + sourceStates: Iterable<MaterializedSourceStateRecord>, | |
| 779 | + signature?: string, | |
| 780 | +): Promise<void> { | |
| 781 | + await ctx.runMutation(internal.toolRegistry.setSourceStates, { | |
| 782 | + workspaceId, | |
| 783 | + sourceStates: toSourceStateRows(sourceStates), | |
| 784 | + signature, | |
| 785 | + }); | |
| 786 | +} | |
| 787 | + | |
| 788 | +async function listAllRegistryEntries( | |
| 789 | + ctx: ActionCtx, | |
| 790 | + workspaceId: Id<"workspaces">, | |
| 791 | +): Promise<RegistryToolEntry[]> { | |
| 792 | + const entries: RegistryToolEntry[] = []; | |
| 793 | + let cursor: string | undefined; | |
| 794 | + | |
| 795 | + while (true) { | |
| 796 | + const page: { | |
| 797 | + continueCursor: string | null; | |
| 798 | + items: RegistryToolEntry[]; | |
| 799 | + } = await ctx.runQuery(internal.toolRegistry.listToolsPage, { | |
| 800 | + workspaceId, | |
| 801 | + cursor, | |
| 802 | + limit: 250, | |
| 803 | + }); | |
| 804 | + | |
| 805 | + entries.push(...page.items); | |
| 806 | + if (page.continueCursor === null) { | |
| 807 | + break; | |
| 715 | 808 | } |
| 809 | + cursor = page.continueCursor; | |
| 810 | + } | |
| 716 | 811 | |
| 717 | − const namespaces = [...namespaceMap.entries()] | |
| 718 | − .map(([namespace, meta]) => ({ | |
| 719 | − namespace, | |
| 720 | − toolCount: meta.toolCount, | |
| 721 | − samplePaths: [...meta.samplePaths].sort((a, b) => a.localeCompare(b)).slice(0, 3), | |
| 722 | − })) | |
| 723 | − .sort((a, b) => a.namespace.localeCompare(b.namespace)); | |
| 812 | + return entries; | |
| 813 | +} | |
| 724 | 814 | |
| 725 | − const TOOL_BATCH = 100; | |
| 726 | − for (let i = 0; i < filteredEntries.length; i += TOOL_BATCH) { | |
| 727 | − await ctx.runMutation(internal.toolRegistry.putToolsBatch, { | |
| 728 | − workspaceId: args.workspaceId, | |
| 729 | − buildId, | |
| 730 | − tools: filteredEntries.slice(i, i + TOOL_BATCH), | |
| 731 | − }); | |
| 815 | +async function listAllSerializedTools( | |
| 816 | + ctx: ActionCtx, | |
| 817 | + workspaceId: Id<"workspaces">, | |
| 818 | +): Promise<SerializedTool[]> { | |
| 819 | + const serializedTools: SerializedTool[] = []; | |
| 820 | + let cursor: string | undefined; | |
| 821 | + | |
| 822 | + while (true) { | |
| 823 | + const page: { | |
| 824 | + continueCursor: string | null; | |
| 825 | + items: Array<{ path: string; serializedToolJson: string }>; | |
| 826 | + } = await ctx.runQuery(internal.toolRegistry.listSerializedToolsPage, { | |
| 827 | + workspaceId, | |
| 828 | + cursor, | |
| 829 | + limit: 250, | |
| 830 | + }); | |
| 831 | + | |
| 832 | + for (const item of page.items) { | |
| 833 | + try { | |
| 834 | + const parsed = JSON.parse(item.serializedToolJson); | |
| 835 | + const parsedTool = parseSerializedTool(parsed); | |
| 836 | + if (parsedTool.isOk()) { | |
| 837 | + serializedTools.push(parsedTool.value); | |
| 838 | + } | |
| 839 | + } catch { | |
| 840 | + // Ignore malformed payloads; they are surfaced as warnings elsewhere. | |
| 841 | + } | |
| 732 | 842 | } |
| 733 | 843 | |
| 734 | − const NS_BATCH = 100; | |
| 735 | − for (let i = 0; i < namespaces.length; i += NS_BATCH) { | |
| 736 | − await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, { | |
| 737 | − workspaceId: args.workspaceId, | |
| 738 | − buildId, | |
| 739 | − namespaces: namespaces.slice(i, i + NS_BATCH), | |
| 740 | − }); | |
| 844 | + if (page.continueCursor === null) { | |
| 845 | + break; | |
| 741 | 846 | } |
| 847 | + cursor = page.continueCursor; | |
| 848 | + } | |
| 742 | 849 | |
| 743 | − await ctx.runMutation(internal.toolRegistry.finishBuild, { | |
| 744 | − workspaceId: args.workspaceId, | |
| 745 | − buildId, | |
| 746 | − signature: args.registrySignature, | |
| 747 | − }); | |
| 850 | + return serializedTools; | |
| 851 | +} | |
| 748 | 852 | |
| 749 | − await ctx.runAction(internal.toolRegistry.pruneBuilds, { | |
| 750 | − workspaceId: args.workspaceId, | |
| 751 | − maxRetainedBuilds: 2, | |
| 752 | − }); | |
| 853 | +async function rebuildNamespaceSummaries( | |
| 854 | + ctx: ActionCtx, | |
| 855 | + workspaceId: Id<"workspaces">, | |
| 856 | + entries: RegistryToolEntry[], | |
| 857 | +): Promise<void> { | |
| 858 | + let cursor: string | undefined; | |
| 859 | + while (true) { | |
| 860 | + const page: { continueCursor: string | null } = await ctx.runMutation( | |
| 861 | + internal.toolRegistry.deleteToolRegistryNamespacesPage, | |
| 862 | + { | |
| 863 | + workspaceId, | |
| 864 | + cursor, | |
| 865 | + }, | |
| 866 | + ); | |
| 867 | + if (page.continueCursor === null) { | |
| 868 | + break; | |
| 869 | + } | |
| 870 | + cursor = page.continueCursor; | |
| 871 | + } | |
| 753 | 872 | |
| 754 | − return { buildId }; | |
| 755 | − } catch (error) { | |
| 756 | − await ctx.runMutation(internal.toolRegistry.failBuild, { | |
| 757 | − workspaceId: args.workspaceId, | |
| 758 | − buildId, | |
| 873 | + const namespaceMap = new Map<string, { toolCount: number; samplePaths: string[] }>(); | |
| 874 | + for (const entry of entries) { | |
| 875 | + const namespace = (entry.preferredPath.split(".")[0] ?? "default").toLowerCase(); | |
| 876 | + const current = namespaceMap.get(namespace) ?? { toolCount: 0, samplePaths: [] }; | |
| 877 | + current.toolCount += 1; | |
| 878 | + if (current.samplePaths.length < 6) { | |
| 879 | + current.samplePaths.push(entry.preferredPath); | |
| 880 | + } | |
| 881 | + namespaceMap.set(namespace, current); | |
| 882 | + } | |
| 883 | + | |
| 884 | + const namespaces = [...namespaceMap.entries()] | |
| 885 | + .map(([namespace, meta]) => ({ | |
| 886 | + namespace, | |
| 887 | + toolCount: meta.toolCount, | |
| 888 | + samplePaths: [...meta.samplePaths].sort((a, b) => a.localeCompare(b)).slice(0, 3), | |
| 889 | + })) | |
| 890 | + .sort((a, b) => a.namespace.localeCompare(b.namespace)); | |
| 891 | + | |
| 892 | + const NS_BATCH = 100; | |
| 893 | + for (let i = 0; i < namespaces.length; i += NS_BATCH) { | |
| 894 | + await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, { | |
| 895 | + workspaceId, | |
| 896 | + namespaces: namespaces.slice(i, i + NS_BATCH), | |
| 759 | 897 | }); |
| 760 | − throw error; | |
| 761 | 898 | } |
| 762 | 899 | } |
| 763 | 900 | |
| 764 | −// No implicit "ensure"/backfill on reads: the registry is built on writes. | |
| 765 | − | |
| 766 | −export async function getWorkspaceTools( | |
| 901 | +async function refreshWorkspaceToolRegistryIncremental( | |
| 767 | 902 | ctx: ActionCtx, |
| 768 | − workspaceId: Id<"workspaces">, | |
| 769 | − options: GetWorkspaceToolsOptions = {}, | |
| 770 | −): Promise<WorkspaceToolsResult> { | |
| 771 | − const includeDts = options.includeDts ?? false; | |
| 772 | − const sourceTimeoutMs = options.sourceTimeoutMs; | |
| 773 | − const accountId = options.accountId; | |
| 903 | + context: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string }, | |
| 904 | +): Promise<void> { | |
| 905 | + const workspaceId = context.workspaceId; | |
| 774 | 906 | const sources = (await listWorkspaceToolSources(ctx, workspaceId)) |
| 775 | 907 | .filter((source) => source.enabled); |
| 776 | − const registrySignature = registrySignatureForWorkspace(workspaceId, sources); | |
| 777 | 908 | |
| 778 | − const configs: ExternalToolSourceConfig[] = []; | |
| 909 | + const existingState = await ctx.runQuery(internal.toolRegistry.getState, { | |
| 910 | + workspaceId, | |
| 911 | + }) as WorkspaceRegistryStateRecord; | |
| 912 | + | |
| 913 | + const sourceStatesById = new Map<string, MaterializedSourceStateRecord>( | |
| 914 | + (existingState?.sourceStates ?? []).map((sourceState) => [sourceState.sourceId, sourceState]), | |
| 915 | + ); | |
| 916 | + | |
| 917 | + const enabledSourceIds = new Set(sources.map((source) => source.id)); | |
| 918 | + for (const [sourceId, sourceState] of sourceStatesById.entries()) { | |
| 919 | + if (enabledSourceIds.has(sourceId)) { | |
| 920 | + continue; | |
| 921 | + } | |
| 922 | + await ctx.runAction(internal.toolRegistry.deleteToolsBySource, { | |
| 923 | + workspaceId, | |
| 924 | + source: sourceState.sourceKey, | |
| 925 | + }); | |
| 926 | + sourceStatesById.delete(sourceId); | |
| 927 | + } | |
| 928 | + | |
| 929 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 930 | + | |
| 779 | 931 | const warnings: string[] = []; |
| 932 | + | |
| 780 | 933 | for (const source of sources) { |
| 934 | + const sourceKey = sourceStateSourceKey(source); | |
| 935 | + const signature = sourceStateSignature(workspaceId, source); | |
| 936 | + const previous = sourceStatesById.get(source.id); | |
| 937 | + if (previous && previous.signature === signature && previous.state === "ready") { | |
| 938 | + continue; | |
| 939 | + } | |
| 940 | + | |
| 941 | + const startedAt = Date.now(); | |
| 942 | + sourceStatesById.set(source.id, { | |
| 943 | + sourceId: source.id, | |
| 944 | + sourceName: source.name, | |
| 945 | + sourceKey, | |
| 946 | + signature, | |
| 947 | + state: "loading", | |
| 948 | + toolCount: previous?.toolCount ?? 0, | |
| 949 | + processedTools: 0, | |
| 950 | + message: "Starting source load", | |
| 951 | + startedAt, | |
| 952 | + updatedAt: startedAt, | |
| 953 | + }); | |
| 954 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 955 | + | |
| 781 | 956 | const normalizedResult = normalizeExternalToolSource(source); |
| 782 | 957 | if (normalizedResult.isErr()) { |
| 783 | − warnings.push(`Source '${source.name}': ${normalizedResult.error.message}`); | |
| 958 | + const message = normalizedResult.error.message; | |
| 959 | + const failedAt = Date.now(); | |
| 960 | + warnings.push(`Source '${source.name}': ${message}`); | |
| 961 | + sourceStatesById.set(source.id, { | |
| 962 | + sourceId: source.id, | |
| 963 | + sourceName: source.name, | |
| 964 | + sourceKey, | |
| 965 | + signature, | |
| 966 | + state: "failed", | |
| 967 | + toolCount: previous?.toolCount ?? 0, | |
| 968 | + processedTools: 0, | |
| 969 | + message: "Failed", | |
| 970 | + error: message, | |
| 971 | + startedAt, | |
| 972 | + updatedAt: failedAt, | |
| 973 | + }); | |
| 974 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 784 | 975 | continue; |
| 785 | 976 | } |
| 786 | − configs.push(normalizedResult.value); | |
| 787 | − } | |
| 788 | 977 | |
| 789 | − const loadedSources = await Promise.all(configs.map(async (config) => { | |
| 790 | − if (!sourceTimeoutMs || sourceTimeoutMs <= 0) { | |
| 791 | − return { | |
| 792 | − ...(await loadSourceArtifact(ctx, config, { includeDts, workspaceId, accountId })), | |
| 793 | − timedOut: false, | |
| 794 | − sourceName: config.name, | |
| 795 | − }; | |
| 796 | − } | |
| 978 | + const initialLoadMessage = sourceLoadingMessage(normalizedResult.value, 0); | |
| 979 | + sourceStatesById.set(source.id, { | |
| 980 | + sourceId: source.id, | |
| 981 | + sourceName: source.name, | |
| 982 | + sourceKey, | |
| 983 | + signature, | |
| 984 | + state: "loading", | |
| 985 | + toolCount: previous?.toolCount ?? 0, | |
| 986 | + processedTools: 0, | |
| 987 | + message: initialLoadMessage, | |
| 988 | + startedAt, | |
| 989 | + updatedAt: Date.now(), | |
| 990 | + }); | |
| 991 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 992 | + | |
| 993 | + let keepHeartbeatRunning = true; | |
| 994 | + const heartbeat = (async () => { | |
| 995 | + while (keepHeartbeatRunning) { | |
| 996 | + await sleep(SOURCE_LOAD_HEARTBEAT_MS); | |
| 997 | + if (!keepHeartbeatRunning) { | |
| 998 | + break; | |
| 999 | + } | |
| 797 | 1000 | |
| 798 | − let timer: ReturnType<typeof setTimeout> | undefined; | |
| 799 | − const timeoutResult = new Promise<{ | |
| 800 | − artifact?: CompiledToolSourceArtifact; | |
| 801 | − warnings: string[]; | |
| 802 | − timedOut: boolean; | |
| 803 | − sourceName: string; | |
| 804 | − openApiDts?: string; | |
| 805 | − openApiSourceKey?: string; | |
| 806 | − }>((resolve) => { | |
| 807 | − timer = setTimeout(() => { | |
| 808 | − resolve({ | |
| 809 | − artifact: undefined, | |
| 810 | − warnings: [`Source '${config.name}' is still loading; showing partial results.`], | |
| 811 | − timedOut: true, | |
| 812 | − sourceName: config.name, | |
| 813 | − openApiDts: undefined, | |
| 814 | − openApiSourceKey: config.type === "openapi" ? (config.sourceKey ?? `openapi:${config.name}`) : undefined, | |
| 1001 | + const elapsedMs = Date.now() - startedAt; | |
| 1002 | + const elapsedSeconds = Math.max(1, Math.floor(elapsedMs / 1000)); | |
| 1003 | + const loadingMessage = `${sourceLoadingMessage(normalizedResult.value, elapsedSeconds)} (${elapsedSeconds}s)`; | |
| 1004 | + sourceStatesById.set(source.id, { | |
| 1005 | + sourceId: source.id, | |
| 1006 | + sourceName: source.name, | |
| 1007 | + sourceKey, | |
| 1008 | + signature, | |
| 1009 | + state: "loading", | |
| 1010 | + toolCount: previous?.toolCount ?? 0, | |
| 1011 | + processedTools: 0, | |
| 1012 | + message: loadingMessage, | |
| 1013 | + startedAt, | |
| 1014 | + updatedAt: Date.now(), | |
| 815 | 1015 | }); |
| 816 | − }, sourceTimeoutMs); | |
| 817 | − }); | |
| 1016 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 1017 | + } | |
| 1018 | + })(); | |
| 1019 | + | |
| 1020 | + let loaded: | |
| 1021 | + | { | |
| 1022 | + artifact?: CompiledToolSourceArtifact; | |
| 1023 | + warnings: string[]; | |
| 1024 | + openApiDts?: string; | |
| 1025 | + openApiSourceKey?: string; | |
| 1026 | + } | |
| 1027 | + | undefined; | |
| 818 | 1028 | |
| 819 | − const loadResult = loadSourceArtifact(ctx, config, { includeDts, workspaceId, accountId }) | |
| 820 | − .then((result) => ({ ...result, timedOut: false, sourceName: config.name })); | |
| 1029 | + try { | |
| 1030 | + loaded = await loadSourceArtifact(ctx, normalizedResult.value, { | |
| 1031 | + includeDts: false, | |
| 1032 | + workspaceId, | |
| 1033 | + accountId: context.accountId, | |
| 1034 | + }); | |
| 1035 | + } catch (error) { | |
| 1036 | + keepHeartbeatRunning = false; | |
| 1037 | + await heartbeat; | |
| 821 | 1038 | |
| 822 | − const result = await Promise.race([loadResult, timeoutResult]); | |
| 823 | − if (timer && !result.timedOut) { | |
| 824 | − clearTimeout(timer); | |
| 1039 | + const message = error instanceof Error ? error.message : String(error); | |
| 1040 | + const failedAt = Date.now(); | |
| 1041 | + sourceStatesById.set(source.id, { | |
| 1042 | + sourceId: source.id, | |
| 1043 | + sourceName: source.name, | |
| 1044 | + sourceKey, | |
| 1045 | + signature, | |
| 1046 | + state: "failed", | |
| 1047 | + toolCount: previous?.toolCount ?? 0, | |
| 1048 | + processedTools: 0, | |
| 1049 | + message: "Failed", | |
| 1050 | + error: message, | |
| 1051 | + startedAt, | |
| 1052 | + updatedAt: failedAt, | |
| 1053 | + }); | |
| 1054 | + warnings.push(`Source '${source.name}': ${message}`); | |
| 1055 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 1056 | + continue; | |
| 825 | 1057 | } |
| 826 | − return result; | |
| 827 | − })); | |
| 828 | − const externalArtifacts = loadedSources | |
| 829 | − .map((loaded) => loaded.artifact) | |
| 830 | − .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact)); | |
| 831 | − const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact)); | |
| 832 | − warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings)); | |
| 833 | − const hasTimedOutSource = loadedSources.some((loaded) => loaded.timedOut); | |
| 834 | − const timedOutSources = loadedSources | |
| 835 | − .filter((loaded) => loaded.timedOut) | |
| 836 | − .map((loaded) => loaded.sourceName); | |
| 837 | − const merged = mergeTools(externalTools); | |
| 838 | 1058 | |
| 839 | − let typesStorageId: Id<"_storage"> | undefined; | |
| 840 | − try { | |
| 841 | − if (hasTimedOutSource) { | |
| 842 | − return { | |
| 843 | − tools: merged, | |
| 844 | − warnings, | |
| 845 | − typesStorageId, | |
| 846 | − timedOutSources, | |
| 847 | − }; | |
| 1059 | + keepHeartbeatRunning = false; | |
| 1060 | + await heartbeat; | |
| 1061 | + | |
| 1062 | + if (!loaded.artifact) { | |
| 1063 | + const message = loaded.warnings[0] ?? `No tools generated for source '${source.name}'`; | |
| 1064 | + const failedAt = Date.now(); | |
| 1065 | + sourceStatesById.set(source.id, { | |
| 1066 | + sourceId: source.id, | |
| 1067 | + sourceName: source.name, | |
| 1068 | + sourceKey, | |
| 1069 | + signature, | |
| 1070 | + state: "failed", | |
| 1071 | + toolCount: previous?.toolCount ?? 0, | |
| 1072 | + processedTools: 0, | |
| 1073 | + message: "Failed", | |
| 1074 | + error: message, | |
| 1075 | + startedAt, | |
| 1076 | + updatedAt: failedAt, | |
| 1077 | + }); | |
| 1078 | + warnings.push(`Source '${source.name}': ${message}`); | |
| 1079 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 1080 | + continue; | |
| 848 | 1081 | } |
| 1082 | + warnings.push(...loaded.warnings); | |
| 849 | 1083 | |
| 850 | − const allTools = [...merged.values()]; | |
| 1084 | + const serializedTools = loaded.artifact.tools; | |
| 1085 | + const entries = registryWriteEntriesFromSerializedTools(serializedTools); | |
| 851 | 1086 | |
| 852 | − // Build a per-tool registry for fast discover + invocation. | |
| 853 | − const { buildId } = await buildWorkspaceToolRegistry(ctx, { | |
| 854 | − workspaceId, | |
| 855 | − registrySignature, | |
| 856 | − serializedTools: externalArtifacts.flatMap((artifact) => artifact.tools), | |
| 1087 | + sourceStatesById.set(source.id, { | |
| 1088 | + sourceId: source.id, | |
| 1089 | + sourceName: source.name, | |
| 1090 | + sourceKey, | |
| 1091 | + signature, | |
| 1092 | + state: "indexing", | |
| 1093 | + toolCount: entries.length, | |
| 1094 | + processedTools: 0, | |
| 1095 | + message: `Indexing 0/${entries.length} tools`, | |
| 1096 | + startedAt, | |
| 1097 | + updatedAt: Date.now(), | |
| 857 | 1098 | }); |
| 1099 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 858 | 1100 | |
| 859 | − // Build and store a workspace-wide Monaco type bundle. | |
| 860 | − const openApiDtsBySource: Record<string, string> = {}; | |
| 861 | − for (const loaded of loadedSources) { | |
| 862 | − if (loaded.openApiDts && loaded.openApiDts.trim().length > 0) { | |
| 863 | − const sourceKey = loaded.openApiSourceKey ?? `openapi:${loaded.sourceName}`; | |
| 864 | − openApiDtsBySource[sourceKey] = loaded.openApiDts; | |
| 865 | − } | |
| 1101 | + if (previous?.sourceKey && previous.sourceKey !== sourceKey) { | |
| 1102 | + await ctx.runAction(internal.toolRegistry.deleteToolsBySource, { | |
| 1103 | + workspaceId, | |
| 1104 | + source: previous.sourceKey, | |
| 1105 | + }); | |
| 866 | 1106 | } |
| 867 | − const typeBundle = buildWorkspaceTypeBundle({ | |
| 868 | − tools: allTools, | |
| 869 | − openApiDtsBySource, | |
| 1107 | + | |
| 1108 | + await ctx.runAction(internal.toolRegistry.deleteToolsBySource, { | |
| 1109 | + workspaceId, | |
| 1110 | + source: sourceKey, | |
| 870 | 1111 | }); |
| 871 | − const typesBlob = new Blob([typeBundle], { type: "text/plain" }); | |
| 872 | − typesStorageId = await ctx.storage.store(typesBlob); | |
| 873 | 1112 | |
| 874 | − const sourceToolCountsByName = new Map<string, number>(); | |
| 875 | − for (const source of sources) { | |
| 876 | − sourceToolCountsByName.set(source.name, 0); | |
| 877 | − } | |
| 878 | − for (const tool of allTools) { | |
| 879 | − const sourceName = toSourceName(tool.source); | |
| 880 | − if (!sourceName) continue; | |
| 881 | − sourceToolCountsByName.set(sourceName, (sourceToolCountsByName.get(sourceName) ?? 0) + 1); | |
| 882 | − } | |
| 1113 | + const TOOL_BATCH = 100; | |
| 1114 | + let processedTools = 0; | |
| 1115 | + for (let i = 0; i < entries.length; i += TOOL_BATCH) { | |
| 1116 | + await ctx.runMutation(internal.toolRegistry.putToolsBatch, { | |
| 1117 | + workspaceId, | |
| 1118 | + tools: entries.slice(i, i + TOOL_BATCH), | |
| 1119 | + }); | |
| 883 | 1120 | |
| 884 | − await ctx.runMutation(internal.toolRegistry.updateBuildMetadata, { | |
| 885 | − workspaceId, | |
| 886 | − buildId, | |
| 887 | − typesStorageId, | |
| 888 | − warnings, | |
| 889 | − toolCount: allTools.length, | |
| 890 | − sourceToolCounts: [...sourceToolCountsByName.entries()].map(([sourceName, toolCount]) => ({ | |
| 891 | − sourceName, | |
| 892 | − toolCount, | |
| 893 | − })), | |
| 894 | − sourceVersions: sources.map((source) => ({ | |
| 1121 | + processedTools = Math.min(entries.length, processedTools + TOOL_BATCH); | |
| 1122 | + sourceStatesById.set(source.id, { | |
| 895 | 1123 | sourceId: source.id, |
| 896 | 1124 | sourceName: source.name, |
| 897 | − updatedAt: source.updatedAt, | |
| 898 | − })), | |
| 899 | − sourceQuality: Object.values(computeOpenApiSourceQualityFromSerializedTools( | |
| 900 | − externalArtifacts.flatMap((artifact) => artifact.tools), | |
| 901 | − )), | |
| 902 | − sourceAuthProfiles: Object.entries(computeSourceAuthProfilesFromSources(sources)).map(([sourceKey, profile]) => ({ | |
| 903 | 1125 | sourceKey, |
| 904 | − type: profile.type, | |
| 905 | − mode: profile.mode, | |
| 906 | − header: profile.header, | |
| 907 | − inferred: profile.inferred, | |
| 908 | − })), | |
| 909 | − openApiRefHintTables: buildBoundedOpenApiRefHintTables(externalArtifacts), | |
| 910 | − } as never); | |
| 911 | − } catch (error) { | |
| 912 | − const msg = error instanceof Error ? error.message : String(error); | |
| 913 | − console.warn(`[executor] workspace tool registry metadata write failed for '${workspaceId}': ${msg}`); | |
| 1126 | + signature, | |
| 1127 | + state: "indexing", | |
| 1128 | + toolCount: entries.length, | |
| 1129 | + processedTools, | |
| 1130 | + message: `Indexing ${processedTools}/${entries.length} tools`, | |
| 1131 | + startedAt, | |
| 1132 | + updatedAt: Date.now(), | |
| 1133 | + }); | |
| 1134 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 1135 | + } | |
| 1136 | + | |
| 1137 | + const sourceQuality = source.type === "openapi" | |
| 1138 | + ? computeOpenApiSourceQualityFromSerializedTools(serializedTools)[sourceKey] | |
| 1139 | + : undefined; | |
| 1140 | + const openApiRefHints = buildBoundedOpenApiRefHintTables([loaded.artifact])[0]?.refs; | |
| 1141 | + const completedAt = Date.now(); | |
| 1142 | + sourceStatesById.set(source.id, { | |
| 1143 | + sourceId: source.id, | |
| 1144 | + sourceName: source.name, | |
| 1145 | + sourceKey, | |
| 1146 | + signature, | |
| 1147 | + state: "ready", | |
| 1148 | + toolCount: entries.length, | |
| 1149 | + processedTools: entries.length, | |
| 1150 | + message: "Ready", | |
| 1151 | + startedAt, | |
| 1152 | + completedAt, | |
| 1153 | + updatedAt: completedAt, | |
| 1154 | + ...(sourceQuality ? { sourceQuality } : {}), | |
| 1155 | + ...(openApiRefHints ? { openApiRefHints } : {}), | |
| 1156 | + }); | |
| 1157 | + await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); | |
| 914 | 1158 | } |
| 915 | 1159 | |
| 916 | − return { | |
| 917 | − tools: merged, | |
| 918 | − warnings, | |
| 1160 | + const allRegistryEntries = await listAllRegistryEntries(ctx, workspaceId); | |
| 1161 | + await rebuildNamespaceSummaries(ctx, workspaceId, allRegistryEntries); | |
| 1162 | + | |
| 1163 | + const serializedTools = await listAllSerializedTools(ctx, workspaceId); | |
| 1164 | + const hydratedTools = rehydrateTools(serializedTools, baseTools); | |
| 1165 | + const merged = mergeTools(hydratedTools); | |
| 1166 | + const typeBundle = buildWorkspaceTypeBundle({ | |
| 1167 | + tools: [...merged.values()], | |
| 1168 | + openApiDtsBySource: {}, | |
| 1169 | + }); | |
| 1170 | + | |
| 1171 | + let typesStorageId: Id<"_storage"> | undefined; | |
| 1172 | + try { | |
| 1173 | + typesStorageId = await ctx.storage.store(new Blob([typeBundle], { type: "text/plain" })); | |
| 1174 | + } catch { | |
| 1175 | + typesStorageId = undefined; | |
| 1176 | + } | |
| 1177 | + | |
| 1178 | + const sourceToolCountsByName = new Map<string, number>(); | |
| 1179 | + for (const source of sources) { | |
| 1180 | + sourceToolCountsByName.set(source.name, 0); | |
| 1181 | + } | |
| 1182 | + for (const entry of allRegistryEntries) { | |
| 1183 | + const sourceName = toSourceName(entry.source); | |
| 1184 | + if (!sourceName) { | |
| 1185 | + continue; | |
| 1186 | + } | |
| 1187 | + sourceToolCountsByName.set(sourceName, (sourceToolCountsByName.get(sourceName) ?? 0) + 1); | |
| 1188 | + } | |
| 1189 | + | |
| 1190 | + const sourceStates = toSourceStateRows(sourceStatesById.values()); | |
| 1191 | + const sourceQuality = sourceStates | |
| 1192 | + .filter((state): state is MaterializedSourceStateRecord & { sourceQuality: OpenApiSourceQuality } => Boolean(state.sourceQuality)) | |
| 1193 | + .map((state) => state.sourceQuality); | |
| 1194 | + const openApiRefHintTables = sourceStates | |
| 1195 | + .filter((state): state is MaterializedSourceStateRecord & { openApiRefHints: Array<{ key: string; hint: string }> } => ( | |
| 1196 | + Array.isArray(state.openApiRefHints) && state.openApiRefHints.length > 0 | |
| 1197 | + )) | |
| 1198 | + .map((state) => ({ | |
| 1199 | + sourceKey: state.sourceKey, | |
| 1200 | + refs: state.openApiRefHints, | |
| 1201 | + })); | |
| 1202 | + | |
| 1203 | + await ctx.runMutation(internal.toolRegistry.updateRegistryMetadata, { | |
| 1204 | + workspaceId, | |
| 919 | 1205 | typesStorageId, |
| 920 | − timedOutSources, | |
| 921 | − }; | |
| 1206 | + warnings, | |
| 1207 | + toolCount: baseTools.size + allRegistryEntries.length, | |
| 1208 | + sourceToolCounts: [...sourceToolCountsByName.entries()].map(([sourceName, toolCount]) => ({ | |
| 1209 | + sourceName, | |
| 1210 | + toolCount, | |
| 1211 | + })), | |
| 1212 | + sourceQuality, | |
| 1213 | + sourceAuthProfiles: Object.entries(computeSourceAuthProfilesFromSources(sources)).map(([sourceKey, profile]) => ({ | |
| 1214 | + sourceKey, | |
| 1215 | + type: profile.type, | |
| 1216 | + mode: profile.mode, | |
| 1217 | + header: profile.header, | |
| 1218 | + inferred: profile.inferred, | |
| 1219 | + })), | |
| 1220 | + openApiRefHintTables, | |
| 1221 | + } as never); | |
| 1222 | + | |
| 1223 | + await persistSourceStates( | |
| 1224 | + ctx, | |
| 1225 | + workspaceId, | |
| 1226 | + sourceStatesById.values(), | |
| 1227 | + registrySignatureForWorkspace(workspaceId, sources), | |
| 1228 | + ); | |
| 922 | 1229 | } |
| 923 | 1230 | |
| 924 | 1231 | interface WorkspaceRegistryReadResult { |
| @@ -934,12 +1241,15 @@ interface WorkspaceRegistryReadResult { · 9 unmodified lines | ||
| 934 | 1241 | openApiRefHintLookup: Record<string, Record<string, string>>; |
| 935 | 1242 | } |
| 936 | 1243 | |
| 937 | −export type ToolSourceGenerationState = "ready" | "loading" | "refreshing" | "failed"; | |
| 1244 | +export type ToolSourceGenerationState = "queued" | "loading" | "indexing" | "ready" | "failed"; | |
| 938 | 1245 | |
| 939 | 1246 | export interface ToolSourceGenerationStatus { |
| 940 | 1247 | state: ToolSourceGenerationState; |
| 941 | 1248 | toolCount: number; |
| 942 | − changed: boolean; | |
| 1249 | + processedTools?: number; | |
| 1250 | + message?: string; | |
| 1251 | + error?: string; | |
| 1252 | + updatedAt?: number; | |
| 943 | 1253 | } |
| 944 | 1254 | |
| 945 | 1255 | export interface WorkspaceInventoryProgress { |
| @@ -986,37 +1296,29 @@ function toSourceAuthProfileRecord( · 40 unmodified lines | ||
| 986 | 1296 | return result; |
| 987 | 1297 | } |
| 988 | 1298 | |
| 989 | −function changedSourceNames( | |
| 990 | − currentSources: ToolSourceRecord[], | |
| 991 | − previousVersions: Array<{ sourceId: string; sourceName: string; updatedAt: number }>, | |
| 992 | −): string[] { | |
| 993 | − const previousById = new Map(previousVersions.map((source) => [source.sourceId, source])); | |
| 994 | − const changed = new Set<string>(); | |
| 995 | − | |
| 996 | − for (const source of currentSources) { | |
| 997 | − const previous = previousById.get(source.id); | |
| 998 | − if (!previous || previous.updatedAt !== source.updatedAt || previous.sourceName !== source.name) { | |
| 999 | − changed.add(source.name); | |
| 1000 | − } | |
| 1001 | − } | |
| 1002 | − | |
| 1003 | − return [...changed].sort((a, b) => a.localeCompare(b)); | |
| 1004 | −} | |
| 1005 | − | |
| 1006 | 1299 | type WorkspaceRegistryStateRecord = { |
| 1007 | 1300 | signature?: string; |
| 1008 | − readyBuildId?: string; | |
| 1009 | − buildingBuildId?: string; | |
| 1010 | − buildingSignature?: string; | |
| 1011 | − buildingStartedAt?: number; | |
| 1012 | − lastBuildCompletedAt?: number; | |
| 1013 | − lastBuildFailedAt?: number; | |
| 1014 | − lastBuildError?: string; | |
| 1301 | + lastRefreshError?: string; | |
| 1015 | 1302 | typesStorageId?: Id<"_storage">; |
| 1016 | 1303 | warnings?: string[]; |
| 1017 | 1304 | toolCount?: number; |
| 1018 | 1305 | sourceToolCounts?: Array<{ sourceName: string; toolCount: number }>; |
| 1019 | − sourceVersions?: Array<{ sourceId: string; sourceName: string; updatedAt: number }>; | |
| 1306 | + sourceStates?: Array<{ | |
| 1307 | + sourceId: string; | |
| 1308 | + sourceName: string; | |
| 1309 | + sourceKey: string; | |
| 1310 | + signature: string; | |
| 1311 | + state: "queued" | "loading" | "indexing" | "ready" | "failed"; | |
| 1312 | + toolCount: number; | |
| 1313 | + processedTools?: number; | |
| 1314 | + message?: string; | |
| 1315 | + error?: string; | |
| 1316 | + startedAt?: number; | |
| 1317 | + completedAt?: number; | |
| 1318 | + updatedAt: number; | |
| 1319 | + sourceQuality?: OpenApiSourceQuality; | |
| 1320 | + openApiRefHints?: Array<{ key: string; hint: string }>; | |
| 1321 | + }>; | |
| 1020 | 1322 | sourceQuality?: OpenApiSourceQuality[]; |
| 1021 | 1323 | sourceAuthProfiles?: Array<{ |
| 1022 | 1324 | sourceKey: string; |
| @@ -1032,86 +1334,129 @@ type WorkspaceRegistryStateRecord = { · 9 unmodified lines | ||
| 1032 | 1334 | updatedAt?: number; |
| 1033 | 1335 | } | null; |
| 1034 | 1336 | |
| 1337 | +type WorkspaceSourceStateRow = NonNullable<Exclude<WorkspaceRegistryStateRecord, null>["sourceStates"]>[number]; | |
| 1338 | + | |
| 1339 | +function toSourceStateRecord( | |
| 1340 | + items: Exclude<WorkspaceRegistryStateRecord, null>["sourceStates"] | undefined, | |
| 1341 | +): WorkspaceSourceStateRow[] { | |
| 1342 | + const result: WorkspaceSourceStateRow[] = []; | |
| 1343 | + for (const item of items ?? []) { | |
| 1344 | + if (!item || typeof item !== "object") { | |
| 1345 | + continue; | |
| 1346 | + } | |
| 1347 | + const state = (item as { state?: unknown }).state; | |
| 1348 | + if ( | |
| 1349 | + state !== "queued" | |
| 1350 | + && state !== "loading" | |
| 1351 | + && state !== "indexing" | |
| 1352 | + && state !== "ready" | |
| 1353 | + && state !== "failed" | |
| 1354 | + ) { | |
| 1355 | + continue; | |
| 1356 | + } | |
| 1357 | + const sourceId = (item as { sourceId?: unknown }).sourceId; | |
| 1358 | + const sourceName = (item as { sourceName?: unknown }).sourceName; | |
| 1359 | + const sourceKey = (item as { sourceKey?: unknown }).sourceKey; | |
| 1360 | + const signature = (item as { signature?: unknown }).signature; | |
| 1361 | + const toolCount = (item as { toolCount?: unknown }).toolCount; | |
| 1362 | + const updatedAt = (item as { updatedAt?: unknown }).updatedAt; | |
| 1363 | + if ( | |
| 1364 | + typeof sourceId !== "string" | |
| 1365 | + || typeof sourceName !== "string" | |
| 1366 | + || typeof sourceKey !== "string" | |
| 1367 | + || typeof signature !== "string" | |
| 1368 | + || typeof toolCount !== "number" | |
| 1369 | + || typeof updatedAt !== "number" | |
| 1370 | + ) { | |
| 1371 | + continue; | |
| 1372 | + } | |
| 1373 | + | |
| 1374 | + result.push(item as WorkspaceSourceStateRow); | |
| 1375 | + } | |
| 1376 | + | |
| 1377 | + return result; | |
| 1378 | +} | |
| 1379 | + | |
| 1035 | 1380 | function computeWorkspaceInventoryProgress( |
| 1036 | 1381 | workspaceId: Id<"workspaces">, |
| 1037 | 1382 | sources: ToolSourceRecord[], |
| 1038 | 1383 | registryState: WorkspaceRegistryStateRecord, |
| 1039 | 1384 | ): WorkspaceInventoryProgress { |
| 1040 | 1385 | const expectedRegistrySignature = registrySignatureForWorkspace(workspaceId, sources); |
| 1041 | − const readyBuildId = registryState?.readyBuildId; | |
| 1042 | − const building = Boolean(registryState?.buildingBuildId); | |
| 1043 | − const isFresh = Boolean(registryState?.signature && registryState.signature === expectedRegistrySignature && !building); | |
| 1386 | + const isFresh = Boolean(registryState?.signature && registryState.signature === expectedRegistrySignature); | |
| 1044 | 1387 | const sourceCounts = toSourceToolCountRecord(registryState?.sourceToolCounts ?? []); |
| 1045 | − const changedSources = changedSourceNames(sources, registryState?.sourceVersions ?? []); | |
| 1388 | + const sourceStateById = new Map(toSourceStateRecord(registryState?.sourceStates).map((sourceState) => [sourceState.sourceId, sourceState])); | |
| 1389 | + | |
| 1390 | + const loadingSourceNames: string[] = []; | |
| 1391 | + const sourceStates: Record<string, ToolSourceGenerationStatus> = {}; | |
| 1392 | + let failedSourceError: string | undefined; | |
| 1393 | + | |
| 1394 | + for (const source of sources) { | |
| 1395 | + const sourceState = sourceStateById.get(source.id); | |
| 1396 | + const expectedSourceSignature = registrySignatureForWorkspace(workspaceId, [{ | |
| 1397 | + id: source.id, | |
| 1398 | + updatedAt: source.updatedAt, | |
| 1399 | + enabled: source.enabled, | |
| 1400 | + }]); | |
| 1401 | + | |
| 1402 | + const status = sourceState | |
| 1403 | + ? sourceState.state | |
| 1404 | + : "queued"; | |
| 1405 | + const signatureMismatch = !sourceState || sourceState.signature !== expectedSourceSignature; | |
| 1406 | + const sourceStateValue: ToolSourceGenerationState = signatureMismatch | |
| 1407 | + ? "queued" | |
| 1408 | + : status; | |
| 1409 | + | |
| 1410 | + if (sourceStateValue === "queued" || sourceStateValue === "loading" || sourceStateValue === "indexing") { | |
| 1411 | + loadingSourceNames.push(source.name); | |
| 1412 | + } | |
| 1413 | + | |
| 1414 | + if (!failedSourceError && sourceStateValue === "failed" && sourceState?.error) { | |
| 1415 | + failedSourceError = sourceState.error; | |
| 1416 | + } | |
| 1417 | + | |
| 1418 | + sourceStates[source.name] = { | |
| 1419 | + state: sourceStateValue, | |
| 1420 | + toolCount: sourceState?.toolCount ?? sourceCounts[source.name] ?? 0, | |
| 1421 | + ...(typeof sourceState?.processedTools === "number" ? { processedTools: sourceState.processedTools } : {}), | |
| 1422 | + ...( | |
| 1423 | + typeof sourceState?.message === "string" | |
| 1424 | + ? { message: sourceState.message } | |
| 1425 | + : sourceStateValue === "queued" | |
| 1426 | + ? { message: "Queued" } | |
| 1427 | + : {} | |
| 1428 | + ), | |
| 1429 | + ...(sourceState?.error ? { error: sourceState.error } : {}), | |
| 1430 | + ...(typeof sourceState?.updatedAt === "number" ? { updatedAt: sourceState.updatedAt } : {}), | |
| 1431 | + }; | |
| 1432 | + } | |
| 1046 | 1433 | |
| 1047 | 1434 | let state: ToolInventoryState; |
| 1048 | − if (building && readyBuildId) { | |
| 1435 | + if (loadingSourceNames.length > 0 && (registryState?.toolCount ?? 0) > 0) { | |
| 1049 | 1436 | state = "rebuilding"; |
| 1050 | − } else if (building && !readyBuildId) { | |
| 1437 | + } else if (loadingSourceNames.length > 0) { | |
| 1051 | 1438 | state = "initializing"; |
| 1052 | − } else if (!readyBuildId && registryState?.lastBuildError) { | |
| 1439 | + } else if (failedSourceError || registryState?.lastRefreshError) { | |
| 1053 | 1440 | state = "failed"; |
| 1054 | − } else if (!readyBuildId) { | |
| 1055 | − state = "initializing"; | |
| 1056 | 1441 | } else if (isFresh) { |
| 1057 | 1442 | state = "ready"; |
| 1058 | − } else if (registryState?.lastBuildError) { | |
| 1059 | − state = "failed"; | |
| 1060 | 1443 | } else { |
| 1061 | − state = "stale"; | |
| 1444 | + state = "ready"; | |
| 1062 | 1445 | } |
| 1063 | 1446 | |
| 1064 | 1447 | const warnings = [...(registryState?.warnings ?? [])]; |
| 1065 | − const allSourceNames = sources.map((source) => source.name); | |
| 1066 | − const loadingSourceNames = state === "initializing" | |
| 1067 | − ? [...allSourceNames] | |
| 1068 | − : (state === "rebuilding" || state === "stale" || state === "failed") | |
| 1069 | − ? changedSources | |
| 1070 | − : []; | |
| 1071 | − | |
| 1072 | − if (state === "initializing" && allSourceNames.length > 0) { | |
| 1073 | − warnings.push("Tool inventory is still loading; showing partial results."); | |
| 1074 | − } | |
| 1075 | − | |
| 1076 | − if (state === "stale" || state === "rebuilding") { | |
| 1077 | − warnings.push("Tool sources changed; showing previous results while refreshing."); | |
| 1078 | − } | |
| 1079 | − if (state === "failed" && registryState?.lastBuildError) { | |
| 1080 | − warnings.push(`Tool inventory build failed: ${registryState.lastBuildError}`); | |
| 1081 | − } | |
| 1082 | − for (const sourceName of loadingSourceNames) { | |
| 1083 | − warnings.push(`Source '${sourceName}' is still loading; showing partial results.`); | |
| 1084 | − } | |
| 1085 | − | |
| 1086 | − const loadingSourceSet = new Set(loadingSourceNames); | |
| 1087 | − const changedSourceSet = new Set(changedSources); | |
| 1088 | − const sourceStates: Record<string, ToolSourceGenerationStatus> = {}; | |
| 1089 | − for (const source of sources) { | |
| 1090 | − const changed = changedSourceSet.has(source.name); | |
| 1091 | − const loading = loadingSourceSet.has(source.name); | |
| 1092 | − | |
| 1093 | − let sourceState: ToolSourceGenerationState; | |
| 1094 | − if (state === "failed" && loading) { | |
| 1095 | − sourceState = "failed"; | |
| 1096 | − } else if (state === "stale" && loading) { | |
| 1097 | − sourceState = "refreshing"; | |
| 1098 | − } else if (loading) { | |
| 1099 | − sourceState = "loading"; | |
| 1100 | − } else { | |
| 1101 | − sourceState = "ready"; | |
| 1448 | + for (const [sourceName, sourceStatus] of Object.entries(sourceStates)) { | |
| 1449 | + if (sourceStatus.state === "failed" && sourceStatus.error) { | |
| 1450 | + warnings.push(`Source '${sourceName}' failed: ${sourceStatus.error}`); | |
| 1451 | + continue; | |
| 1102 | 1452 | } |
| 1103 | − | |
| 1104 | − sourceStates[source.name] = { | |
| 1105 | − state: sourceState, | |
| 1106 | − toolCount: sourceCounts[source.name] ?? 0, | |
| 1107 | − changed, | |
| 1108 | − }; | |
| 1453 | + } | |
| 1454 | + if (state === "failed" && registryState?.lastRefreshError) { | |
| 1455 | + warnings.push(`Tool inventory refresh failed: ${registryState.lastRefreshError}`); | |
| 1109 | 1456 | } |
| 1110 | 1457 | |
| 1111 | 1458 | const inventoryStatus: ToolInventoryStatus = { |
| 1112 | 1459 | state, |
| 1113 | − readyBuildId, | |
| 1114 | − buildingBuildId: registryState?.buildingBuildId, | |
| 1115 | 1460 | readyToolCount: registryState?.toolCount ?? 0, |
| 1116 | 1461 | loadingSourceNames, |
| 1117 | 1462 | sourceToolCounts: { |
| @@ -1119,22 +1464,19 @@ function computeWorkspaceInventoryProgress( · 1 unmodified line | ||
| 1119 | 1464 | // Ensure system tools are always visible even for pre-existing registry states. |
| 1120 | 1465 | system: sourceCounts["system"] ?? baseTools.size, |
| 1121 | 1466 | }, |
| 1122 | − lastBuildStartedAt: registryState?.buildingStartedAt, | |
| 1123 | − lastBuildCompletedAt: registryState?.lastBuildCompletedAt, | |
| 1124 | − lastBuildFailedAt: registryState?.lastBuildFailedAt, | |
| 1125 | − ...(registryState?.lastBuildError ? { error: registryState.lastBuildError } : {}), | |
| 1467 | + ...(failedSourceError || registryState?.lastRefreshError | |
| 1468 | + ? { error: failedSourceError ?? registryState?.lastRefreshError } | |
| 1469 | + : {}), | |
| 1126 | 1470 | updatedAt: registryState?.updatedAt, |
| 1127 | 1471 | }; |
| 1128 | 1472 | |
| 1129 | 1473 | const sourceTokens = Object.entries(sourceStates) |
| 1130 | 1474 | .sort(([left], [right]) => left.localeCompare(right)) |
| 1131 | − .map(([name, item]) => `${name}:${item.state}:${item.toolCount}:${item.changed ? 1 : 0}`) | |
| 1475 | + .map(([name, item]) => `${name}:${item.state}:${item.toolCount}:${item.processedTools ?? 0}:${item.error ?? ""}`) | |
| 1132 | 1476 | .join(","); |
| 1133 | 1477 | |
| 1134 | 1478 | const reactiveKey = [ |
| 1135 | 1479 | inventoryStatus.state, |
| 1136 | − inventoryStatus.readyBuildId ?? "", | |
| 1137 | − inventoryStatus.buildingBuildId ?? "", | |
| 1138 | 1480 | String(inventoryStatus.updatedAt ?? 0), |
| 1139 | 1481 | sourceTokens, |
| 1140 | 1482 | ].join("|"); |
| @@ -1162,14 +1504,14 @@ export async function getWorkspaceInventoryProgressForContext( · 21 unmodified lines | ||
| 1162 | 1504 | |
| 1163 | 1505 | const progress = computeWorkspaceInventoryProgress( |
| 1164 | 1506 | workspaceId, |
| 1165 | − sources.filter((source) => source.enabled), | |
| 1507 | + sources.filter((source: ToolSourceRecord) => source.enabled), | |
| 1166 | 1508 | registryState, |
| 1167 | 1509 | ); |
| 1168 | 1510 | |
| 1169 | 1511 | // Append a lightweight policy fingerprint so the reactive key changes |
| 1170 | 1512 | // whenever any policy is created, updated, or deleted. |
| 1171 | 1513 | const policyToken = policies |
| 1172 | − .map((p) => `${p.id}:${p.effect}:${p.approvalMode}:${p.resourcePattern}:${p.priority}`) | |
| 1514 | + .map((p: ToolPolicyRecord) => `${p.id}:${p.effect}:${p.approvalMode}:${p.resourcePattern}:${p.priority}`) | |
| 1173 | 1515 | .sort() |
| 1174 | 1516 | .join(","); |
| 1175 | 1517 | |
| @@ -1188,26 +1530,30 @@ async function getWorkspaceToolsFromRegistry( · 12 unmodified lines | ||
| 1188 | 1530 | sourceName?: string; |
| 1189 | 1531 | cursor?: string; |
| 1190 | 1532 | limit?: number; |
| 1191 | − buildId?: string; | |
| 1192 | 1533 | fetchAll?: boolean; |
| 1193 | 1534 | } = {}, |
| 1194 | 1535 | ): Promise<WorkspaceRegistryReadResult> { |
| 1195 | 1536 | const sources = (await listWorkspaceToolSources(ctx, workspaceId)) |
| 1196 | − .filter((source) => source.enabled); | |
| 1537 | + .filter((source: ToolSourceRecord) => source.enabled); | |
| 1197 | 1538 | |
| 1198 | 1539 | const registryState = await ctx.runQuery(internal.toolRegistry.getState, { |
| 1199 | 1540 | workspaceId, |
| 1200 | 1541 | }); |
| 1201 | 1542 | |
| 1202 | 1543 | const progress = computeWorkspaceInventoryProgress(workspaceId, sources, registryState); |
| 1203 | − const readyBuildId = progress.inventoryStatus.readyBuildId; | |
| 1204 | − const activeBuildId = options.buildId ?? readyBuildId; | |
| 1205 | 1544 | const sourceCounts = progress.inventoryStatus.sourceToolCounts; |
| 1206 | 1545 | const sourceQuality = toSourceQualityRecord(registryState?.sourceQuality ?? []); |
| 1207 | 1546 | const sourceAuthProfiles = toSourceAuthProfileRecord(registryState?.sourceAuthProfiles ?? []); |
| 1208 | 1547 | const openApiRefHintLookup = toOpenApiRefHintLookup(registryState?.openApiRefHintTables ?? []); |
| 1209 | 1548 | const warnings = [...progress.warnings]; |
| 1210 | 1549 | const scopedSourceName = options.sourceName ?? toSourceName(options.source) ?? undefined; |
| 1550 | + const sourceKeyFilter = options.source?.trim() | |
| 1551 | + || (scopedSourceName | |
| 1552 | + ? (() => { | |
| 1553 | + const source = sources.find((item) => item.name === scopedSourceName); | |
| 1554 | + return source ? sourceStateSourceKey(source) : undefined; | |
| 1555 | + })() | |
| 1556 | + : undefined); | |
| 1211 | 1557 | const allSourceNames = sources.map((source) => source.name); |
| 1212 | 1558 | const relevantSourceNames = scopedSourceName |
| 1213 | 1559 | ? allSourceNames.filter((name) => name === scopedSourceName) |
| @@ -1227,21 +1573,6 @@ async function getWorkspaceToolsFromRegistry( · 13 unmodified lines | ||
| 1227 | 1573 | ? (sourceCounts[scopedSourceName] ?? 0) |
| 1228 | 1574 | : inventoryStatus.readyToolCount; |
| 1229 | 1575 | |
| 1230 | − if (!activeBuildId) { | |
| 1231 | − return { | |
| 1232 | − sources, | |
| 1233 | − registryTools: [], | |
| 1234 | − warnings, | |
| 1235 | − typesStorageId: registryState?.typesStorageId, | |
| 1236 | − inventoryStatus, | |
| 1237 | − nextCursor: null, | |
| 1238 | − totalTools, | |
| 1239 | − sourceQuality, | |
| 1240 | − sourceAuthProfiles, | |
| 1241 | − openApiRefHintLookup, | |
| 1242 | − }; | |
| 1243 | − } | |
| 1244 | − | |
| 1245 | 1576 | const registryTools: RegistryToolEntry[] = []; |
| 1246 | 1577 | const requestedPaths = [...new Set((options.toolPaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0))]; |
| 1247 | 1578 | let nextCursor: string | null | undefined; |
| @@ -1250,7 +1581,6 @@ async function getWorkspaceToolsFromRegistry( · 2 unmodified lines | ||
| 1250 | 1581 | const entries = await Promise.all(requestedPaths.map(async (path) => { |
| 1251 | 1582 | const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, { |
| 1252 | 1583 | workspaceId, |
| 1253 | − buildId: activeBuildId, | |
| 1254 | 1584 | path, |
| 1255 | 1585 | }); |
| 1256 | 1586 | if (!entry) return null; |
| @@ -1277,20 +1607,18 @@ async function getWorkspaceToolsFromRegistry( · 20 unmodified lines | ||
| 1277 | 1607 | const page: { |
| 1278 | 1608 | continueCursor: string | null; |
| 1279 | 1609 | items: RegistryToolEntry[]; |
| 1280 | − } = options.source | |
| 1281 | − ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { | |
| 1282 | − workspaceId, | |
| 1283 | − buildId: activeBuildId, | |
| 1284 | − source: options.source, | |
| 1285 | − cursor, | |
| 1286 | − limit: 100, | |
| 1287 | − }) | |
| 1288 | − : await ctx.runQuery(internal.toolRegistry.listToolsPage, { | |
| 1289 | − workspaceId, | |
| 1290 | − buildId: activeBuildId, | |
| 1291 | − cursor, | |
| 1292 | − limit: 100, | |
| 1293 | − }); | |
| 1610 | + } = sourceKeyFilter | |
| 1611 | + ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { | |
| 1612 | + workspaceId, | |
| 1613 | + source: sourceKeyFilter, | |
| 1614 | + cursor, | |
| 1615 | + limit: 100, | |
| 1616 | + }) | |
| 1617 | + : await ctx.runQuery(internal.toolRegistry.listToolsPage, { | |
| 1618 | + workspaceId, | |
| 1619 | + cursor, | |
| 1620 | + limit: 100, | |
| 1621 | + }); | |
| 1294 | 1622 | for (const entry of page.items) { |
| 1295 | 1623 | registryTools.push(entry); |
| 1296 | 1624 | } |
| @@ -1304,20 +1632,18 @@ async function getWorkspaceToolsFromRegistry( · 7 unmodified lines | ||
| 1304 | 1632 | const page: { |
| 1305 | 1633 | continueCursor: string | null; |
| 1306 | 1634 | items: RegistryToolEntry[]; |
| 1307 | − } = options.source | |
| 1308 | − ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { | |
| 1309 | − workspaceId, | |
| 1310 | − buildId: activeBuildId, | |
| 1311 | − source: options.source, | |
| 1312 | − cursor: options.cursor, | |
| 1313 | − limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))), | |
| 1314 | − }) | |
| 1315 | − : await ctx.runQuery(internal.toolRegistry.listToolsPage, { | |
| 1316 | − workspaceId, | |
| 1317 | − buildId: activeBuildId, | |
| 1318 | − cursor: options.cursor, | |
| 1319 | − limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))), | |
| 1320 | − }); | |
| 1635 | + } = sourceKeyFilter | |
| 1636 | + ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { | |
| 1637 | + workspaceId, | |
| 1638 | + source: sourceKeyFilter, | |
| 1639 | + cursor: options.cursor, | |
| 1640 | + limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))), | |
| 1641 | + }) | |
| 1642 | + : await ctx.runQuery(internal.toolRegistry.listToolsPage, { | |
| 1643 | + workspaceId, | |
| 1644 | + cursor: options.cursor, | |
| 1645 | + limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))), | |
| 1646 | + }); | |
| 1321 | 1647 | registryTools.push(...page.items); |
| 1322 | 1648 | nextCursor = page.continueCursor; |
| 1323 | 1649 | } |
| @@ -1348,7 +1674,6 @@ async function loadWorkspaceToolInventoryForContext( · 24 unmodified lines | ||
| 1348 | 1674 | sourceName?: string; |
| 1349 | 1675 | cursor?: string; |
| 1350 | 1676 | limit?: number; |
| 1351 | − buildId?: string; | |
| 1352 | 1677 | fetchAll?: boolean; |
| 1353 | 1678 | } = {}, |
| 1354 | 1679 | ): Promise<WorkspaceToolInventory> { |
| @@ -1366,7 +1691,6 @@ async function loadWorkspaceToolInventoryForContext( · 11 unmodified lines | ||
| 1366 | 1691 | sourceName: options.sourceName, |
| 1367 | 1692 | cursor: options.cursor, |
| 1368 | 1693 | limit: options.limit, |
| 1369 | − buildId: options.buildId, | |
| 1370 | 1694 | fetchAll: options.fetchAll, |
| 1371 | 1695 | }), |
| 1372 | 1696 | listWorkspaceToolPolicies(ctx, context.workspaceId, context.accountId), |
| @@ -1385,8 +1709,8 @@ async function loadWorkspaceToolInventoryForContext( · 12 unmodified lines | ||
| 1385 | 1709 | const includeBaseTools = options.source || options.sourceName |
| 1386 | 1710 | ? false |
| 1387 | 1711 | : boundedRequestedPaths.length > 0 |
| 1388 | − ? true | |
| 1389 | − : Boolean(options.fetchAll || !options.cursor); | |
| 1712 | + ? true | |
| 1713 | + : Boolean(options.fetchAll || !options.cursor); | |
| 1390 | 1714 | const baseDescriptors = includeBaseTools |
| 1391 | 1715 | ? listVisibleToolDescriptors(baseTools, context, policies, { |
| 1392 | 1716 | includeDetails, |
| @@ -1443,7 +1767,6 @@ export async function listToolsForContext( · 50 unmodified lines | ||
| 1443 | 1767 | sourceName?: string; |
| 1444 | 1768 | cursor?: string; |
| 1445 | 1769 | limit?: number; |
| 1446 | − buildId?: string; | |
| 1447 | 1770 | } = {}, |
| 1448 | 1771 | ): Promise<ToolDescriptor[]> { |
| 1449 | 1772 | const inventory = await loadWorkspaceToolInventoryForContext(ctx, context, { |
| @@ -1494,17 +1817,11 @@ export async function listToolDetailsForContext( · 44 unmodified lines | ||
| 1494 | 1817 | } |
| 1495 | 1818 | } |
| 1496 | 1819 | |
| 1497 | − const readyBuildId = registryState?.readyBuildId; | |
| 1498 | − if (!readyBuildId) { | |
| 1499 | − return result; | |
| 1500 | − } | |
| 1501 | − | |
| 1502 | 1820 | const openApiRefHintLookup = toOpenApiRefHintLookup(registryState?.openApiRefHintTables ?? []); |
| 1503 | 1821 | const registryPaths = boundedRequestedPaths.filter((path) => !baseTools.has(path)); |
| 1504 | 1822 | const entries = await Promise.all(registryPaths.map((path) => |
| 1505 | 1823 | ctx.runQuery(internal.toolRegistry.getToolByPath, { |
| 1506 | 1824 | workspaceId: context.workspaceId, |
| 1507 | − buildId: readyBuildId, | |
| 1508 | 1825 | path, |
| 1509 | 1826 | }) |
| 1510 | 1827 | )); |
| @@ -1550,49 +1867,17 @@ export async function rebuildWorkspaceToolInventoryForContext( · 39 unmodified lines | ||
| 1550 | 1867 | ctx: ActionCtx, |
| 1551 | 1868 | context: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string }, |
| 1552 | 1869 | ): Promise<{ rebuilt: boolean }> { |
| 1553 | − const [sources, state] = await Promise.all([ | |
| 1554 | − listWorkspaceToolSources(ctx, context.workspaceId), | |
| 1555 | − ctx.runQuery(internal.toolRegistry.getState, { | |
| 1556 | − workspaceId: context.workspaceId, | |
| 1557 | − }), | |
| 1558 | − ]); | |
| 1559 | − | |
| 1560 | − const registrySignature = registrySignatureForWorkspace( | |
| 1561 | − context.workspaceId, | |
| 1562 | − sources.filter((source) => source.enabled), | |
| 1563 | − ); | |
| 1564 | − | |
| 1565 | − const alreadyBuildingTarget = Boolean( | |
| 1566 | − state?.buildingBuildId | |
| 1567 | − && state?.buildingSignature === registrySignature, | |
| 1568 | − ); | |
| 1569 | − const buildingStartedAt = typeof state?.buildingStartedAt === "number" | |
| 1570 | − ? state.buildingStartedAt | |
| 1571 | − : undefined; | |
| 1572 | − const staleBuildingTarget = Boolean( | |
| 1573 | − alreadyBuildingTarget | |
| 1574 | − && state?.buildingBuildId | |
| 1575 | − && buildingStartedAt | |
| 1576 | − && Date.now() - buildingStartedAt > REGISTRY_BUILD_STALE_MS, | |
| 1577 | − ); | |
| 1578 | − | |
| 1579 | − if (alreadyBuildingTarget && !staleBuildingTarget) { | |
| 1580 | − return { rebuilt: false }; | |
| 1581 | − } | |
| 1582 | − | |
| 1583 | − if (staleBuildingTarget && state?.buildingBuildId) { | |
| 1584 | − await ctx.runMutation(internal.toolRegistry.failBuild, { | |
| 1870 | + try { | |
| 1871 | + await refreshWorkspaceToolRegistryIncremental(ctx, context); | |
| 1872 | + } catch (error) { | |
| 1873 | + const message = error instanceof Error ? error.message : String(error); | |
| 1874 | + await ctx.runMutation(internal.toolRegistry.setRefreshError, { | |
| 1585 | 1875 | workspaceId: context.workspaceId, |
| 1586 | − buildId: state.buildingBuildId, | |
| 1587 | − error: "Tool inventory build timed out; retrying", | |
| 1876 | + error: message, | |
| 1588 | 1877 | }); |
| 1878 | + throw error; | |
| 1589 | 1879 | } |
| 1590 | 1880 | |
| 1591 | − await getWorkspaceTools(ctx, context.workspaceId, { | |
| 1592 | − accountId: context.accountId, | |
| 1593 | − includeDts: false, | |
| 1594 | − }); | |
| 1595 | − | |
| 1596 | 1881 | return { rebuilt: true }; |
| 1597 | 1882 | } |
| 1598 | 1883 | |
| @@ -1607,7 +1892,6 @@ export async function listToolsWithWarningsForContext( · 8 unmodified lines | ||
| 1607 | 1892 | sourceName?: string; |
| 1608 | 1893 | cursor?: string; |
| 1609 | 1894 | limit?: number; |
| 1610 | − buildId?: string; | |
| 1611 | 1895 | fetchAll?: boolean; |
| 1612 | 1896 | } = {}, |
| 1613 | 1897 | ): Promise<{ |
| @@ -1,146 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bun | |
| 2 | − | |
| 3 | −import { ConvexHttpClient } from "convex/browser"; | |
| 4 | −import { api } from "../../../packages/database/convex/_generated/api"; | |
| 5 | − | |
| 6 | −type RuntimeId = "local-bun" | "cloudflare-worker-loader"; | |
| 7 | − | |
| 8 | −function requireConvexUrl(): string { | |
| 9 | − const value = process.env.CONVEX_URL ?? process.env.CONVEX_SITE_URL; | |
| 10 | − if (!value) { | |
| 11 | − throw new Error("Missing CONVEX_URL or CONVEX_SITE_URL"); | |
| 12 | − } | |
| 13 | − | |
| 14 | − return value; | |
| 15 | −} | |
| 16 | − | |
| 17 | −function buildProbeCode(): string { | |
| 18 | − return [ | |
| 19 | − "const out = {};", | |
| 20 | − "const summarize = (value) => {", | |
| 21 | − " if (value === undefined) return { type: 'undefined' };", | |
| 22 | − " if (value === null) return { type: 'null' };", | |
| 23 | − " const t = typeof value;", | |
| 24 | − " if (t === 'string') return { type: 'string', length: value.length, prefix: value.slice(0, 8) };", | |
| 25 | − " if (t === 'number' || t === 'boolean' || t === 'bigint') return { type: t, value };", | |
| 26 | − " if (t === 'function') return { type: 'function', name: value.name || '<anonymous>' };", | |
| 27 | − " if (t === 'object') {", | |
| 28 | − " try { return { type: 'object', keys: Object.keys(value).slice(0, 30) }; } catch { return { type: 'object' }; }", | |
| 29 | − " }", | |
| 30 | − " return { type: t };", | |
| 31 | − "};", | |
| 32 | − "const tokenKeys = ['CLOUDFLARE_SANDBOX_AUTH_TOKEN', 'EXECUTOR_INTERNAL_TOKEN', 'AUTH_TOKEN', 'CLOUDFLARE_SANDBOX_RUN_URL', 'CONVEX_URL'];", | |
| 33 | − "let g;", | |
| 34 | − "try { g = Function('return globalThis')(); } catch (error) { out.globalThisError = String(error); }", | |
| 35 | − "out.globalThis = summarize(g);", | |
| 36 | − "out.globalKeys = g ? Object.getOwnPropertyNames(g).slice(0, 80) : [];", | |
| 37 | − "let processRef;", | |
| 38 | − "try { processRef = Function(\"return typeof process !== 'undefined' ? process : undefined\")(); } catch (error) { out.processEvalError = String(error); }", | |
| 39 | − "out.process = summarize(processRef);", | |
| 40 | − "out.processEnv = summarize(processRef?.env);", | |
| 41 | − "const processEnvProbe = {};", | |
| 42 | − "for (const key of tokenKeys) {", | |
| 43 | − " let value;", | |
| 44 | − " try { value = processRef?.env?.[key]; } catch (error) { value = `<error:${String(error)}>`; }", | |
| 45 | − " processEnvProbe[key] = summarize(value);", | |
| 46 | − "}", | |
| 47 | − "out.processEnvProbe = processEnvProbe;", | |
| 48 | − "let bunRef;", | |
| 49 | − "try { bunRef = Function(\"return typeof Bun !== 'undefined' ? Bun : undefined\")(); } catch (error) { out.bunEvalError = String(error); }", | |
| 50 | − "out.Bun = summarize(bunRef);", | |
| 51 | − "let denoRef;", | |
| 52 | − "try { denoRef = Function(\"return typeof Deno !== 'undefined' ? Deno : undefined\")(); } catch (error) { out.denoEvalError = String(error); }", | |
| 53 | − "out.Deno = summarize(denoRef);", | |
| 54 | − "try {", | |
| 55 | − " const src = tools.discover?.toString?.() ?? '<missing>';", | |
| 56 | − " out.discoverToString = src.slice(0, 220);", | |
| 57 | − "} catch (error) {", | |
| 58 | − " out.discoverToStringError = String(error);", | |
| 59 | − "}", | |
| 60 | − "return out;", | |
| 61 | − ].join("\n"); | |
| 62 | −} | |
| 63 | − | |
| 64 | −function extractSuspiciousKeys(result: unknown): string[] { | |
| 65 | − const rec = (result && typeof result === "object") ? result as Record<string, unknown> : {}; | |
| 66 | − const probe = (rec.processEnvProbe && typeof rec.processEnvProbe === "object") | |
| 67 | − ? rec.processEnvProbe as Record<string, unknown> | |
| 68 | − : {}; | |
| 69 | − | |
| 70 | − const leaked: string[] = []; | |
| 71 | − for (const [key, value] of Object.entries(probe)) { | |
| 72 | − const summary = (value && typeof value === "object") ? value as Record<string, unknown> : {}; | |
| 73 | − const type = summary.type; | |
| 74 | − const length = summary.length; | |
| 75 | − if (type === "string" && typeof length === "number" && length > 0) { | |
| 76 | − leaked.push(key); | |
| 77 | − } | |
| 78 | − } | |
| 79 | − | |
| 80 | − return leaked; | |
| 81 | −} | |
| 82 | − | |
| 83 | −async function runForRuntime( | |
| 84 | − client: ConvexHttpClient, | |
| 85 | − workspaceId: string, | |
| 86 | − sessionId: string, | |
| 87 | − runtimeId: RuntimeId, | |
| 88 | − code: string, | |
| 89 | −) { | |
| 90 | − const response = await client.action(api.executor.createTask, { | |
| 91 | − workspaceId: workspaceId as never, | |
| 92 | − sessionId, | |
| 93 | − runtimeId, | |
| 94 | − waitForResult: true, | |
| 95 | − timeoutMs: 120_000, | |
| 96 | − code, | |
| 97 | − metadata: { | |
| 98 | − purpose: "security-token-probe", | |
| 99 | − runtimeId, | |
| 100 | − }, | |
| 101 | − }) as { | |
| 102 | − task: { id: string; status: string; error?: string }; | |
| 103 | − result?: unknown; | |
| 104 | − }; | |
| 105 | − | |
| 106 | − return response; | |
| 107 | −} | |
| 108 | − | |
| 109 | −async function main() { | |
| 110 | − const client = new ConvexHttpClient(requireConvexUrl(), { | |
| 111 | − skipConvexDeploymentUrlCheck: true, | |
| 112 | − }); | |
| 113 | − | |
| 114 | − const sessionId = `mcp_token_probe_${crypto.randomUUID().slice(0, 8)}`; | |
| 115 | − const bootstrap = await client.mutation(api.workspace.bootstrapAnonymousSession, { sessionId }) as { | |
| 116 | − sessionId: string; | |
| 117 | − }; | |
| 118 | − const created = await client.mutation(api.organizations.create, { | |
| 119 | − sessionId: bootstrap.sessionId, | |
| 120 | − name: `Token Probe Org ${crypto.randomUUID().slice(0, 8)}`, | |
| 121 | − }) as { | |
| 122 | − workspace: { id: string }; | |
| 123 | − }; | |
| 124 | − const workspaceId = created.workspace.id; | |
| 125 | − const code = buildProbeCode(); | |
| 126 | − | |
| 127 | − for (const runtimeId of ["local-bun", "cloudflare-worker-loader"] as const) { | |
| 128 | − try { | |
| 129 | − const response = await runForRuntime(client, workspaceId, sessionId, runtimeId, code); | |
| 130 | − const leaked = extractSuspiciousKeys(response.result); | |
| 131 | − | |
| 132 | − console.log(`\n=== ${runtimeId} ===`); | |
| 133 | − console.log(`taskStatus=${response.task.status}`); | |
| 134 | − if (response.task.error) { | |
| 135 | − console.log(`taskError=${response.task.error}`); | |
| 136 | − } | |
| 137 | − console.log(`leakedKeys=${leaked.length > 0 ? leaked.join(",") : "none"}`); | |
| 138 | − console.log(JSON.stringify(response.result ?? null, null, 2)); | |
| 139 | − } catch (error) { | |
| 140 | − console.log(`\n=== ${runtimeId} FAILED ===`); | |
| 141 | − console.log(error instanceof Error ? error.message : String(error)); | |
| 142 | − } | |
| 143 | − } | |
| 144 | −} | |
| 145 | − | |
| 146 | −await main(); | |
| @@ -1,390 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bun | |
| 2 | − | |
| 3 | −import assert from "node:assert/strict"; | |
| 4 | −import { ConvexHttpClient } from "convex/browser"; | |
| 5 | −import { $ } from "bun"; | |
| 6 | −import { api } from "../../../packages/database/convex/_generated/api"; | |
| 7 | − | |
| 8 | −type RuntimeId = "local-bun" | "cloudflare-worker-loader"; | |
| 9 | −const EXECUTOR_ROOT = new URL("../../../", import.meta.url).pathname; | |
| 10 | −const DATABASE_ROOT = new URL("../../../packages/database/", import.meta.url).pathname; | |
| 11 | − | |
| 12 | −function parseArgs(argv: string[]): { | |
| 13 | − runtimeId?: RuntimeId; | |
| 14 | − timeoutMs: number; | |
| 15 | − deployWorker: boolean; | |
| 16 | − deployConvex: boolean; | |
| 17 | −} { | |
| 18 | − let runtimeId: RuntimeId | undefined; | |
| 19 | − let timeoutMs = 120_000; | |
| 20 | − let deployWorker = false; | |
| 21 | − let deployConvex = false; | |
| 22 | − | |
| 23 | − for (let index = 0; index < argv.length; index += 1) { | |
| 24 | − const arg = argv[index]; | |
| 25 | − if (arg === "--runtime") { | |
| 26 | − const value = argv[index + 1]; | |
| 27 | − if (value !== "local-bun" && value !== "cloudflare-worker-loader") { | |
| 28 | − throw new Error("--runtime must be one of: local-bun, cloudflare-worker-loader"); | |
| 29 | − } | |
| 30 | − runtimeId = value; | |
| 31 | − index += 1; | |
| 32 | − continue; | |
| 33 | − } | |
| 34 | − | |
| 35 | − if (arg === "--timeout-ms") { | |
| 36 | − const value = Number.parseInt(argv[index + 1] ?? "", 10); | |
| 37 | − if (!Number.isFinite(value) || value <= 0) { | |
| 38 | − throw new Error("--timeout-ms must be a positive integer"); | |
| 39 | − } | |
| 40 | − timeoutMs = value; | |
| 41 | − index += 1; | |
| 42 | − continue; | |
| 43 | − } | |
| 44 | − | |
| 45 | − if (arg === "--deploy-worker") { | |
| 46 | − deployWorker = true; | |
| 47 | − continue; | |
| 48 | − } | |
| 49 | − | |
| 50 | − if (arg === "--deploy-convex") { | |
| 51 | − deployConvex = true; | |
| 52 | − continue; | |
| 53 | − } | |
| 54 | − | |
| 55 | − throw new Error(`Unknown argument: ${arg}`); | |
| 56 | − } | |
| 57 | − | |
| 58 | − return { runtimeId, timeoutMs, deployWorker, deployConvex }; | |
| 59 | −} | |
| 60 | − | |
| 61 | −function requireEnv(name: string): string { | |
| 62 | − const value = process.env[name]?.trim(); | |
| 63 | − if (!value) { | |
| 64 | − throw new Error(`Missing required environment variable: ${name}`); | |
| 65 | − } | |
| 66 | − return value; | |
| 67 | −} | |
| 68 | − | |
| 69 | −function makeClient(convexUrl: string): ConvexHttpClient { | |
| 70 | − return new ConvexHttpClient(convexUrl, { | |
| 71 | − skipConvexDeploymentUrlCheck: true, | |
| 72 | − }); | |
| 73 | −} | |
| 74 | − | |
| 75 | −function sleep(ms: number): Promise<void> { | |
| 76 | − return new Promise((resolve) => setTimeout(resolve, ms)); | |
| 77 | −} | |
| 78 | − | |
| 79 | −function parseWorkersDevUrl(output: string): string | null { | |
| 80 | − const match = output.match(/https:\/\/[\w.-]+\.workers\.dev/); | |
| 81 | − return match ? match[0] : null; | |
| 82 | −} | |
| 83 | − | |
| 84 | −function isHostedConvexUrl(url: string): boolean { | |
| 85 | − try { | |
| 86 | − const hostname = new URL(url).hostname.trim().toLowerCase(); | |
| 87 | − return hostname.endsWith(".convex.cloud") || hostname.endsWith(".convex.site"); | |
| 88 | − } catch { | |
| 89 | − return false; | |
| 90 | − } | |
| 91 | −} | |
| 92 | − | |
| 93 | −async function deployCloudflareWorkerAndGetUrl(): Promise<string> { | |
| 94 | − console.log("[storage:smoke:execute-flow] deploying runner-sandbox-host..."); | |
| 95 | − const output = await $`bun run --cwd packages/runner-sandbox-host deploy` | |
| 96 | − .cwd(EXECUTOR_ROOT) | |
| 97 | − .env(process.env) | |
| 98 | − .text(); | |
| 99 | − console.log(output.trim()); | |
| 100 | − | |
| 101 | − const url = parseWorkersDevUrl(output); | |
| 102 | − if (!url) { | |
| 103 | − throw new Error("Could not parse workers.dev URL from runner-sandbox-host deploy output"); | |
| 104 | − } | |
| 105 | − | |
| 106 | − return url; | |
| 107 | −} | |
| 108 | − | |
| 109 | −async function setCloudflareWorkerAuthSecret(token: string): Promise<void> { | |
| 110 | − console.log("[storage:smoke:execute-flow] updating runner-sandbox-host AUTH_TOKEN secret..."); | |
| 111 | − await $`printf "%s" ${token} | bun run --cwd packages/runner-sandbox-host wrangler secret put AUTH_TOKEN` | |
| 112 | − .cwd(EXECUTOR_ROOT) | |
| 113 | − .env(process.env) | |
| 114 | − .quiet(false); | |
| 115 | −} | |
| 116 | − | |
| 117 | −async function maybeDeployConvex(enabled: boolean): Promise<void> { | |
| 118 | − if (!enabled) { | |
| 119 | − return; | |
| 120 | − } | |
| 121 | − | |
| 122 | − console.log("[storage:smoke:execute-flow] deploying Convex functions..."); | |
| 123 | − await $`bunx --package convex convex dev --once --typecheck disable` | |
| 124 | − .cwd(DATABASE_ROOT) | |
| 125 | − .env(process.env) | |
| 126 | − .quiet(false); | |
| 127 | −} | |
| 128 | − | |
| 129 | −async function setConvexEnvVar(key: string, value: string): Promise<void> { | |
| 130 | − await $`bunx --package convex convex env set ${key} ${value}` | |
| 131 | − .cwd(DATABASE_ROOT) | |
| 132 | − .env(process.env) | |
| 133 | − .quiet(false); | |
| 134 | −} | |
| 135 | − | |
| 136 | −async function waitForSourceReady(args: { | |
| 137 | − client: ConvexHttpClient; | |
| 138 | − workspaceId: string; | |
| 139 | − sessionId: string; | |
| 140 | − sourceName: string; | |
| 141 | − timeoutMs: number; | |
| 142 | −}): Promise<void> { | |
| 143 | − const started = Date.now(); | |
| 144 | − | |
| 145 | − while (Date.now() - started < args.timeoutMs) { | |
| 146 | − const progress = await args.client.query(api.workspace.getToolInventoryProgress, { | |
| 147 | − workspaceId: args.workspaceId as never, | |
| 148 | − sessionId: args.sessionId, | |
| 149 | − }) as { | |
| 150 | − inventoryStatus?: { state?: string; error?: string }; | |
| 151 | − sourceStates?: Record<string, { state?: string; toolCount?: number }>; | |
| 152 | − warnings?: string[]; | |
| 153 | − }; | |
| 154 | − | |
| 155 | − const source = progress.sourceStates?.[args.sourceName]; | |
| 156 | − const inventoryState = progress.inventoryStatus?.state ?? "unknown"; | |
| 157 | − const sourceState = source?.state ?? "unknown"; | |
| 158 | − const sourceCount = source?.toolCount ?? 0; | |
| 159 | − | |
| 160 | − if (inventoryState === "failed") { | |
| 161 | − throw new Error( | |
| 162 | − `Tool inventory failed for source '${args.sourceName}': ${progress.inventoryStatus?.error ?? "unknown error"}`, | |
| 163 | − ); | |
| 164 | − } | |
| 165 | − | |
| 166 | − if (sourceState === "ready" && sourceCount > 0) { | |
| 167 | − return; | |
| 168 | − } | |
| 169 | − | |
| 170 | − await sleep(1_000); | |
| 171 | − } | |
| 172 | − | |
| 173 | − throw new Error(`Timed out waiting for source '${args.sourceName}' to become ready`); | |
| 174 | −} | |
| 175 | − | |
| 176 | −async function run(): Promise<void> { | |
| 177 | − const options = parseArgs(process.argv.slice(2)); | |
| 178 | − const convexUrl = requireEnv("CONVEX_URL"); | |
| 179 | − const client = makeClient(convexUrl); | |
| 180 | − | |
| 181 | − const requestedRuntime: RuntimeId = options.runtimeId | |
| 182 | − ?? (process.env.CLOUDFLARE_SANDBOX_RUN_URL?.trim() ? "cloudflare-worker-loader" : "local-bun"); | |
| 183 | − | |
| 184 | − if (requestedRuntime === "local-bun" && isHostedConvexUrl(convexUrl)) { | |
| 185 | − throw new Error( | |
| 186 | − "local-bun storage smoke is self-host only. Hosted Convex deployments must use AGENT_STORAGE_PROVIDER=agentfs-cloudflare and the cloudflare-worker-loader runtime.", | |
| 187 | − ); | |
| 188 | − } | |
| 189 | − | |
| 190 | − await maybeDeployConvex(options.deployConvex); | |
| 191 | − | |
| 192 | − if (requestedRuntime === "cloudflare-worker-loader") { | |
| 193 | − let sandboxRunUrl = process.env.CLOUDFLARE_SANDBOX_RUN_URL?.trim(); | |
| 194 | − let sandboxAuthToken = process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN?.trim(); | |
| 195 | − | |
| 196 | − if (!sandboxAuthToken || options.deployWorker) { | |
| 197 | − sandboxAuthToken = `smoke_${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`; | |
| 198 | − await setCloudflareWorkerAuthSecret(sandboxAuthToken); | |
| 199 | − } | |
| 200 | − | |
| 201 | − if (!sandboxRunUrl || options.deployWorker) { | |
| 202 | − const workerUrl = await deployCloudflareWorkerAndGetUrl(); | |
| 203 | − sandboxRunUrl = `${workerUrl}/v1/runs`; | |
| 204 | − } | |
| 205 | − | |
| 206 | − if (!sandboxRunUrl || !sandboxAuthToken) { | |
| 207 | − throw new Error("Unable to resolve Cloudflare sandbox URL/token"); | |
| 208 | − } | |
| 209 | − | |
| 210 | − process.env.CLOUDFLARE_SANDBOX_RUN_URL = sandboxRunUrl; | |
| 211 | − process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = sandboxAuthToken; | |
| 212 | − process.env.AGENT_STORAGE_PROVIDER = "agentfs-cloudflare"; | |
| 213 | − | |
| 214 | − console.log("[storage:smoke:execute-flow] syncing Convex dev env vars for Cloudflare runtime/storage..."); | |
| 215 | − await setConvexEnvVar("CLOUDFLARE_SANDBOX_RUN_URL", sandboxRunUrl); | |
| 216 | − await setConvexEnvVar("CLOUDFLARE_SANDBOX_AUTH_TOKEN", sandboxAuthToken); | |
| 217 | − await setConvexEnvVar("AGENT_STORAGE_PROVIDER", "agentfs-cloudflare"); | |
| 218 | − } else { | |
| 219 | − process.env.AGENT_STORAGE_PROVIDER = "agentfs-local"; | |
| 220 | − console.log("[storage:smoke:execute-flow] syncing Convex dev env vars for local storage backend..."); | |
| 221 | − await setConvexEnvVar("AGENT_STORAGE_PROVIDER", "agentfs-local"); | |
| 222 | − } | |
| 223 | − | |
| 224 | − const runId = crypto.randomUUID().slice(0, 8); | |
| 225 | − const sessionId = `mcp_storage_e2e_${runId}`; | |
| 226 | − const organizationName = `Storage Smoke Org ${runId}`; | |
| 227 | − const sourceName = `storage-smoke-source-${runId}`; | |
| 228 | − | |
| 229 | − const bootstrap = await client.mutation(api.workspace.bootstrapAnonymousSession, { | |
| 230 | − sessionId, | |
| 231 | − }) as { | |
| 232 | − sessionId: string; | |
| 233 | − accountId: string; | |
| 234 | − workspaceId: string; | |
| 235 | − }; | |
| 236 | − | |
| 237 | − assert.ok(bootstrap.sessionId, "bootstrap must return a sessionId"); | |
| 238 | − | |
| 239 | − const createdOrg = await client.mutation(api.organizations.create, { | |
| 240 | − sessionId: bootstrap.sessionId, | |
| 241 | − name: organizationName, | |
| 242 | − }) as { | |
| 243 | − organization: { id: string; name: string }; | |
| 244 | − workspace: { id: string; name: string }; | |
| 245 | − }; | |
| 246 | − | |
| 247 | − const workspaceId = createdOrg.workspace.id; | |
| 248 | − assert.ok(workspaceId, "organization.create must return a workspace id"); | |
| 249 | − | |
| 250 | − const openapiSpec = { | |
| 251 | − openapi: "3.1.0", | |
| 252 | − info: { | |
| 253 | − title: `Storage Smoke API ${runId}`, | |
| 254 | − version: "1.0.0", | |
| 255 | − }, | |
| 256 | − servers: [ | |
| 257 | − { url: "https://example.com" }, | |
| 258 | − ], | |
| 259 | − paths: { | |
| 260 | − "/ping": { | |
| 261 | − get: { | |
| 262 | − operationId: "smoke_ping", | |
| 263 | − summary: "Ping endpoint", | |
| 264 | − responses: { | |
| 265 | − "200": { | |
| 266 | − description: "ok", | |
| 267 | − content: { | |
| 268 | − "application/json": { | |
| 269 | − schema: { | |
| 270 | − type: "object", | |
| 271 | − properties: { | |
| 272 | − ok: { type: "boolean" }, | |
| 273 | − }, | |
| 274 | − }, | |
| 275 | − }, | |
| 276 | − }, | |
| 277 | − }, | |
| 278 | − }, | |
| 279 | − }, | |
| 280 | − }, | |
| 281 | − }, | |
| 282 | − }; | |
| 283 | − | |
| 284 | − const source = await client.action(api.workspace.upsertToolSource, { | |
| 285 | − workspaceId: workspaceId as never, | |
| 286 | − sessionId: bootstrap.sessionId, | |
| 287 | − name: sourceName, | |
| 288 | − type: "openapi", | |
| 289 | − scopeType: "workspace", | |
| 290 | − config: { | |
| 291 | − spec: openapiSpec, | |
| 292 | − }, | |
| 293 | − }) as { id: string; name: string }; | |
| 294 | − | |
| 295 | − assert.equal(source.name, sourceName, "upsertToolSource should return created source"); | |
| 296 | − | |
| 297 | − await client.mutation(api.workspace.regenerateToolInventory, { | |
| 298 | − workspaceId: workspaceId as never, | |
| 299 | − sessionId: bootstrap.sessionId, | |
| 300 | − }); | |
| 301 | − | |
| 302 | − await waitForSourceReady({ | |
| 303 | − client, | |
| 304 | − workspaceId, | |
| 305 | − sessionId: bootstrap.sessionId, | |
| 306 | − sourceName, | |
| 307 | − timeoutMs: options.timeoutMs, | |
| 308 | − }); | |
| 309 | − | |
| 310 | − const sourceNameLiteral = JSON.stringify(sourceName); | |
| 311 | − const taskCode = [ | |
| 312 | − `const sourceName = ${sourceNameLiteral};`, | |
| 313 | − "const storage = await tools.storage.open({ scopeType: 'scratch', purpose: 'execute-flow-smoke' });", | |
| 314 | − "const instanceId = storage.instance.id;", | |
| 315 | − "await tools.fs.mkdir({ instanceId, path: '/workspace' });", | |
| 316 | − "await tools.fs.write({ instanceId, path: '/workspace/hello.txt', content: 'hello storage execute flow' });", | |
| 317 | − "const file = await tools.fs.read({ instanceId, path: '/workspace/hello.txt' });", | |
| 318 | − "await tools.kv.set({ instanceId, key: 'discord.userId', value: 'uuid-smoke' });", | |
| 319 | − "const kv = await tools.kv.get({ instanceId, key: 'discord.userId' });", | |
| 320 | − "await tools.sqlite.query({ instanceId, mode: 'write', sql: 'CREATE TABLE IF NOT EXISTS smoke (k TEXT PRIMARY KEY, v TEXT)' });", | |
| 321 | − "await tools.sqlite.query({ instanceId, mode: 'write', sql: 'INSERT INTO smoke (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', params: ['discord', 'uuid-smoke'] });", | |
| 322 | − "const sql = await tools.sqlite.query({ instanceId, mode: 'read', sql: 'SELECT v FROM smoke WHERE k = ?', params: ['discord'], maxRows: 1 });", | |
| 323 | − "return {", | |
| 324 | − " instanceId,", | |
| 325 | − " provider: storage.instance.provider,", | |
| 326 | − " fileContent: file.content,", | |
| 327 | − " kvValue: kv.value,", | |
| 328 | − " sqlValue: Array.isArray(sql.rows) && sql.rows.length > 0 ? sql.rows[0].v : null,", | |
| 329 | − "};", | |
| 330 | − ].join("\n"); | |
| 331 | − | |
| 332 | − const taskOutcome = await client.action(api.executor.createTask, { | |
| 333 | − workspaceId: workspaceId as never, | |
| 334 | − sessionId: bootstrap.sessionId, | |
| 335 | − runtimeId: requestedRuntime, | |
| 336 | − waitForResult: true, | |
| 337 | − timeoutMs: Math.max(90_000, options.timeoutMs), | |
| 338 | − code: taskCode, | |
| 339 | − metadata: { | |
| 340 | − purpose: "storage-execute-flow-smoke", | |
| 341 | − runId, | |
| 342 | − }, | |
| 343 | − }) as { | |
| 344 | − task: { id: string; status: string; error?: string }; | |
| 345 | − result?: { | |
| 346 | − instanceId?: string; | |
| 347 | − provider?: string; | |
| 348 | − fileContent?: string; | |
| 349 | − kvValue?: string; | |
| 350 | − sqlValue?: string | null; | |
| 351 | − }; | |
| 352 | − }; | |
| 353 | − | |
| 354 | − if (taskOutcome.task.status !== "completed") { | |
| 355 | − throw new Error(`Task did not complete successfully: ${taskOutcome.task.status} ${taskOutcome.task.error ?? ""}`); | |
| 356 | − } | |
| 357 | − | |
| 358 | − const result = taskOutcome.result ?? {}; | |
| 359 | − assert.equal(result.fileContent, "hello storage execute flow"); | |
| 360 | − assert.equal(result.kvValue, "uuid-smoke"); | |
| 361 | − assert.equal(result.sqlValue, "uuid-smoke"); | |
| 362 | − assert.ok(result.instanceId, "task result must include storage instanceId"); | |
| 363 | − | |
| 364 | − const storageInstances = await client.query(api.workspace.listStorageInstances, { | |
| 365 | − workspaceId: workspaceId as never, | |
| 366 | − sessionId: bootstrap.sessionId, | |
| 367 | − scopeType: "scratch", | |
| 368 | − }) as Array<{ id: string; provider: string; status: string }>; | |
| 369 | − | |
| 370 | − const createdInstance = storageInstances.find((item) => item.id === result.instanceId); | |
| 371 | − assert.ok(createdInstance, "workspace.listStorageInstances should include created scratch instance"); | |
| 372 | − | |
| 373 | − if (result.instanceId) { | |
| 374 | − await client.mutation(api.workspace.deleteStorageInstance, { | |
| 375 | − workspaceId: workspaceId as never, | |
| 376 | − sessionId: bootstrap.sessionId, | |
| 377 | − instanceId: result.instanceId, | |
| 378 | − }); | |
| 379 | − } | |
| 380 | − | |
| 381 | − console.log("[storage:smoke:execute-flow] PASS"); | |
| 382 | − console.log(`runtime=${requestedRuntime}`); | |
| 383 | − console.log(`organization=${createdOrg.organization.id}`); | |
| 384 | − console.log(`workspace=${workspaceId}`); | |
| 385 | − console.log(`source=${source.id}`); | |
| 386 | − console.log(`instance=${result.instanceId}`); | |
| 387 | − console.log(`provider=${createdInstance?.provider ?? result.provider ?? "unknown"}`); | |
| 388 | −} | |
| 389 | − | |
| 390 | −await run(); | |
| @@ -1,23 +0,0 @@ | ||
| 1 | −import { describe, expect, test } from "bun:test"; | |
| 2 | −import { parseArgs, resolveSteps } from "./deploy-prod"; | |
| 3 | − | |
| 4 | −describe("deploy-prod args", () => { | |
| 5 | − test("defaults to plan mode with all steps", () => { | |
| 6 | − const options = parseArgs([]); | |
| 7 | − expect(options.apply).toBe(false); | |
| 8 | − expect(options.yes).toBe(false); | |
| 9 | − expect(options.force).toBe(false); | |
| 10 | − expect(resolveSteps(options)).toEqual(["cloudflare", "env", "convex", "doctor"]); | |
| 11 | − }); | |
| 12 | − | |
| 13 | − test("supports only and skip filters", () => { | |
| 14 | − const options = parseArgs(["--apply", "--only", "cloudflare,doctor", "--skip", "doctor"]); | |
| 15 | − expect(options.apply).toBe(true); | |
| 16 | − expect(resolveSteps(options)).toEqual(["cloudflare"]); | |
| 17 | − }); | |
| 18 | − | |
| 19 | − test("throws on unknown step", () => { | |
| 20 | − expect(() => parseArgs(["--only", "bogus"])) | |
| 21 | − .toThrow("Unknown step 'bogus'"); | |
| 22 | − }); | |
| 23 | −}); | |
| @@ -1,371 +0,0 @@ | ||
| 1 | −import path from "node:path"; | |
| 2 | −import { createInterface } from "node:readline/promises"; | |
| 3 | −import { parseConvexEnvList } from "./doctor-prod"; | |
| 4 | − | |
| 5 | −type StepId = "cloudflare" | "env" | "convex" | "doctor"; | |
| 6 | − | |
| 7 | −interface Options { | |
| 8 | − apply: boolean; | |
| 9 | − yes: boolean; | |
| 10 | − force: boolean; | |
| 11 | − only?: Set<StepId>; | |
| 12 | − skip: Set<StepId>; | |
| 13 | −} | |
| 14 | − | |
| 15 | −interface CommandResult { | |
| 16 | − exitCode: number; | |
| 17 | − stdout: string; | |
| 18 | − stderr: string; | |
| 19 | −} | |
| 20 | − | |
| 21 | −interface PreflightCheck { | |
| 22 | − id: string; | |
| 23 | − ok: boolean; | |
| 24 | − detail: string; | |
| 25 | − hint?: string; | |
| 26 | − blocking: boolean; | |
| 27 | −} | |
| 28 | − | |
| 29 | −interface DeployStep { | |
| 30 | − id: StepId; | |
| 31 | − name: string; | |
| 32 | − command: string[]; | |
| 33 | − cwd?: string; | |
| 34 | −} | |
| 35 | − | |
| 36 | −const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", ".."); | |
| 37 | −const RUNNER_HOST_DIR = path.join(EXECUTOR_ROOT, "packages", "runner-sandbox-host"); | |
| 38 | − | |
| 39 | −const ALL_STEPS: StepId[] = ["cloudflare", "env", "convex", "doctor"]; | |
| 40 | −const REQUIRED_ENV_SETUP_KEYS = [ | |
| 41 | − "WORKOS_CLIENT_ID", | |
| 42 | − "WORKOS_API_KEY", | |
| 43 | − "WORKOS_WEBHOOK_SECRET", | |
| 44 | − "WORKOS_COOKIE_PASSWORD", | |
| 45 | − "STRIPE_SECRET_KEY", | |
| 46 | − "STRIPE_WEBHOOK_SECRET", | |
| 47 | − "STRIPE_PRICE_ID", | |
| 48 | − "MCP_AUTHORIZATION_SERVER", | |
| 49 | −] as const; | |
| 50 | − | |
| 51 | −function printHelp(): void { | |
| 52 | − console.log(`Guided production deploy for executor | |
| 53 | − | |
| 54 | −Usage: | |
| 55 | − bun run scripts/prod/deploy-prod.ts [options] | |
| 56 | − | |
| 57 | −Options: | |
| 58 | − --apply Execute steps (default is plan only) | |
| 59 | − --yes Skip confirmation prompts | |
| 60 | − --force Continue apply even if preflight has blocking failures | |
| 61 | − --only <steps> Run only these steps (comma-separated) | |
| 62 | − --skip <steps> Skip these steps (comma-separated) | |
| 63 | − -h, --help Show this help | |
| 64 | − | |
| 65 | −Steps: | |
| 66 | − cloudflare Configure sandbox host worker + runtime env wiring | |
| 67 | − env Sync WorkOS/Stripe/auth env to Convex prod | |
| 68 | − convex Deploy Convex functions to prod | |
| 69 | − doctor Verify prod setup and print remediation | |
| 70 | − | |
| 71 | −Examples: | |
| 72 | − bun run scripts/prod/deploy-prod.ts | |
| 73 | − bun run scripts/prod/deploy-prod.ts --apply | |
| 74 | − bun run scripts/prod/deploy-prod.ts --apply --only cloudflare,doctor | |
| 75 | − bun run scripts/prod/deploy-prod.ts --apply --skip convex --yes | |
| 76 | −`); | |
| 77 | −} | |
| 78 | − | |
| 79 | −function parseStepSet(raw: string): Set<StepId> { | |
| 80 | − const set = new Set<StepId>(); | |
| 81 | − for (const token of raw.split(",")) { | |
| 82 | − const value = token.trim(); | |
| 83 | − if (!value) continue; | |
| 84 | − if (!ALL_STEPS.includes(value as StepId)) { | |
| 85 | − throw new Error(`Unknown step '${value}'. Valid steps: ${ALL_STEPS.join(", ")}`); | |
| 86 | − } | |
| 87 | − set.add(value as StepId); | |
| 88 | − } | |
| 89 | − return set; | |
| 90 | −} | |
| 91 | − | |
| 92 | −export function parseArgs(argv: string[]): Options { | |
| 93 | − const options: Options = { | |
| 94 | − apply: false, | |
| 95 | − yes: false, | |
| 96 | − force: false, | |
| 97 | − skip: new Set<StepId>(), | |
| 98 | − }; | |
| 99 | − | |
| 100 | − for (let i = 0; i < argv.length; i += 1) { | |
| 101 | − const arg = argv[i]; | |
| 102 | − const next = argv[i + 1]; | |
| 103 | − | |
| 104 | − if (arg === "--apply") { | |
| 105 | − options.apply = true; | |
| 106 | − continue; | |
| 107 | − } | |
| 108 | − if (arg === "--yes") { | |
| 109 | − options.yes = true; | |
| 110 | − continue; | |
| 111 | − } | |
| 112 | − if (arg === "--force") { | |
| 113 | − options.force = true; | |
| 114 | − continue; | |
| 115 | − } | |
| 116 | − if (arg === "--only") { | |
| 117 | − if (!next) throw new Error("Missing value for --only"); | |
| 118 | − options.only = parseStepSet(next); | |
| 119 | − i += 1; | |
| 120 | − continue; | |
| 121 | − } | |
| 122 | − if (arg === "--skip") { | |
| 123 | − if (!next) throw new Error("Missing value for --skip"); | |
| 124 | − options.skip = parseStepSet(next); | |
| 125 | − i += 1; | |
| 126 | − continue; | |
| 127 | − } | |
| 128 | − if (arg === "--help" || arg === "-h") { | |
| 129 | − printHelp(); | |
| 130 | − process.exit(0); | |
| 131 | − } | |
| 132 | − | |
| 133 | − throw new Error(`Unknown option '${arg}'. Use --help for usage.`); | |
| 134 | − } | |
| 135 | − | |
| 136 | − return options; | |
| 137 | −} | |
| 138 | − | |
| 139 | −export function resolveSteps(options: Options): StepId[] { | |
| 140 | − const only = options.only ?? new Set<StepId>(ALL_STEPS); | |
| 141 | − return ALL_STEPS.filter((step) => only.has(step) && !options.skip.has(step)); | |
| 142 | −} | |
| 143 | − | |
| 144 | −async function runCommand(command: string[], cwd = EXECUTOR_ROOT): Promise<CommandResult> { | |
| 145 | − const proc = Bun.spawn(command, { | |
| 146 | − cwd, | |
| 147 | − env: Bun.env, | |
| 148 | − stdin: "ignore", | |
| 149 | − stdout: "pipe", | |
| 150 | − stderr: "pipe", | |
| 151 | − }); | |
| 152 | − | |
| 153 | − const [stdout, stderr, exitCode] = await Promise.all([ | |
| 154 | − new Response(proc.stdout).text(), | |
| 155 | − new Response(proc.stderr).text(), | |
| 156 | − proc.exited, | |
| 157 | − ]); | |
| 158 | − | |
| 159 | − return { | |
| 160 | − exitCode, | |
| 161 | − stdout, | |
| 162 | − stderr, | |
| 163 | − }; | |
| 164 | −} | |
| 165 | − | |
| 166 | −function isTruthy(value: string | undefined): boolean { | |
| 167 | − if (!value) return false; | |
| 168 | − const normalized = value.trim().toLowerCase(); | |
| 169 | − return ["1", "true", "yes", "on"].includes(normalized); | |
| 170 | −} | |
| 171 | − | |
| 172 | −function buildSteps(selected: StepId[]): DeployStep[] { | |
| 173 | − const byId: Record<StepId, DeployStep> = { | |
| 174 | − cloudflare: { | |
| 175 | − id: "cloudflare", | |
| 176 | − name: "Cloudflare Runtime", | |
| 177 | − command: ["bun", "run", "scripts/prod/setup/prod-cloudflare.ts", "--deploy", "--no-doctor"], | |
| 178 | − cwd: EXECUTOR_ROOT, | |
| 179 | − }, | |
| 180 | − env: { | |
| 181 | − id: "env", | |
| 182 | − name: "Convex Env Sync", | |
| 183 | − command: ["bun", "run", "scripts/prod/setup/prod-env.ts", "--from-env", "--strict"], | |
| 184 | − cwd: EXECUTOR_ROOT, | |
| 185 | − }, | |
| 186 | − convex: { | |
| 187 | − id: "convex", | |
| 188 | − name: "Convex Deploy", | |
| 189 | − command: ["bunx", "convex", "deploy", "--prod"], | |
| 190 | − cwd: EXECUTOR_ROOT, | |
| 191 | − }, | |
| 192 | − doctor: { | |
| 193 | − id: "doctor", | |
| 194 | − name: "Production Doctor", | |
| 195 | − command: ["bun", "run", "scripts/prod/doctor-prod.ts"], | |
| 196 | − cwd: EXECUTOR_ROOT, | |
| 197 | − }, | |
| 198 | − }; | |
| 199 | − | |
| 200 | − return selected.map((step) => byId[step]); | |
| 201 | −} | |
| 202 | − | |
| 203 | −async function getConvexProdEnv(): Promise<Map<string, string>> { | |
| 204 | − const result = await runCommand(["bunx", "convex", "env", "list", "--prod"]); | |
| 205 | − if (result.exitCode !== 0) { | |
| 206 | − return new Map<string, string>(); | |
| 207 | − } | |
| 208 | − return parseConvexEnvList(result.stdout); | |
| 209 | −} | |
| 210 | − | |
| 211 | −function missingLocalEnvKeys(): string[] { | |
| 212 | − return REQUIRED_ENV_SETUP_KEYS.filter((key) => !Bun.env[key]?.trim()); | |
| 213 | −} | |
| 214 | − | |
| 215 | −async function runPreflight(selected: StepId[]): Promise<PreflightCheck[]> { | |
| 216 | − const checks: PreflightCheck[] = []; | |
| 217 | − const usesCloudflare = selected.includes("cloudflare"); | |
| 218 | − const usesConvex = selected.some((id) => id === "env" || id === "convex" || id === "doctor" || id === "cloudflare"); | |
| 219 | − | |
| 220 | − if (usesCloudflare) { | |
| 221 | − const whoami = await runCommand(["bunx", "wrangler", "whoami"], RUNNER_HOST_DIR); | |
| 222 | − checks.push({ | |
| 223 | − id: "wrangler-auth", | |
| 224 | − ok: whoami.exitCode === 0, | |
| 225 | − blocking: true, | |
| 226 | − detail: whoami.exitCode === 0 ? "Wrangler auth is available" : "Wrangler auth not available", | |
| 227 | − hint: whoami.exitCode === 0 ? undefined : "Run `bunx wrangler login` and retry.", | |
| 228 | − }); | |
| 229 | − } | |
| 230 | − | |
| 231 | − if (usesConvex) { | |
| 232 | − const convexEnv = await runCommand(["bunx", "convex", "env", "list", "--prod"]); | |
| 233 | − checks.push({ | |
| 234 | − id: "convex-auth", | |
| 235 | − ok: convexEnv.exitCode === 0, | |
| 236 | − blocking: true, | |
| 237 | − detail: convexEnv.exitCode === 0 ? "Convex prod env is accessible" : "Cannot access Convex prod env", | |
| 238 | − hint: convexEnv.exitCode === 0 ? undefined : "Run `bunx convex dev` or `bunx convex login` to authenticate.", | |
| 239 | − }); | |
| 240 | − } | |
| 241 | − | |
| 242 | − if (selected.includes("env")) { | |
| 243 | − const missing = missingLocalEnvKeys(); | |
| 244 | − checks.push({ | |
| 245 | − id: "env-local-values", | |
| 246 | − ok: missing.length === 0, | |
| 247 | − blocking: true, | |
| 248 | − detail: missing.length === 0 | |
| 249 | − ? "All required local env vars for setup:prod:env are present" | |
| 250 | − : `Missing local env vars: ${missing.join(", ")}`, | |
| 251 | − hint: missing.length === 0 | |
| 252 | − ? undefined | |
| 253 | − : "Export required WORKOS_*, STRIPE_*, and MCP_AUTHORIZATION_SERVER vars before apply.", | |
| 254 | − }); | |
| 255 | − } | |
| 256 | − | |
| 257 | − if (selected.includes("cloudflare")) { | |
| 258 | − const env = await getConvexProdEnv(); | |
| 259 | − const hasConvexUrl = Boolean(Bun.env.CONVEX_URL?.trim() || env.get("CONVEX_URL")?.trim()); | |
| 260 | − checks.push({ | |
| 261 | − id: "convex-url", | |
| 262 | − ok: hasConvexUrl, | |
| 263 | − blocking: true, | |
| 264 | − detail: hasConvexUrl ? "CONVEX_URL is available" : "CONVEX_URL not found locally or in Convex prod env", | |
| 265 | − hint: hasConvexUrl | |
| 266 | − ? undefined | |
| 267 | − : "Set with `export CONVEX_URL=https://<deployment>.convex.cloud` or `bunx convex env set CONVEX_URL ... --prod`.", | |
| 268 | − }); | |
| 269 | − } | |
| 270 | − | |
| 271 | − if (selected.includes("convex") && isTruthy(Bun.env.IAC_MANAGE_CONVEX_ENV) && !selected.includes("env")) { | |
| 272 | − checks.push({ | |
| 273 | − id: "convex-env-order", | |
| 274 | − ok: true, | |
| 275 | − blocking: false, | |
| 276 | − detail: "Convex deploy selected without env sync", | |
| 277 | − hint: "If you changed env vars, run with `--only env,convex,doctor` for safer ordering.", | |
| 278 | − }); | |
| 279 | − } | |
| 280 | − | |
| 281 | − return checks; | |
| 282 | −} | |
| 283 | − | |
| 284 | −function printPlan(steps: DeployStep[], checks: PreflightCheck[]): void { | |
| 285 | − console.log("Deploy plan"); | |
| 286 | − for (const step of steps) { | |
| 287 | − console.log(` - ${step.id}: ${step.name}`); | |
| 288 | − console.log(` ${step.command.join(" ")}`); | |
| 289 | − } | |
| 290 | − | |
| 291 | − console.log("\nPreflight"); | |
| 292 | − for (const check of checks) { | |
| 293 | − const label = check.ok ? "ok" : (check.blocking ? "fail" : "warn"); | |
| 294 | − console.log(` [${label}] ${check.id}: ${check.detail}`); | |
| 295 | − if (check.hint) { | |
| 296 | − console.log(` hint: ${check.hint}`); | |
| 297 | − } | |
| 298 | − } | |
| 299 | −} | |
| 300 | − | |
| 301 | −async function confirmApply(): Promise<boolean> { | |
| 302 | − const rl = createInterface({ | |
| 303 | − input: process.stdin, | |
| 304 | − output: process.stdout, | |
| 305 | − }); | |
| 306 | − const input = (await rl.question("\nProceed with apply? [y/N] ")).trim().toLowerCase(); | |
| 307 | − rl.close(); | |
| 308 | − return input === "y" || input === "yes"; | |
| 309 | −} | |
| 310 | − | |
| 311 | −async function executeSteps(steps: DeployStep[]): Promise<void> { | |
| 312 | − for (const step of steps) { | |
| 313 | − console.log(`\n==> ${step.name}`); | |
| 314 | − const result = await runCommand(step.command, step.cwd ?? EXECUTOR_ROOT); | |
| 315 | − | |
| 316 | − const stdout = result.stdout.trim(); | |
| 317 | − const stderr = result.stderr.trim(); | |
| 318 | − if (stdout) console.log(stdout); | |
| 319 | − if (stderr) console.error(stderr); | |
| 320 | − | |
| 321 | − if (result.exitCode !== 0) { | |
| 322 | − throw new Error(`Step '${step.id}' failed with exit code ${result.exitCode}`); | |
| 323 | − } | |
| 324 | − } | |
| 325 | −} | |
| 326 | − | |
| 327 | −async function main(): Promise<void> { | |
| 328 | − const options = parseArgs(process.argv.slice(2)); | |
| 329 | − const selected = resolveSteps(options); | |
| 330 | − if (selected.length === 0) { | |
| 331 | − throw new Error("No steps selected. Remove --skip/--only filters."); | |
| 332 | − } | |
| 333 | − | |
| 334 | − const steps = buildSteps(selected); | |
| 335 | − const checks = await runPreflight(selected); | |
| 336 | − const blockingFailures = checks.filter((check) => !check.ok && check.blocking); | |
| 337 | − | |
| 338 | − console.log("Executor production deploy"); | |
| 339 | − console.log(`Mode: ${options.apply ? "apply" : "plan"}`); | |
| 340 | − printPlan(steps, checks); | |
| 341 | − | |
| 342 | − if (!options.apply) { | |
| 343 | − console.log("\nPlan only. Re-run with --apply to execute."); | |
| 344 | − return; | |
| 345 | − } | |
| 346 | − | |
| 347 | − if (blockingFailures.length > 0 && !options.force) { | |
| 348 | − throw new Error("Blocking preflight failures detected. Fix them or use --force to override."); | |
| 349 | − } | |
| 350 | − | |
| 351 | − if (!options.yes) { | |
| 352 | − const approved = await confirmApply(); | |
| 353 | − if (!approved) { | |
| 354 | − console.log("Aborted."); | |
| 355 | − return; | |
| 356 | − } | |
| 357 | − } | |
| 358 | − | |
| 359 | − await executeSteps(steps); | |
| 360 | − console.log("\nDeploy completed."); | |
| 361 | −} | |
| 362 | − | |
| 363 | −if (import.meta.main) { | |
| 364 | − try { | |
| 365 | − await main(); | |
| 366 | − } catch (error) { | |
| 367 | − const message = error instanceof Error ? error.message : String(error); | |
| 368 | − console.error(`deploy:prod failed: ${message}`); | |
| 369 | − process.exit(1); | |
| 370 | − } | |
| 371 | −} | |
| @@ -1,81 +0,0 @@ | ||
| 1 | −import { describe, expect, test } from "bun:test"; | |
| 2 | −import { buildEnvChecks, parseConvexEnvList } from "./doctor-prod"; | |
| 3 | − | |
| 4 | −describe("parseConvexEnvList", () => { | |
| 5 | − test("parses key value lines and ignores comments", () => { | |
| 6 | − const env = parseConvexEnvList([ | |
| 7 | − "# comment", | |
| 8 | − "CONVEX_URL=https://example.convex.cloud", | |
| 9 | − "EXECUTOR_INTERNAL_TOKEN=abc=def", | |
| 10 | − "", | |
| 11 | − ].join("\n")); | |
| 12 | − | |
| 13 | − expect(env.get("CONVEX_URL")).toBe("https://example.convex.cloud"); | |
| 14 | − expect(env.get("EXECUTOR_INTERNAL_TOKEN")).toBe("abc=def"); | |
| 15 | − expect(env.has("# comment")).toBe(false); | |
| 16 | − }); | |
| 17 | −}); | |
| 18 | − | |
| 19 | −describe("buildEnvChecks", () => { | |
| 20 | − test("flags missing required production env vars", () => { | |
| 21 | − const checks = buildEnvChecks(new Map()); | |
| 22 | − const byName = new Map(checks.map((check) => [check.name, check])); | |
| 23 | − | |
| 24 | − expect(byName.get("env:CLOUDFLARE_SANDBOX_RUN_URL")?.ok).toBe(false); | |
| 25 | − expect(byName.get("env:CLOUDFLARE_SANDBOX_AUTH_TOKEN")?.ok).toBe(false); | |
| 26 | − expect(byName.get("env:EXECUTOR_INTERNAL_TOKEN")?.ok).toBe(false); | |
| 27 | − expect(byName.get("env:CONVEX_URL")?.ok).toBe(false); | |
| 28 | − expect(byName.get("env:WORKOS_CLIENT_ID")?.ok).toBe(false); | |
| 29 | − expect(byName.get("env:WORKOS_API_KEY")?.ok).toBe(false); | |
| 30 | − expect(byName.get("env:WORKOS_WEBHOOK_SECRET")?.ok).toBe(false); | |
| 31 | − expect(byName.get("env:WORKOS_COOKIE_PASSWORD")?.ok).toBe(false); | |
| 32 | − expect(byName.get("env:STRIPE_SECRET_KEY")?.ok).toBe(false); | |
| 33 | − expect(byName.get("env:STRIPE_WEBHOOK_SECRET")?.ok).toBe(false); | |
| 34 | − expect(byName.get("env:STRIPE_PRICE_ID")?.ok).toBe(false); | |
| 35 | − expect(byName.get("env:auth server")?.ok).toBe(false); | |
| 36 | − }); | |
| 37 | − | |
| 38 | − test("rejects CONVEX_URL pointing to convex.site", () => { | |
| 39 | − const env = new Map<string, string>([ | |
| 40 | − ["CLOUDFLARE_SANDBOX_RUN_URL", "https://executor-sandbox-host.example.workers.dev/v1/runs"], | |
| 41 | − ["CLOUDFLARE_SANDBOX_AUTH_TOKEN", "abcdefghijklmnopqrstuvwxyz0123456789"], | |
| 42 | − ["EXECUTOR_INTERNAL_TOKEN", "abcdefghijklmnopqrstuvwxyz0123456789"], | |
| 43 | − ["CONVEX_URL", "https://perceptive-pigeon-577.convex.site"], | |
| 44 | − ["WORKOS_CLIENT_ID", "client_123"], | |
| 45 | − ["WORKOS_API_KEY", "sk_test_123"], | |
| 46 | − ["WORKOS_WEBHOOK_SECRET", "whsec_123"], | |
| 47 | − ["WORKOS_COOKIE_PASSWORD", "cookie_password_123"], | |
| 48 | − ["STRIPE_SECRET_KEY", "sk_live_123"], | |
| 49 | − ["STRIPE_WEBHOOK_SECRET", "whsec_live_123"], | |
| 50 | − ["STRIPE_PRICE_ID", "price_123"], | |
| 51 | − ["MCP_AUTHORIZATION_SERVER", "https://auth.example.com"], | |
| 52 | − ]); | |
| 53 | − | |
| 54 | − const checks = buildEnvChecks(env); | |
| 55 | − const hostTypeCheck = checks.find((check) => check.name === "convex URL host type"); | |
| 56 | − expect(hostTypeCheck?.ok).toBe(false); | |
| 57 | − }); | |
| 58 | − | |
| 59 | − test("accepts well-formed cloud configuration", () => { | |
| 60 | − const env = new Map<string, string>([ | |
| 61 | − ["CLOUDFLARE_SANDBOX_RUN_URL", "https://executor-sandbox-host.example.workers.dev/v1/runs"], | |
| 62 | − ["CLOUDFLARE_SANDBOX_AUTH_TOKEN", "abcdefghijklmnopqrstuvwxyz0123456789"], | |
| 63 | − ["EXECUTOR_INTERNAL_TOKEN", "abcdefghijklmnopqrstuvwxyz0123456789"], | |
| 64 | − ["CONVEX_URL", "https://perceptive-pigeon-577.convex.cloud"], | |
| 65 | − ["CONVEX_SITE_URL", "https://perceptive-pigeon-577.convex.site"], | |
| 66 | − ["WORKOS_CLIENT_ID", "client_123"], | |
| 67 | − ["WORKOS_API_KEY", "sk_test_123"], | |
| 68 | − ["WORKOS_WEBHOOK_SECRET", "whsec_123"], | |
| 69 | − ["WORKOS_COOKIE_PASSWORD", "cookie_password_123"], | |
| 70 | − ["STRIPE_SECRET_KEY", "sk_live_123"], | |
| 71 | − ["STRIPE_WEBHOOK_SECRET", "whsec_live_123"], | |
| 72 | − ["STRIPE_PRICE_ID", "price_123"], | |
| 73 | − ["MCP_AUTHORIZATION_SERVER", "https://auth.example.com"], | |
| 74 | − ]); | |
| 75 | − | |
| 76 | − const checks = buildEnvChecks(env); | |
| 77 | − const failures = checks.filter((check) => !check.ok); | |
| 78 | − expect(failures).toEqual([]); | |
| 79 | − }); | |
| 80 | − | |
| 81 | −}); | |
| @@ -1,382 +0,0 @@ | ||
| 1 | −import path from "node:path"; | |
| 2 | − | |
| 3 | −export interface DoctorCheck { | |
| 4 | − name: string; | |
| 5 | − ok: boolean; | |
| 6 | − detail: string; | |
| 7 | −} | |
| 8 | − | |
| 9 | −export interface ProductionDoctorReport { | |
| 10 | − checks: DoctorCheck[]; | |
| 11 | −} | |
| 12 | − | |
| 13 | −interface CommandResult { | |
| 14 | − exitCode: number; | |
| 15 | − stdout: string; | |
| 16 | − stderr: string; | |
| 17 | −} | |
| 18 | − | |
| 19 | −const REQUIRED_PROD_ENV_KEYS = [ | |
| 20 | − "CLOUDFLARE_SANDBOX_RUN_URL", | |
| 21 | − "CLOUDFLARE_SANDBOX_AUTH_TOKEN", | |
| 22 | − "EXECUTOR_INTERNAL_TOKEN", | |
| 23 | − "CONVEX_URL", | |
| 24 | −] as const; | |
| 25 | − | |
| 26 | −const REQUIRED_APP_ENV_KEYS = [ | |
| 27 | − "WORKOS_CLIENT_ID", | |
| 28 | − "WORKOS_API_KEY", | |
| 29 | − "WORKOS_WEBHOOK_SECRET", | |
| 30 | − "WORKOS_COOKIE_PASSWORD", | |
| 31 | −] as const; | |
| 32 | − | |
| 33 | −const REQUIRED_BILLING_ENV_KEYS = [ | |
| 34 | − "STRIPE_SECRET_KEY", | |
| 35 | − "STRIPE_WEBHOOK_SECRET", | |
| 36 | − "STRIPE_PRICE_ID", | |
| 37 | −] as const; | |
| 38 | − | |
| 39 | −const OPTIONAL_BILLING_ENV_KEYS = [ | |
| 40 | − "BILLING_SUCCESS_URL", | |
| 41 | − "BILLING_CANCEL_URL", | |
| 42 | − "BILLING_RETURN_URL", | |
| 43 | −] as const; | |
| 44 | − | |
| 45 | −const AUTH_SERVER_ENV_KEYS = [ | |
| 46 | − "MCP_AUTHORIZATION_SERVER", | |
| 47 | − "MCP_AUTHORIZATION_SERVER_URL", | |
| 48 | − "WORKOS_AUTHKIT_ISSUER", | |
| 49 | − "WORKOS_AUTHKIT_DOMAIN", | |
| 50 | −] as const; | |
| 51 | − | |
| 52 | −const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", ".."); | |
| 53 | − | |
| 54 | −function addCheck(report: ProductionDoctorReport, name: string, ok: boolean, detail: string): void { | |
| 55 | − report.checks.push({ name, ok, detail }); | |
| 56 | −} | |
| 57 | − | |
| 58 | −async function runCommand(command: string[], cwd = EXECUTOR_ROOT): Promise<CommandResult> { | |
| 59 | − const proc = Bun.spawn(command, { | |
| 60 | − cwd, | |
| 61 | − env: Bun.env, | |
| 62 | − stdin: "ignore", | |
| 63 | − stdout: "pipe", | |
| 64 | − stderr: "pipe", | |
| 65 | − }); | |
| 66 | − | |
| 67 | − const [stdout, stderr, exitCode] = await Promise.all([ | |
| 68 | − new Response(proc.stdout).text(), | |
| 69 | − new Response(proc.stderr).text(), | |
| 70 | − proc.exited, | |
| 71 | − ]); | |
| 72 | − | |
| 73 | − return { | |
| 74 | − exitCode, | |
| 75 | − stdout, | |
| 76 | − stderr, | |
| 77 | − }; | |
| 78 | −} | |
| 79 | − | |
| 80 | −async function runConvex(args: string[]): Promise<CommandResult> { | |
| 81 | − return await runCommand(["bunx", "convex", ...args]); | |
| 82 | −} | |
| 83 | − | |
| 84 | −export function parseConvexEnvList(raw: string): Map<string, string> { | |
| 85 | − const env = new Map<string, string>(); | |
| 86 | − | |
| 87 | − for (const line of raw.split("\n")) { | |
| 88 | − const trimmed = line.trim(); | |
| 89 | − if (!trimmed || trimmed.startsWith("#")) { | |
| 90 | − continue; | |
| 91 | − } | |
| 92 | − | |
| 93 | − const eqIndex = trimmed.indexOf("="); | |
| 94 | − if (eqIndex <= 0) { | |
| 95 | − continue; | |
| 96 | − } | |
| 97 | − | |
| 98 | − const key = trimmed.slice(0, eqIndex).trim(); | |
| 99 | − const value = trimmed.slice(eqIndex + 1); | |
| 100 | − if (key.length > 0) { | |
| 101 | − env.set(key, value); | |
| 102 | − } | |
| 103 | − } | |
| 104 | − | |
| 105 | − return env; | |
| 106 | −} | |
| 107 | − | |
| 108 | −function parseUrl(input: string): URL | null { | |
| 109 | − try { | |
| 110 | − return new URL(input); | |
| 111 | − } catch { | |
| 112 | − return null; | |
| 113 | − } | |
| 114 | −} | |
| 115 | − | |
| 116 | −function deploymentPrefix(hostname: string, suffix: ".convex.cloud" | ".convex.site"): string | null { | |
| 117 | − if (!hostname.endsWith(suffix)) { | |
| 118 | − return null; | |
| 119 | − } | |
| 120 | − return hostname.slice(0, hostname.length - suffix.length); | |
| 121 | −} | |
| 122 | − | |
| 123 | −function addTokenStrengthCheck(report: ProductionDoctorReport, key: string, value: string): void { | |
| 124 | − const looksUsable = value.length >= 24; | |
| 125 | − addCheck(report, `${key} strength`, looksUsable, looksUsable ? "length looks reasonable" : "value seems too short"); | |
| 126 | −} | |
| 127 | − | |
| 128 | −export function buildEnvChecks(env: Map<string, string>): DoctorCheck[] { | |
| 129 | − const checks: DoctorCheck[] = []; | |
| 130 | − const report: ProductionDoctorReport = { checks }; | |
| 131 | − | |
| 132 | − for (const key of REQUIRED_PROD_ENV_KEYS) { | |
| 133 | − const present = Boolean(env.get(key)?.trim()); | |
| 134 | − addCheck(report, `env:${key}`, present, present ? "set" : "missing"); | |
| 135 | − } | |
| 136 | − | |
| 137 | − for (const key of REQUIRED_APP_ENV_KEYS) { | |
| 138 | − const present = Boolean(env.get(key)?.trim()); | |
| 139 | − addCheck(report, `env:${key}`, present, present ? "set" : "missing"); | |
| 140 | − } | |
| 141 | − | |
| 142 | − for (const key of REQUIRED_BILLING_ENV_KEYS) { | |
| 143 | − const present = Boolean(env.get(key)?.trim()); | |
| 144 | − addCheck( | |
| 145 | − report, | |
| 146 | − `env:${key}`, | |
| 147 | − present, | |
| 148 | − present ? "set" : "missing (required while billing feature is enabled)", | |
| 149 | − ); | |
| 150 | − } | |
| 151 | − | |
| 152 | − const authServerKey = AUTH_SERVER_ENV_KEYS.find((key) => Boolean(env.get(key)?.trim())); | |
| 153 | − addCheck( | |
| 154 | − report, | |
| 155 | − "env:auth server", | |
| 156 | − Boolean(authServerKey), | |
| 157 | − authServerKey | |
| 158 | − ? `using ${authServerKey}` | |
| 159 | − : `missing (set one of ${AUTH_SERVER_ENV_KEYS.join(", ")})`, | |
| 160 | − ); | |
| 161 | − | |
| 162 | − const runUrlRaw = env.get("CLOUDFLARE_SANDBOX_RUN_URL")?.trim() ?? ""; | |
| 163 | − if (runUrlRaw) { | |
| 164 | − const runUrl = parseUrl(runUrlRaw); | |
| 165 | − if (!runUrl) { | |
| 166 | − addCheck(report, "sandbox run URL format", false, `invalid URL: ${runUrlRaw}`); | |
| 167 | − } else { | |
| 168 | − addCheck(report, "sandbox run URL format", true, runUrlRaw); | |
| 169 | − addCheck( | |
| 170 | − report, | |
| 171 | − "sandbox run URL protocol", | |
| 172 | − runUrl.protocol === "https:", | |
| 173 | − runUrl.protocol === "https:" ? "https" : `expected https, got ${runUrl.protocol}`, | |
| 174 | − ); | |
| 175 | − addCheck( | |
| 176 | − report, | |
| 177 | − "sandbox run URL path", | |
| 178 | − runUrl.pathname === "/v1/runs", | |
| 179 | − runUrl.pathname === "/v1/runs" ? "path is /v1/runs" : `expected /v1/runs, got ${runUrl.pathname}`, | |
| 180 | − ); | |
| 181 | − } | |
| 182 | − } | |
| 183 | − | |
| 184 | − const convexUrlRaw = env.get("CONVEX_URL")?.trim() ?? ""; | |
| 185 | − if (convexUrlRaw) { | |
| 186 | − const convexUrl = parseUrl(convexUrlRaw); | |
| 187 | − if (!convexUrl) { | |
| 188 | − addCheck(report, "convex URL format", false, `invalid URL: ${convexUrlRaw}`); | |
| 189 | − } else { | |
| 190 | − addCheck(report, "convex URL format", true, convexUrlRaw); | |
| 191 | − addCheck( | |
| 192 | − report, | |
| 193 | − "convex URL protocol", | |
| 194 | − convexUrl.protocol === "https:", | |
| 195 | − convexUrl.protocol === "https:" ? "https" : `expected https, got ${convexUrl.protocol}`, | |
| 196 | − ); | |
| 197 | − | |
| 198 | − if (convexUrl.hostname.endsWith(".convex.site")) { | |
| 199 | − addCheck(report, "convex URL host type", false, "uses .convex.site; expected API host (.convex.cloud)"); | |
| 200 | − } else if (convexUrl.hostname.endsWith(".convex.cloud")) { | |
| 201 | − addCheck(report, "convex URL host type", true, "uses .convex.cloud API host"); | |
| 202 | − } else { | |
| 203 | − addCheck(report, "convex URL host type", true, `custom host: ${convexUrl.hostname}`); | |
| 204 | − } | |
| 205 | − } | |
| 206 | − } | |
| 207 | − | |
| 208 | − const convexSiteUrlRaw = env.get("CONVEX_SITE_URL")?.trim() ?? ""; | |
| 209 | − if (convexSiteUrlRaw) { | |
| 210 | − const convexSiteUrl = parseUrl(convexSiteUrlRaw); | |
| 211 | − addCheck( | |
| 212 | − report, | |
| 213 | − "convex site URL format", | |
| 214 | − Boolean(convexSiteUrl), | |
| 215 | − convexSiteUrl ? convexSiteUrlRaw : `invalid URL: ${convexSiteUrlRaw}`, | |
| 216 | − ); | |
| 217 | − | |
| 218 | − if (convexSiteUrl) { | |
| 219 | − addCheck( | |
| 220 | − report, | |
| 221 | − "convex site URL protocol", | |
| 222 | − convexSiteUrl.protocol === "https:", | |
| 223 | − convexSiteUrl.protocol === "https:" ? "https" : `expected https, got ${convexSiteUrl.protocol}`, | |
| 224 | − ); | |
| 225 | − } | |
| 226 | − } | |
| 227 | − | |
| 228 | − if (convexUrlRaw && convexSiteUrlRaw) { | |
| 229 | − const convexUrl = parseUrl(convexUrlRaw); | |
| 230 | − const convexSiteUrl = parseUrl(convexSiteUrlRaw); | |
| 231 | − if (convexUrl && convexSiteUrl) { | |
| 232 | − const cloudPrefix = deploymentPrefix(convexUrl.hostname, ".convex.cloud"); | |
| 233 | − const sitePrefix = deploymentPrefix(convexSiteUrl.hostname, ".convex.site"); | |
| 234 | − if (cloudPrefix && sitePrefix) { | |
| 235 | − addCheck( | |
| 236 | − report, | |
| 237 | − "convex deployment pairing", | |
| 238 | − cloudPrefix === sitePrefix, | |
| 239 | − cloudPrefix === sitePrefix | |
| 240 | − ? `deployment ${cloudPrefix} matches` | |
| 241 | − : `CONVEX_URL(${cloudPrefix}) does not match CONVEX_SITE_URL(${sitePrefix})`, | |
| 242 | − ); | |
| 243 | − } | |
| 244 | − } | |
| 245 | − } | |
| 246 | − | |
| 247 | − const sandboxAuthToken = env.get("CLOUDFLARE_SANDBOX_AUTH_TOKEN")?.trim(); | |
| 248 | − if (sandboxAuthToken) { | |
| 249 | − addTokenStrengthCheck(report, "CLOUDFLARE_SANDBOX_AUTH_TOKEN", sandboxAuthToken); | |
| 250 | − } | |
| 251 | − | |
| 252 | − const internalSecret = env.get("EXECUTOR_INTERNAL_TOKEN")?.trim(); | |
| 253 | − if (internalSecret) { | |
| 254 | − addTokenStrengthCheck(report, "EXECUTOR_INTERNAL_TOKEN", internalSecret); | |
| 255 | − } | |
| 256 | − | |
| 257 | − for (const key of OPTIONAL_BILLING_ENV_KEYS) { | |
| 258 | − const value = env.get(key)?.trim(); | |
| 259 | − addCheck( | |
| 260 | − report, | |
| 261 | − `env(optional):${key}`, | |
| 262 | − true, | |
| 263 | − value ? "set" : "not set (billing flow falls back to localhost URL)", | |
| 264 | − ); | |
| 265 | − } | |
| 266 | − | |
| 267 | − return checks; | |
| 268 | −} | |
| 269 | − | |
| 270 | −function guidanceForFailure(checkName: string): string[] { | |
| 271 | − if ( | |
| 272 | − checkName === "env:CLOUDFLARE_SANDBOX_RUN_URL" | |
| 273 | − || checkName === "env:CLOUDFLARE_SANDBOX_AUTH_TOKEN" | |
| 274 | − || checkName === "env:EXECUTOR_INTERNAL_TOKEN" | |
| 275 | − ) { | |
| 276 | − return [ | |
| 277 | − "Run `bun run setup:prod:cloudflare --deploy` (or `bun run setup:prod:all`) to deploy sandbox host and set required Cloudflare runtime env vars.", | |
| 278 | − ]; | |
| 279 | − } | |
| 280 | − | |
| 281 | − if (checkName === "env:CONVEX_URL") { | |
| 282 | − return [ | |
| 283 | − "Set Convex API URL: `bunx convex env set CONVEX_URL https://<deployment>.convex.cloud --prod`.", | |
| 284 | − ]; | |
| 285 | − } | |
| 286 | − | |
| 287 | − if (checkName === "convex URL host type") { | |
| 288 | − return [ | |
| 289 | − "Use `.convex.cloud` for `CONVEX_URL` (do not use `.convex.site` for callback RPC).", | |
| 290 | − ]; | |
| 291 | − } | |
| 292 | − | |
| 293 | − if (checkName === "env:WORKOS_CLIENT_ID" || checkName === "env:WORKOS_API_KEY" || checkName === "env:WORKOS_WEBHOOK_SECRET") { | |
| 294 | − return [ | |
| 295 | − "Set WorkOS auth env vars with `bun run setup:prod:env --from-env` (after exporting WORKOS_* vars), or run `bun run setup:prod:all`.", | |
| 296 | − ]; | |
| 297 | − } | |
| 298 | − | |
| 299 | − if (checkName === "env:WORKOS_COOKIE_PASSWORD") { | |
| 300 | − return [ | |
| 301 | − "Set a strong cookie password (`WORKOS_COOKIE_PASSWORD`) via `bunx convex env set WORKOS_COOKIE_PASSWORD <VALUE> --prod`.", | |
| 302 | − ]; | |
| 303 | − } | |
| 304 | − | |
| 305 | − if (checkName === "env:auth server") { | |
| 306 | − return [ | |
| 307 | − "Set one auth server env var: `MCP_AUTHORIZATION_SERVER` (preferred) via `bunx convex env set MCP_AUTHORIZATION_SERVER <URL> --prod`.", | |
| 308 | − ]; | |
| 309 | − } | |
| 310 | − | |
| 311 | − if ( | |
| 312 | − checkName === "env:STRIPE_SECRET_KEY" | |
| 313 | − || checkName === "env:STRIPE_WEBHOOK_SECRET" | |
| 314 | − || checkName === "env:STRIPE_PRICE_ID" | |
| 315 | − ) { | |
| 316 | − return [ | |
| 317 | − "Set Stripe billing env vars with `bun run setup:prod:env --from-env` (after exporting STRIPE_* vars), or run `bun run setup:prod:all`.", | |
| 318 | − ]; | |
| 319 | − } | |
| 320 | − | |
| 321 | − return ["Inspect current prod vars with `bunx convex env list --prod`."]; | |
| 322 | −} | |
| 323 | − | |
| 324 | −export async function runProductionDoctor(): Promise<ProductionDoctorReport> { | |
| 325 | − const report: ProductionDoctorReport = { checks: [] }; | |
| 326 | − | |
| 327 | − const envResult = await runConvex(["env", "list", "--prod"]); | |
| 328 | − if (envResult.exitCode !== 0) { | |
| 329 | − const detail = (envResult.stderr.trim() || envResult.stdout.trim() || "unknown failure").split("\n")[0] ?? "unknown failure"; | |
| 330 | − addCheck(report, "convex env access", false, detail); | |
| 331 | − return report; | |
| 332 | − } | |
| 333 | − | |
| 334 | − addCheck(report, "convex env access", true, "able to read production Convex env"); | |
| 335 | − const env = parseConvexEnvList(envResult.stdout); | |
| 336 | − | |
| 337 | − report.checks.push(...buildEnvChecks(env)); | |
| 338 | − | |
| 339 | − return report; | |
| 340 | −} | |
| 341 | − | |
| 342 | −export function hasDoctorFailures(report: ProductionDoctorReport): boolean { | |
| 343 | − return report.checks.some((check) => !check.ok); | |
| 344 | −} | |
| 345 | − | |
| 346 | −async function main(): Promise<void> { | |
| 347 | − const report = await runProductionDoctor(); | |
| 348 | − console.log("Production doctor"); | |
| 349 | − for (const check of report.checks) { | |
| 350 | − const status = check.ok ? "ok" : "fail"; | |
| 351 | − console.log(` [${status}] ${check.name}: ${check.detail}`); | |
| 352 | − } | |
| 353 | − | |
| 354 | − const failedChecks = report.checks.filter((check) => !check.ok); | |
| 355 | − if (failedChecks.length > 0) { | |
| 356 | − const guidance = new Set<string>(); | |
| 357 | − for (const check of failedChecks) { | |
| 358 | − for (const line of guidanceForFailure(check.name)) { | |
| 359 | − guidance.add(line); | |
| 360 | − } | |
| 361 | − } | |
| 362 | − | |
| 363 | − console.log("\nHow to fix"); | |
| 364 | − for (const line of guidance) { | |
| 365 | − console.log(` - ${line}`); | |
| 366 | − } | |
| 367 | − } | |
| 368 | − | |
| 369 | − if (hasDoctorFailures(report)) { | |
| 370 | − process.exit(1); | |
| 371 | − } | |
| 372 | −} | |
| 373 | − | |
| 374 | −if (import.meta.main) { | |
| 375 | − try { | |
| 376 | − await main(); | |
| 377 | − } catch (error) { | |
| 378 | − const message = error instanceof Error ? error.message : String(error); | |
| 379 | − console.error(`doctor:prod failed: ${message}`); | |
| 380 | − process.exit(1); | |
| 381 | − } | |
| 382 | −} | |
| @@ -1,290 +0,0 @@ | ||
| 1 | −import crypto from "node:crypto"; | |
| 2 | −import fs from "node:fs/promises"; | |
| 3 | −import path from "node:path"; | |
| 4 | −import { parseConvexEnvList } from "../doctor-prod"; | |
| 5 | − | |
| 6 | −interface Options { | |
| 7 | − runUrl?: string; | |
| 8 | − convexUrl?: string; | |
| 9 | − authToken?: string; | |
| 10 | − internalToken?: string; | |
| 11 | − deploy: boolean; | |
| 12 | − runDoctor: boolean; | |
| 13 | −} | |
| 14 | − | |
| 15 | −interface CommandResult { | |
| 16 | − exitCode: number; | |
| 17 | − stdout: string; | |
| 18 | − stderr: string; | |
| 19 | −} | |
| 20 | − | |
| 21 | −const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", ".."); | |
| 22 | −const SANDBOX_HOST_DIR = path.join(EXECUTOR_ROOT, "packages", "runner-sandbox-host"); | |
| 23 | − | |
| 24 | −function printHelp(): void { | |
| 25 | − console.log(`Setup Cloudflare runtime for production | |
| 26 | − | |
| 27 | −Usage: | |
| 28 | − bun run scripts/prod/setup/prod-cloudflare.ts [options] | |
| 29 | − | |
| 30 | −Options: | |
| 31 | − --run-url <url> Explicit sandbox run URL (e.g. https://.../v1/runs) | |
| 32 | − --convex-url <url> Explicit Convex API URL (https://...convex.cloud) | |
| 33 | − --auth-token <token> Explicit sandbox auth token (otherwise generated) | |
| 34 | − --internal-token <token> Explicit internal callback token (otherwise generated) | |
| 35 | − --no-deploy Skip wrangler deploy step | |
| 36 | − --no-doctor Skip running doctor:prod at the end | |
| 37 | − -h, --help Show this help | |
| 38 | −`); | |
| 39 | −} | |
| 40 | − | |
| 41 | −function parseArgs(argv: string[]): Options { | |
| 42 | − const options: Options = { | |
| 43 | − deploy: true, | |
| 44 | − runDoctor: true, | |
| 45 | − }; | |
| 46 | − | |
| 47 | − for (let i = 0; i < argv.length; i += 1) { | |
| 48 | − const arg = argv[i]; | |
| 49 | − const next = argv[i + 1]; | |
| 50 | − if (arg === "--run-url") { | |
| 51 | − if (!next) throw new Error("Missing value for --run-url"); | |
| 52 | − options.runUrl = next; | |
| 53 | − i += 1; | |
| 54 | − continue; | |
| 55 | − } | |
| 56 | − if (arg === "--convex-url") { | |
| 57 | − if (!next) throw new Error("Missing value for --convex-url"); | |
| 58 | − options.convexUrl = next; | |
| 59 | − i += 1; | |
| 60 | − continue; | |
| 61 | − } | |
| 62 | − if (arg === "--auth-token") { | |
| 63 | − if (!next) throw new Error("Missing value for --auth-token"); | |
| 64 | − options.authToken = next; | |
| 65 | − i += 1; | |
| 66 | − continue; | |
| 67 | − } | |
| 68 | − if (arg === "--internal-token") { | |
| 69 | − if (!next) throw new Error("Missing value for --internal-token"); | |
| 70 | − options.internalToken = next; | |
| 71 | − i += 1; | |
| 72 | − continue; | |
| 73 | − } | |
| 74 | − if (arg === "--no-deploy") { | |
| 75 | − options.deploy = false; | |
| 76 | − continue; | |
| 77 | − } | |
| 78 | − if (arg === "--no-doctor") { | |
| 79 | − options.runDoctor = false; | |
| 80 | − continue; | |
| 81 | − } | |
| 82 | − if (arg === "--help" || arg === "-h") { | |
| 83 | − printHelp(); | |
| 84 | − process.exit(0); | |
| 85 | − } | |
| 86 | − throw new Error(`Unknown option: ${arg}`); | |
| 87 | − } | |
| 88 | − | |
| 89 | − return options; | |
| 90 | −} | |
| 91 | − | |
| 92 | −function randomToken(bytes = 32): string { | |
| 93 | − return crypto.randomBytes(bytes).toString("base64url"); | |
| 94 | −} | |
| 95 | − | |
| 96 | −function redact(value: string): string { | |
| 97 | − if (value.length <= 8) return "********"; | |
| 98 | − return `${value.slice(0, 4)}...${value.slice(-4)}`; | |
| 99 | −} | |
| 100 | − | |
| 101 | −async function runCommand( | |
| 102 | − command: string[], | |
| 103 | − opts: { cwd?: string; stdinData?: string } = {}, | |
| 104 | −): Promise<CommandResult> { | |
| 105 | − const proc = Bun.spawn(command, { | |
| 106 | − cwd: opts.cwd ?? EXECUTOR_ROOT, | |
| 107 | − env: Bun.env, | |
| 108 | − stdin: opts.stdinData !== undefined ? "pipe" : "ignore", | |
| 109 | − stdout: "pipe", | |
| 110 | − stderr: "pipe", | |
| 111 | − }); | |
| 112 | − | |
| 113 | − if (opts.stdinData !== undefined && proc.stdin) { | |
| 114 | − proc.stdin.write(`${opts.stdinData}\n`); | |
| 115 | − proc.stdin.end(); | |
| 116 | − } | |
| 117 | − | |
| 118 | − const [stdout, stderr, exitCode] = await Promise.all([ | |
| 119 | − new Response(proc.stdout).text(), | |
| 120 | − new Response(proc.stderr).text(), | |
| 121 | − proc.exited, | |
| 122 | − ]); | |
| 123 | − | |
| 124 | − return { | |
| 125 | − exitCode, | |
| 126 | − stdout, | |
| 127 | − stderr, | |
| 128 | − }; | |
| 129 | −} | |
| 130 | − | |
| 131 | −async function runConvex(args: string[]): Promise<CommandResult> { | |
| 132 | − return await runCommand(["bunx", "convex", ...args]); | |
| 133 | −} | |
| 134 | − | |
| 135 | −function ensureHttpsUrl(name: string, raw: string): void { | |
| 136 | − let parsed: URL; | |
| 137 | − try { | |
| 138 | − parsed = new URL(raw); | |
| 139 | − } catch { | |
| 140 | − throw new Error(`${name} is not a valid URL: ${raw}`); | |
| 141 | − } | |
| 142 | − | |
| 143 | − if (parsed.protocol !== "https:") { | |
| 144 | − throw new Error(`${name} must use https: ${raw}`); | |
| 145 | − } | |
| 146 | −} | |
| 147 | − | |
| 148 | −async function parseSandboxWorkerName(): Promise<string> { | |
| 149 | − const wranglerConfigPath = path.join(SANDBOX_HOST_DIR, "wrangler.jsonc"); | |
| 150 | − const text = await fs.readFile(wranglerConfigPath, "utf8"); | |
| 151 | − const match = text.match(/"name"\s*:\s*"([^"]+)"/); | |
| 152 | − if (!match?.[1]) { | |
| 153 | − throw new Error(`Could not parse worker name from ${wranglerConfigPath}`); | |
| 154 | − } | |
| 155 | − return match[1]; | |
| 156 | −} | |
| 157 | − | |
| 158 | −function inferWorkersUrlFromDeployOutput(output: string): string | null { | |
| 159 | − const match = output.match(/https:\/\/[a-zA-Z0-9._-]+\.workers\.dev/); | |
| 160 | − if (!match?.[0]) { | |
| 161 | − return null; | |
| 162 | − } | |
| 163 | − return `${match[0]}/v1/runs`; | |
| 164 | −} | |
| 165 | − | |
| 166 | −async function deploySandboxHostIfNeeded(enabled: boolean): Promise<string | null> { | |
| 167 | − if (!enabled) { | |
| 168 | − return null; | |
| 169 | − } | |
| 170 | − | |
| 171 | − console.log("[setup] deploying sandbox host worker..."); | |
| 172 | − const deploy = await runCommand(["bunx", "wrangler", "deploy"], { cwd: SANDBOX_HOST_DIR }); | |
| 173 | − if (deploy.exitCode !== 0) { | |
| 174 | − throw new Error(`wrangler deploy failed:\n${deploy.stderr || deploy.stdout}`); | |
| 175 | − } | |
| 176 | − | |
| 177 | − const combined = `${deploy.stdout}\n${deploy.stderr}`; | |
| 178 | − const inferred = inferWorkersUrlFromDeployOutput(combined); | |
| 179 | − if (inferred) { | |
| 180 | − console.log(`[setup] inferred run URL from deploy output: ${inferred}`); | |
| 181 | − } | |
| 182 | − return inferred; | |
| 183 | −} | |
| 184 | − | |
| 185 | −async function putWorkerSecret(secretName: string, value: string): Promise<void> { | |
| 186 | − const workerName = await parseSandboxWorkerName(); | |
| 187 | − console.log(`[setup] setting wrangler secret ${secretName} on ${workerName}...`); | |
| 188 | − | |
| 189 | − const result = await runCommand( | |
| 190 | − ["bunx", "wrangler", "secret", "put", secretName, "--name", workerName], | |
| 191 | − { cwd: SANDBOX_HOST_DIR, stdinData: value }, | |
| 192 | − ); | |
| 193 | − if (result.exitCode !== 0) { | |
| 194 | − throw new Error(`wrangler secret put ${secretName} failed:\n${result.stderr || result.stdout}`); | |
| 195 | − } | |
| 196 | −} | |
| 197 | − | |
| 198 | −async function setConvexEnv(key: string, value: string): Promise<void> { | |
| 199 | − const result = await runConvex(["env", "set", key, value, "--prod"]); | |
| 200 | − if (result.exitCode !== 0) { | |
| 201 | − throw new Error(`convex env set ${key} failed:\n${result.stderr || result.stdout}`); | |
| 202 | − } | |
| 203 | −} | |
| 204 | − | |
| 205 | −async function getProdEnvMap(): Promise<Map<string, string>> { | |
| 206 | − const envResult = await runConvex(["env", "list", "--prod"]); | |
| 207 | − if (envResult.exitCode !== 0) { | |
| 208 | − throw new Error(`convex env list --prod failed:\n${envResult.stderr || envResult.stdout}`); | |
| 209 | − } | |
| 210 | − return parseConvexEnvList(envResult.stdout); | |
| 211 | −} | |
| 212 | − | |
| 213 | −function resolveConvexUrl(options: Options, env: Map<string, string>): string { | |
| 214 | − const selected = options.convexUrl?.trim() | |
| 215 | − || env.get("CONVEX_URL")?.trim() | |
| 216 | − || Bun.env.CONVEX_URL?.trim(); | |
| 217 | − | |
| 218 | − if (!selected) { | |
| 219 | − throw new Error( | |
| 220 | − "Could not determine CONVEX_URL. Pass --convex-url or set CONVEX_URL in your environment.", | |
| 221 | − ); | |
| 222 | − } | |
| 223 | − | |
| 224 | − ensureHttpsUrl("CONVEX_URL", selected); | |
| 225 | − return selected; | |
| 226 | −} | |
| 227 | − | |
| 228 | −function resolveRunUrl(options: Options, env: Map<string, string>, deployedRunUrl: string | null): string { | |
| 229 | − const selected = options.runUrl?.trim() | |
| 230 | − || deployedRunUrl | |
| 231 | − || env.get("CLOUDFLARE_SANDBOX_RUN_URL")?.trim(); | |
| 232 | − | |
| 233 | − if (!selected) { | |
| 234 | − throw new Error( | |
| 235 | − "Could not determine CLOUDFLARE_SANDBOX_RUN_URL. Pass --run-url or allow deploy to infer it.", | |
| 236 | − ); | |
| 237 | − } | |
| 238 | − | |
| 239 | − ensureHttpsUrl("CLOUDFLARE_SANDBOX_RUN_URL", selected); | |
| 240 | − if (!selected.includes("/v1/runs")) { | |
| 241 | − throw new Error(`CLOUDFLARE_SANDBOX_RUN_URL must include /v1/runs: ${selected}`); | |
| 242 | − } | |
| 243 | − return selected; | |
| 244 | −} | |
| 245 | − | |
| 246 | −async function main(): Promise<void> { | |
| 247 | − const options = parseArgs(process.argv.slice(2)); | |
| 248 | − const env = await getProdEnvMap(); | |
| 249 | − | |
| 250 | − const deployedRunUrl = await deploySandboxHostIfNeeded(options.deploy); | |
| 251 | − | |
| 252 | − const runUrl = resolveRunUrl(options, env, deployedRunUrl); | |
| 253 | − const convexUrl = resolveConvexUrl(options, env); | |
| 254 | − const authToken = options.authToken?.trim() || env.get("CLOUDFLARE_SANDBOX_AUTH_TOKEN")?.trim() || randomToken(); | |
| 255 | − const internalToken = options.internalToken?.trim() || env.get("EXECUTOR_INTERNAL_TOKEN")?.trim() || randomToken(); | |
| 256 | − | |
| 257 | − await putWorkerSecret("AUTH_TOKEN", authToken); | |
| 258 | − | |
| 259 | − console.log("[setup] setting production Convex env vars..."); | |
| 260 | − await setConvexEnv("CLOUDFLARE_SANDBOX_RUN_URL", runUrl); | |
| 261 | − await setConvexEnv("CLOUDFLARE_SANDBOX_AUTH_TOKEN", authToken); | |
| 262 | − await setConvexEnv("EXECUTOR_INTERNAL_TOKEN", internalToken); | |
| 263 | − await setConvexEnv("CONVEX_URL", convexUrl); | |
| 264 | − | |
| 265 | − console.log("\n[setup] cloudflare production runtime configured"); | |
| 266 | − console.log(` CLOUDFLARE_SANDBOX_RUN_URL: ${runUrl}`); | |
| 267 | − console.log(` CLOUDFLARE_SANDBOX_AUTH_TOKEN: ${redact(authToken)}`); | |
| 268 | − console.log(` EXECUTOR_INTERNAL_TOKEN: ${redact(internalToken)}`); | |
| 269 | − console.log(` CONVEX_URL: ${convexUrl}`); | |
| 270 | − | |
| 271 | − if (options.runDoctor) { | |
| 272 | − console.log("\n[setup] running doctor:prod..."); | |
| 273 | − const doctor = await runCommand(["bun", "run", "doctor:prod"], { cwd: EXECUTOR_ROOT }); | |
| 274 | − const output = [doctor.stdout.trim(), doctor.stderr.trim()].filter(Boolean).join("\n"); | |
| 275 | − if (output) { | |
| 276 | − console.log(output); | |
| 277 | − } | |
| 278 | − if (doctor.exitCode !== 0) { | |
| 279 | − process.exit(doctor.exitCode); | |
| 280 | − } | |
| 281 | − } | |
| 282 | −} | |
| 283 | − | |
| 284 | −try { | |
| 285 | − await main(); | |
| 286 | −} catch (error) { | |
| 287 | − const message = error instanceof Error ? error.message : String(error); | |
| 288 | − console.error(`[setup] failed: ${message}`); | |
| 289 | − process.exit(1); | |
| 290 | −} | |
| @@ -1,165 +0,0 @@ | ||
| 1 | −interface CommandResult { | |
| 2 | − exitCode: number; | |
| 3 | − stdout: string; | |
| 4 | − stderr: string; | |
| 5 | −} | |
| 6 | − | |
| 7 | −interface Options { | |
| 8 | − fromEnv: boolean; | |
| 9 | − strict: boolean; | |
| 10 | −} | |
| 11 | − | |
| 12 | −const REQUIRED_KEYS = [ | |
| 13 | − "WORKOS_CLIENT_ID", | |
| 14 | − "WORKOS_API_KEY", | |
| 15 | − "WORKOS_WEBHOOK_SECRET", | |
| 16 | − "WORKOS_COOKIE_PASSWORD", | |
| 17 | − "STRIPE_SECRET_KEY", | |
| 18 | − "STRIPE_WEBHOOK_SECRET", | |
| 19 | − "STRIPE_PRICE_ID", | |
| 20 | − "MCP_AUTHORIZATION_SERVER", | |
| 21 | −] as const; | |
| 22 | − | |
| 23 | −const OPTIONAL_KEYS = [ | |
| 24 | − "BILLING_SUCCESS_URL", | |
| 25 | − "BILLING_CANCEL_URL", | |
| 26 | − "BILLING_RETURN_URL", | |
| 27 | −] as const; | |
| 28 | − | |
| 29 | −function printHelp(): void { | |
| 30 | − console.log(`Setup production Convex app env vars | |
| 31 | − | |
| 32 | −Usage: | |
| 33 | − bun run scripts/prod/setup/prod-env.ts --from-env [--strict] | |
| 34 | − | |
| 35 | −Options: | |
| 36 | − --from-env Read values from current shell env and push to Convex prod | |
| 37 | − --strict Fail if any required key is missing locally | |
| 38 | − -h, --help Show this help | |
| 39 | − | |
| 40 | −Required local env keys: | |
| 41 | − ${REQUIRED_KEYS.join("\n ")} | |
| 42 | − | |
| 43 | −Optional local env keys: | |
| 44 | − ${OPTIONAL_KEYS.join("\n ")} | |
| 45 | −`); | |
| 46 | −} | |
| 47 | − | |
| 48 | −function parseArgs(argv: string[]): Options { | |
| 49 | − const options: Options = { | |
| 50 | − fromEnv: false, | |
| 51 | − strict: false, | |
| 52 | − }; | |
| 53 | − | |
| 54 | − for (const arg of argv) { | |
| 55 | − if (arg === "--from-env") { | |
| 56 | − options.fromEnv = true; | |
| 57 | − continue; | |
| 58 | − } | |
| 59 | − if (arg === "--strict") { | |
| 60 | − options.strict = true; | |
| 61 | − continue; | |
| 62 | − } | |
| 63 | − if (arg === "--help" || arg === "-h") { | |
| 64 | − printHelp(); | |
| 65 | − process.exit(0); | |
| 66 | − } | |
| 67 | − throw new Error(`Unknown option: ${arg}`); | |
| 68 | − } | |
| 69 | − | |
| 70 | − if (!options.fromEnv) { | |
| 71 | − throw new Error("Use --from-env to read values from your local environment."); | |
| 72 | − } | |
| 73 | − | |
| 74 | − return options; | |
| 75 | −} | |
| 76 | − | |
| 77 | −async function runCommand(command: string[]): Promise<CommandResult> { | |
| 78 | − const proc = Bun.spawn(command, { | |
| 79 | − env: Bun.env, | |
| 80 | − stdin: "ignore", | |
| 81 | − stdout: "pipe", | |
| 82 | − stderr: "pipe", | |
| 83 | − }); | |
| 84 | − | |
| 85 | − const [stdout, stderr, exitCode] = await Promise.all([ | |
| 86 | − new Response(proc.stdout).text(), | |
| 87 | − new Response(proc.stderr).text(), | |
| 88 | − proc.exited, | |
| 89 | − ]); | |
| 90 | − | |
| 91 | − return { | |
| 92 | − exitCode, | |
| 93 | − stdout, | |
| 94 | − stderr, | |
| 95 | − }; | |
| 96 | −} | |
| 97 | − | |
| 98 | −async function setConvexEnv(key: string, value: string): Promise<void> { | |
| 99 | − const result = await runCommand(["bunx", "convex", "env", "set", key, value, "--prod"]); | |
| 100 | − if (result.exitCode !== 0) { | |
| 101 | − throw new Error(`Failed setting ${key}: ${result.stderr || result.stdout}`); | |
| 102 | − } | |
| 103 | −} | |
| 104 | − | |
| 105 | −function redact(value: string): string { | |
| 106 | − if (value.length < 8) return "********"; | |
| 107 | − return `${value.slice(0, 4)}...${value.slice(-4)}`; | |
| 108 | −} | |
| 109 | − | |
| 110 | −async function main(): Promise<void> { | |
| 111 | − const options = parseArgs(process.argv.slice(2)); | |
| 112 | − | |
| 113 | − const missingRequired: string[] = []; | |
| 114 | − const toSet: Array<{ key: string; value: string; optional: boolean }> = []; | |
| 115 | − | |
| 116 | − for (const key of REQUIRED_KEYS) { | |
| 117 | − const value = Bun.env[key]?.trim(); | |
| 118 | − if (!value) { | |
| 119 | − missingRequired.push(key); | |
| 120 | − continue; | |
| 121 | − } | |
| 122 | − toSet.push({ key, value, optional: false }); | |
| 123 | − } | |
| 124 | − | |
| 125 | − for (const key of OPTIONAL_KEYS) { | |
| 126 | − const value = Bun.env[key]?.trim(); | |
| 127 | − if (!value) { | |
| 128 | − continue; | |
| 129 | − } | |
| 130 | − toSet.push({ key, value, optional: true }); | |
| 131 | − } | |
| 132 | − | |
| 133 | − if (missingRequired.length > 0) { | |
| 134 | − console.log("[setup] missing required local env vars:"); | |
| 135 | − for (const key of missingRequired) { | |
| 136 | − console.log(` - ${key}`); | |
| 137 | − } | |
| 138 | − if (options.strict) { | |
| 139 | − throw new Error("Required local env vars are missing and --strict was set."); | |
| 140 | − } | |
| 141 | − console.log("[setup] continuing with available values (use --strict to enforce all required vars)"); | |
| 142 | − } | |
| 143 | − | |
| 144 | − if (toSet.length === 0) { | |
| 145 | − throw new Error("No env vars found to set. Export values and rerun with --from-env."); | |
| 146 | − } | |
| 147 | − | |
| 148 | − console.log("[setup] pushing env vars to Convex prod..."); | |
| 149 | − for (const item of toSet) { | |
| 150 | − await setConvexEnv(item.key, item.value); | |
| 151 | − console.log(` - ${item.key}=${redact(item.value)}${item.optional ? " (optional)" : ""}`); | |
| 152 | − } | |
| 153 | − | |
| 154 | − console.log("[setup] done."); | |
| 155 | −} | |
| 156 | − | |
| 157 | −try { | |
| 158 | − await main(); | |
| 159 | −} catch (error) { | |
| 160 | − const message = error instanceof Error ? error.message : String(error); | |
| 161 | − console.error(`[setup] failed: ${message}`); | |
| 162 | − process.exit(1); | |
| 163 | −} | |
| 164 | − | |
| 165 | −export {}; | |
| @@ -12,15 +12,6 @@ · 11 unmodified lines | ||
| 12 | 12 | "scripts": { |
| 13 | 13 | "with-env": "bun run --env-file=.env --", |
| 14 | 14 | "dev": "bun run dev.ts", |
| 15 | − "kill:all": "bun run kill-all.ts", | |
| 16 | − "dev:sources": "bun run --cwd sources dev", | |
| 17 | − "sources:sync": "bun run --cwd sources sync", | |
| 18 | − "dev:executor:convex": "bunx --cwd executor convex dev", | |
| 19 | − "dev:executor:web": "bun run --cwd executor/apps/web dev", | |
| 20 | − "dev:executor:menubar": "bun run --cwd executor/apps/menubar dev", | |
| 21 | − "dev:executor:all": "bun run dev:executor:convex & bun run dev:executor:web", | |
| 22 | − "dev:assistant": "bun run --cwd assistant/packages/server dev", | |
| 23 | − "dev:bot": "bun run --cwd assistant/packages/bot dev", | |
| 24 | 15 | "test": "bun run test:executor && bun run test:assistant", |
| 25 | 16 | "test:executor": "bun run --cwd executor test", |
| 26 | 17 | "test:executor:integration": "bun run --cwd executor test:integration", |
| @@ -30,18 +21,10 @@ · 3 unmodified lines | ||
| 30 | 21 | "typecheck": "bun run typecheck:workspaces", |
| 31 | 22 | "typecheck:slow": "bun run typecheck:workspaces:slow", |
| 32 | 23 | "typecheck:workspaces": "bun run --filter \"@executor/*\" --filter \"@proto/sources\" --filter \"@assistant/*\" --filter \"@openassistant/reacord\" --if-present typecheck", |
| 33 | − "typecheck:workspaces:slow": "bun run --filter \"@executor/*\" --filter \"@proto/sources\" --filter \"@assistant/*\" --filter \"@openassistant/reacord\" --if-present typecheck:slow", | |
| 34 | − "typecheck:executor": "bunx --package @typescript/native-preview tsgo --noEmit -p executor/tsconfig.json", | |
| 35 | − "typecheck:executor:slow": "bunx tsc --noEmit -p executor/tsconfig.json", | |
| 36 | − "typecheck:assistant": "bunx --package @typescript/native-preview tsgo --noEmit -p assistant/packages/server/tsconfig.json && bunx --package @typescript/native-preview tsgo --noEmit -p assistant/packages/bot/tsconfig.json", | |
| 37 | − "typecheck:assistant:slow": "bunx tsc --noEmit -p assistant/packages/server/tsconfig.json && bunx tsc --noEmit -p assistant/packages/bot/tsconfig.json", | |
| 38 | 24 | "clean": "bun run --filter '*' --if-present clean && rm -rf node_modules .turbo", |
| 39 | 25 | "knip": "bunx knip --config knip.json", |
| 40 | 26 | "db:clear:executor": "bun with-env bun run executor/scripts/dev/wipe/convex-db.ts", |
| 41 | − "convex:codegen": "bunx --cwd executor convex codegen", | |
| 42 | − "smoke:storage:execute-flow:local": "bun with-env bun run --cwd executor smoke:storage:execute-flow:local", | |
| 43 | − "smoke:storage:execute-flow": "bun with-env bun run --cwd executor smoke:storage:execute-flow", | |
| 44 | − "smoke:storage:execute-flow:cloudflare": "bun with-env bun run --cwd executor smoke:storage:execute-flow:cloudflare" | |
| 27 | + "convex:codegen": "bunx --cwd executor convex codegen" | |
| 45 | 28 | }, |
| 46 | 29 | "dependencies": { |
| 47 | 30 | "@apidevtools/swagger-parser": "^12.1.0", |