Unified · Split
| @@ -79,11 +79,6 @@ bun run kill:all · 78 unmodified lines | |||
| 79 | # Development | 79 | # Development |
| 80 | bun run dev | 80 | bun run dev |
| 81 | bun run kill:all | 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 | # Quality | 83 | # Quality |
| 89 | bun run test | 84 | bun run test |
| @@ -4,8 +4,6 @@ · 3 unmodified lines | |||
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "scripts": { | 5 | "scripts": { |
| 6 | "test": "bun test packages/core/src/", | 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 | "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", | 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 | "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", | 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 | "clean": "rm -rf node_modules .turbo" | 9 | "clean": "rm -rf node_modules .turbo" |
| @@ -213,6 +213,11 @@ function buildSourceTreeWithLoading( · 212 unmodified lines | |||
| 213 | continue; | 213 | continue; |
| 214 | } | 214 | } |
| 215 | 215 | ||
| 216 | const hasVisibleChildren = existing.children.length > 0 || existing.childCount > 0; | ||
| 217 | if (hasVisibleChildren) { | ||
| 218 | continue; | ||
| 219 | } | ||
| 220 | |||
| 216 | groupsBySource.set(sourceName, { | 221 | groupsBySource.set(sourceName, { |
| 217 | ...existing, | 222 | ...existing, |
| 218 | loadingPlaceholderCount: existing.loadingPlaceholderCount ?? 3, | 223 | loadingPlaceholderCount: existing.loadingPlaceholderCount ?? 3, |
| @@ -229,6 +229,7 @@ export function NavGroupNode({ · 228 unmodified lines | |||
| 229 | onFocusTool, | 229 | onFocusTool, |
| 230 | onSourceClick, | 230 | onSourceClick, |
| 231 | source, | 231 | source, |
| 232 | sourceState, | ||
| 232 | }: { | 233 | }: { |
| 233 | group: ToolGroup; | 234 | group: ToolGroup; |
| 234 | depth: number; | 235 | depth: number; |
| @@ -239,14 +240,24 @@ export function NavGroupNode({ · 4 unmodified lines | |||
| 239 | onFocusTool: (tool: ToolDescriptor) => void; | 240 | onFocusTool: (tool: ToolDescriptor) => void; |
| 240 | onSourceClick?: (sourceName: string) => void; | 241 | onSourceClick?: (sourceName: string) => void; |
| 241 | source?: ToolSourceRecord; | 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 | const isExpanded = expandedKeys.has(group.key); | 251 | const isExpanded = expandedKeys.has(group.key); |
| 244 | const isSource = group.type === "source"; | 252 | const isSource = group.type === "source"; |
| 245 | const isSourceFocused = isSource && focusedSource === group.label; | 253 | const isSourceFocused = isSource && focusedSource === group.label; |
| 246 | const isLoading = | 254 | const isLoadingFromTree = |
| 247 | group.type === "source" && | 255 | group.type === "source" && |
| 248 | typeof group.loadingPlaceholderCount === "number" && | 256 | typeof group.loadingPlaceholderCount === "number" && |
| 249 | group.loadingPlaceholderCount > 0; | 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 | const sourceTypeFallback: ToolSourceRecord["type"] | "local" | "system" = source | 261 | const sourceTypeFallback: ToolSourceRecord["type"] | "local" | "system" = source |
| 251 | ? source.type | 262 | ? source.type |
| 252 | : group.label === "system" | 263 | : group.label === "system" |
| @@ -317,8 +328,22 @@ export function NavGroupNode({ · 64 unmodified lines | |||
| 317 | </span> | 328 | </span> |
| 318 | 329 | ||
| 319 | <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground/60"> | 330 | <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground/60"> |
| 320 | {isLoading ? ( | 331 | {sourceState?.state === "failed" ? ( |
| 321 | <Loader2 className="h-2.5 w-2.5 animate-spin" /> | 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 | {group.approvalCount > 0 && ( | 349 | {group.approvalCount > 0 && ( |
| @@ -327,7 +352,7 @@ export function NavGroupNode({ · 2 unmodified lines | |||
| 327 | {group.approvalCount} | 352 | {group.approvalCount} |
| 328 | </span> | 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 | </span> | 358 | </span> |
| @@ -388,6 +413,7 @@ export function NavGroupNode({ · 54 unmodified lines | |||
| 388 | focusedSource={focusedSource} | 413 | focusedSource={focusedSource} |
| 389 | onFocusTool={onFocusTool} | 414 | onFocusTool={onFocusTool} |
| 390 | onSourceClick={onSourceClick} | 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 | : null} | 432 | : null} |
| 407 | 433 | ||
| 408 | {isLoading ? ( | 434 | {showLoadingRows ? ( |
| 409 | <ToolLoadingRows | 435 | <ToolLoadingRows |
| 410 | source={displayLabel} | 436 | source={displayLabel} |
| 411 | count={group.loadingPlaceholderCount ?? 3} | 437 | count={group.loadingPlaceholderCount ?? 3} |
| @@ -445,7 +471,7 @@ export function SourceSidebar({ · 33 unmodified lines | |||
| 445 | onSourceDeleted?: (sourceName: string) => void; | 471 | onSourceDeleted?: (sourceName: string) => void; |
| 446 | onRegenerate?: () => void; | 472 | onRegenerate?: () => void; |
| 447 | isRebuilding?: boolean; | 473 | isRebuilding?: boolean; |
| 448 | inventoryState?: "initializing" | "ready" | "rebuilding" | "stale" | "failed"; | 474 | inventoryState?: "initializing" | "ready" | "rebuilding" | "failed"; |
| 449 | inventoryError?: string; | 475 | inventoryError?: string; |
| 450 | }) { | 476 | }) { |
| 451 | const warningCountsBySource = useMemo(() => { | 477 | const warningCountsBySource = useMemo(() => { |
| @@ -464,8 +490,6 @@ export function SourceSidebar({ · 12 unmodified lines | |||
| 464 | ); | 490 | ); |
| 465 | const loadingSourceCount = loadingSources.size; | 491 | const loadingSourceCount = loadingSources.size; |
| 466 | const regenerationInProgress = isRebuilding; | 492 | const regenerationInProgress = isRebuilding; |
| 467 | const inventoryStale = inventoryState === "stale"; | ||
| 468 | |||
| 469 | const inventoryStatus = useMemo(() => { | 493 | const inventoryStatus = useMemo(() => { |
| 470 | if (!inventoryState) { | 494 | if (!inventoryState) { |
| 471 | return { | 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 | if (inventoryState === "failed") { | 521 | if (inventoryState === "failed") { |
| 505 | return { | 522 | return { |
| 506 | label: inventoryError ? `Refresh failed: ${inventoryError}` : "Refresh failed", | 523 | label: inventoryError ? `Refresh failed: ${inventoryError}` : "Refresh failed", |
| @@ -566,17 +583,13 @@ export function SourceSidebar({ · 59 unmodified lines | |||
| 566 | title={ | 583 | title={ |
| 567 | regenerationInProgress | 584 | regenerationInProgress |
| 568 | ? "Rebuilding..." | 585 | ? "Rebuilding..." |
| 569 | : inventoryStale | 586 | : "Regenerate inventory" |
| 570 | ? "Inventory is out of date" | ||
| 571 | : "Regenerate inventory" | ||
| 572 | } | 587 | } |
| 573 | className={cn( | 588 | className={cn( |
| 574 | "p-0.5 rounded transition-colors", | 589 | "p-0.5 rounded transition-colors", |
| 575 | regenerationInProgress | 590 | regenerationInProgress |
| 576 | ? "text-terminal-amber cursor-not-allowed" | 591 | ? "text-terminal-amber cursor-not-allowed" |
| 577 | : inventoryStale | 592 | : "text-muted-foreground/40 hover:text-muted-foreground/70", |
| 578 | ? "text-muted-foreground/70 hover:text-muted-foreground" | ||
| 579 | : "text-muted-foreground/40 hover:text-muted-foreground/70", | ||
| 580 | )} | 593 | )} |
| 581 | > | 594 | > |
| 582 | <RefreshCcw className={cn("h-3 w-3", regenerationInProgress && "animate-spin")} /> | 595 | <RefreshCcw className={cn("h-3 w-3", regenerationInProgress && "animate-spin")} /> |
| @@ -18,7 +18,6 @@ import { · 17 unmodified lines | |||
| 18 | type FilterApproval, | 18 | type FilterApproval, |
| 19 | } from "./explorer-derived"; | 19 | } from "./explorer-derived"; |
| 20 | import { sourceLabel } from "@/lib/tool/source-utils"; | 20 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| 21 | import { traverseSchema } from "@/lib/tool/schema-traverse"; | ||
| 22 | import { | 21 | import { |
| 23 | EmptyState, | 22 | EmptyState, |
| 24 | LoadingState, | 23 | LoadingState, |
| @@ -37,27 +36,6 @@ import { warningsBySourceName } from "@/lib/tools/source-helpers"; · 12 unmodified lines | |||
| 37 | 36 | ||
| 38 | // ── Main Explorer ── | 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 | interface ToolExplorerProps { | 39 | interface ToolExplorerProps { |
| 62 | tools: ToolDescriptor[]; | 40 | tools: ToolDescriptor[]; |
| 63 | sources: ToolSourceRecord[]; | 41 | sources: ToolSourceRecord[]; |
| @@ -71,6 +49,13 @@ interface ToolExplorerProps { · 7 unmodified lines | |||
| 71 | onLoadMoreToolsForSource?: (source: { source: string; sourceName: string }) => Promise<void>; | 49 | onLoadMoreToolsForSource?: (source: { source: string; sourceName: string }) => Promise<void>; |
| 72 | loading?: boolean; | 50 | loading?: boolean; |
| 73 | loadingSources?: string[]; | 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 | onLoadToolDetails?: (toolPaths: string[]) => Promise<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>; | 59 | onLoadToolDetails?: (toolPaths: string[]) => Promise<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>; |
| 75 | warnings?: string[]; | 60 | warnings?: string[]; |
| 76 | activeSource: string | null; | 61 | activeSource: string | null; |
| @@ -89,7 +74,7 @@ interface ToolExplorerProps { · 12 unmodified lines | |||
| 89 | onSourceDeleted?: (sourceName: string) => void; | 74 | onSourceDeleted?: (sourceName: string) => void; |
| 90 | onRegenerate?: () => void; | 75 | onRegenerate?: () => void; |
| 91 | isRebuilding?: boolean; | 76 | isRebuilding?: boolean; |
| 92 | inventoryState?: "initializing" | "ready" | "rebuilding" | "stale" | "failed"; | 77 | inventoryState?: "initializing" | "ready" | "rebuilding" | "failed"; |
| 93 | inventoryError?: string; | 78 | inventoryError?: string; |
| 94 | } | 79 | } |
| 95 | 80 | ||
| @@ -103,6 +88,7 @@ export function ToolExplorer({ · 7 unmodified lines | |||
| 103 | sourceLoadingMoreTools, | 88 | sourceLoadingMoreTools, |
| 104 | loading = false, | 89 | loading = false, |
| 105 | loadingSources = [], | 90 | loadingSources = [], |
| 91 | sourceStates = {}, | ||
| 106 | onLoadToolDetails, | 92 | onLoadToolDetails, |
| 107 | warnings = [], | 93 | warnings = [], |
| 108 | activeSource, | 94 | activeSource, |
| @@ -124,21 +110,6 @@ export function ToolExplorer({ · 15 unmodified lines | |||
| 124 | inventoryState, | 110 | inventoryState, |
| 125 | inventoryError, | 111 | inventoryError, |
| 126 | }: ToolExplorerProps) { | 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 | const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({}); | 113 | const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({}); |
| 143 | const [loadingDetailPaths, setLoadingDetailPaths] = useState<Set<string>>(new Set()); | 114 | const [loadingDetailPaths, setLoadingDetailPaths] = useState<Set<string>>(new Set()); |
| 144 | // "new" = add source form, ToolSourceRecord = edit/view existing source, null = no source panel | 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 | return; | 251 | return; |
| 281 | } | 252 | } |
| 282 | 253 | ||
| 283 | const hasDetails = hasRenderableToolDetails(tool); | 254 | if (toolDetailsByPath[tool.path]) { |
| 284 | |||
| 285 | if (hasDetails || toolDetailsByPath[tool.path]) { | ||
| 286 | return; | 255 | return; |
| 287 | } | 256 | } |
| 288 | 257 | ||
| @@ -309,7 +278,7 @@ export function ToolExplorer({ · 20 unmodified lines | |||
| 309 | return next; | 278 | return next; |
| 310 | }); | 279 | }); |
| 311 | } | 280 | } |
| 312 | }, [hasRenderableToolDetails, loadingDetailPaths, onLoadToolDetails, toolDetailsByPath]); | 281 | }, [loadingDetailPaths, onLoadToolDetails, toolDetailsByPath]); |
| 313 | 282 | ||
| 314 | // ── Focus tool handler ────────────────────────────────────────────────── | 283 | // ── Focus tool handler ────────────────────────────────────────────────── |
| 315 | 284 | ||
| @@ -359,12 +328,16 @@ export function ToolExplorer({ · 43 unmodified lines | |||
| 359 | options?: SourceAddedOptions, | 328 | options?: SourceAddedOptions, |
| 360 | ) => { | 329 | ) => { |
| 361 | onSourceAdded?.(source, options); | 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 | setSourceFocusState(source.name, source); | 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 | }, [onSourceAdded, setSourceFocusState]); | 341 | }, [onSourceAdded, setSourceFocusState]); |
| 369 | 342 | ||
| 370 | const focusedTool = useMemo(() => { | 343 | const focusedTool = useMemo(() => { |
| @@ -377,6 +350,20 @@ export function ToolExplorer({ · 6 unmodified lines | |||
| 377 | return sourceByName.get(focusedSourceName) ?? null; | 350 | return sourceByName.get(focusedSourceName) ?? null; |
| 378 | }, [focusedSourceName, sourceByName]); | 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 | useEffect(() => { | 367 | useEffect(() => { |
| 381 | if (formSource !== null && focusedToolPath) { | 368 | if (formSource !== null && focusedToolPath) { |
| 382 | setFocusedToolPath(null); | 369 | setFocusedToolPath(null); |
| @@ -446,39 +433,49 @@ export function ToolExplorer({ · 63 unmodified lines | |||
| 446 | 433 | ||
| 447 | const inventoryStatus = useMemo(() => { | 434 | const inventoryStatus = useMemo(() => { |
| 448 | const loadingSourceCount = loadingSourceSet.size; | 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 | if (!inventoryState) { | 441 | if (!inventoryState) { |
| 451 | return { label: "Checking...", tone: "muted" as const }; | 442 | return { label: "Checking...", tone: "muted" as const }; |
| 452 | } | 443 | } |
| 453 | if (inventoryState === "initializing") { | 444 | if (inventoryState === "initializing") { |
| 454 | const w = loadingSourceCount === 1 ? "source" : "sources"; | 445 | const progress = totalSourceCount > 0 |
| 446 | ? `${readySourceCount}/${totalSourceCount}` | ||
| 447 | : `${loadingSourceCount}`; | ||
| 455 | return { | 448 | return { |
| 456 | label: loadingSourceCount > 0 ? `Building (${loadingSourceCount} ${w})` : "Building...", | 449 | label: `Building ${progress}`, |
| 457 | tone: "loading" as const, | 450 | tone: "loading" as const, |
| 458 | }; | 451 | }; |
| 459 | } | 452 | } |
| 460 | if (inventoryState === "rebuilding") { | 453 | if (inventoryState === "rebuilding") { |
| 461 | const w = loadingSourceCount === 1 ? "source" : "sources"; | 454 | const progress = totalSourceCount > 0 |
| 455 | ? `${readySourceCount}/${totalSourceCount}` | ||
| 456 | : `${loadingSourceCount}`; | ||
| 462 | return { | 457 | return { |
| 463 | label: loadingSourceCount > 0 ? `Refreshing (${loadingSourceCount} ${w})` : "Refreshing...", | 458 | label: `Refreshing ${progress}`, |
| 464 | tone: "loading" as const, | 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 | if (inventoryState === "failed") { | 462 | if (inventoryState === "failed") { |
| 471 | return { | 463 | return { |
| 472 | label: inventoryError ? `Failed: ${inventoryError}` : "Failed", | 464 | label: inventoryError |
| 465 | ? `Failed: ${inventoryError}` | ||
| 466 | : failedSourceCount > 0 | ||
| 467 | ? `Failed (${failedSourceCount} sources)` | ||
| 468 | : "Failed", | ||
| 473 | tone: "error" as const, | 469 | tone: "error" as const, |
| 474 | }; | 470 | }; |
| 475 | } | 471 | } |
| 476 | return { label: "Up to date", tone: "muted" as const }; | 472 | return { |
| 477 | }, [inventoryError, inventoryState, loadingSourceSet.size]); | 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 | const regenerationInProgress = isRebuilding; | 478 | const regenerationInProgress = isRebuilding; |
| 480 | const inventoryStale = inventoryState === "stale"; | ||
| 481 | |||
| 482 | // ── Render ────────────────────────────────────────────────────────────── | 479 | // ── Render ────────────────────────────────────────────────────────────── |
| 483 | 480 | ||
| 484 | return ( | 481 | return ( |
| @@ -519,17 +516,13 @@ export function ToolExplorer({ · 34 unmodified lines | |||
| 519 | title={ | 516 | title={ |
| 520 | regenerationInProgress | 517 | regenerationInProgress |
| 521 | ? "Rebuilding..." | 518 | ? "Rebuilding..." |
| 522 | : inventoryStale | 519 | : "Regenerate inventory" |
| 523 | ? "Out of date — click to refresh" | ||
| 524 | : "Regenerate inventory" | ||
| 525 | } | 520 | } |
| 526 | className={cn( | 521 | className={cn( |
| 527 | "p-0.5 rounded transition-colors", | 522 | "p-0.5 rounded transition-colors", |
| 528 | regenerationInProgress | 523 | regenerationInProgress |
| 529 | ? "text-terminal-amber cursor-not-allowed" | 524 | ? "text-terminal-amber cursor-not-allowed" |
| 530 | : inventoryStale | 525 | : "text-muted-foreground/30 hover:text-muted-foreground/60", |
| 531 | ? "text-muted-foreground/70 hover:text-muted-foreground" | ||
| 532 | : "text-muted-foreground/30 hover:text-muted-foreground/60", | ||
| 533 | )} | 526 | )} |
| 534 | > | 527 | > |
| 535 | <RefreshCcw className={cn("h-3 w-3", regenerationInProgress && "animate-spin")} /> | 528 | <RefreshCcw className={cn("h-3 w-3", regenerationInProgress && "animate-spin")} /> |
| @@ -599,6 +592,7 @@ export function ToolExplorer({ · 63 unmodified lines | |||
| 599 | onFocusTool={handleFocusTool} | 592 | onFocusTool={handleFocusTool} |
| 600 | onSourceClick={handleSourceClick} | 593 | onSourceClick={handleSourceClick} |
| 601 | source={sourceByName.get(group.label) ?? undefined} | 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 | <SourceFormPanel | 609 | <SourceFormPanel |
| 616 | existingSourceNames={sidebarExistingSourceNames} | 610 | existingSourceNames={sidebarExistingSourceNames} |
| 617 | sourceToEdit={formSource === "new" ? undefined : formSource} | 611 | sourceToEdit={formSource === "new" ? undefined : formSource} |
| 612 | sourceProgress={formSourceProgress} | ||
| 618 | sourceDialogMeta={formSource !== "new" ? sourceDialogMeta?.[formSource.name] : undefined} | 613 | sourceDialogMeta={formSource !== "new" ? sourceDialogMeta?.[formSource.name] : undefined} |
| 619 | sourceAuthProfiles={sourceAuthProfiles} | 614 | sourceAuthProfiles={sourceAuthProfiles} |
| 620 | onSourceAdded={handleSourceFormAdded} | 615 | onSourceAdded={handleSourceFormAdded} |
| @@ -626,6 +621,80 @@ export function ToolExplorer({ · 5 unmodified lines | |||
| 626 | tool={focusedTool} | 621 | tool={focusedTool} |
| 627 | loading={loadingDetailPaths.has(focusedTool.path)} | 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 | <ToolDetailEmpty /> | 699 | <ToolDetailEmpty /> |
| 631 | )} | 700 | )} |
| @@ -7,6 +7,7 @@ import { toast } from "sonner"; · 6 unmodified lines | |||
| 7 | import { | 7 | import { |
| 8 | AlertTriangle, | 8 | AlertTriangle, |
| 9 | ArrowLeft, | 9 | ArrowLeft, |
| 10 | CheckCircle2, | ||
| 10 | Loader2, | 11 | Loader2, |
| 11 | Trash2, | 12 | Trash2, |
| 12 | } from "lucide-react"; | 13 | } from "lucide-react"; |
| @@ -49,11 +50,70 @@ function ownerScopeBadge(scopeType: ToolSourceRecord["scopeType"] | undefined): · 36 unmodified lines | |||
| 49 | return scopeType === "organization" ? "org shared" : "workspace only"; | 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 | // ── Main Panel ────────────────────────────────────────────────────────────── | 111 | // ── Main Panel ────────────────────────────────────────────────────────────── |
| 53 | 112 | ||
| 54 | export function SourceFormPanel({ | 113 | export function SourceFormPanel({ |
| 55 | existingSourceNames, | 114 | existingSourceNames, |
| 56 | sourceToEdit, | 115 | sourceToEdit, |
| 116 | sourceProgress, | ||
| 57 | sourceDialogMeta, | 117 | sourceDialogMeta, |
| 58 | sourceAuthProfiles, | 118 | sourceAuthProfiles, |
| 59 | onSourceAdded, | 119 | onSourceAdded, |
| @@ -62,6 +122,7 @@ export function SourceFormPanel({ · 2 unmodified lines | |||
| 62 | }: { | 122 | }: { |
| 63 | existingSourceNames: Set<string>; | 123 | existingSourceNames: Set<string>; |
| 64 | sourceToEdit?: ToolSourceRecord; | 124 | sourceToEdit?: ToolSourceRecord; |
| 125 | sourceProgress?: SourceProgressState; | ||
| 65 | sourceDialogMeta?: SourceDialogMeta; | 126 | sourceDialogMeta?: SourceDialogMeta; |
| 66 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; | 127 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; |
| 67 | onSourceAdded?: OnSourceAdded; | 128 | onSourceAdded?: OnSourceAdded; |
| @@ -247,7 +308,6 @@ export function SourceFormPanel({ · 179 unmodified lines | |||
| 247 | if (!sourceToEdit) { | 308 | if (!sourceToEdit) { |
| 248 | form.reserveSourceName(form.name.trim()); | 309 | form.reserveSourceName(form.name.trim()); |
| 249 | } | 310 | } |
| 250 | onClose(); | ||
| 251 | }; | 311 | }; |
| 252 | 312 | ||
| 253 | const handleMcpOAuthConnect = async () => { | 313 | const handleMcpOAuthConnect = async () => { |
| @@ -388,6 +448,66 @@ export function SourceFormPanel({ · 134 unmodified lines | |||
| 388 | </div> | 448 | </div> |
| 389 | </div> | 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 | {editSourceWarnings.length > 0 ? ( | 511 | {editSourceWarnings.length > 0 ? ( |
| 392 | <div className="rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2 space-y-0.5"> | 512 | <div className="rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2 space-y-0.5"> |
| 393 | {editSourceWarnings.map((warning, i) => ( | 513 | {editSourceWarnings.map((warning, i) => ( |
| @@ -397,10 +397,9 @@ function ToolDetailContent({ · 396 unmodified lines | |||
| 397 | || canRenderOutputSchema; | 397 | || canRenderOutputSchema; |
| 398 | const showLoading = Boolean(loading); | 398 | const showLoading = Boolean(loading); |
| 399 | 399 | ||
| 400 | return ( | 400 | if (showLoading) { |
| 401 | <div className="pt-2 pb-4 flex flex-col gap-4"> | 401 | return ( |
| 402 | {/* Loading state */} | 402 | <div className="pt-2 pb-4 flex flex-col gap-4"> |
| 403 | {showLoading ? ( | ||
| 404 | <div className="space-y-3"> | 403 | <div className="space-y-3"> |
| 405 | <Skeleton className="h-3.5 w-72" /> | 404 | <Skeleton className="h-3.5 w-72" /> |
| 406 | <div> | 405 | <div> |
| @@ -412,8 +411,12 @@ function ToolDetailContent({ · 5 unmodified lines | |||
| 412 | <Skeleton className="h-14 w-full rounded-md mt-1" /> | 411 | <Skeleton className="h-14 w-full rounded-md mt-1" /> |
| 413 | </div> | 412 | </div> |
| 414 | </div> | 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 | {/* Description */} | 420 | {/* Description */} |
| 418 | {description ? ( | 421 | {description ? ( |
| 419 | <div className="text-[12px] leading-relaxed text-muted-foreground"> | 422 | <div className="text-[12px] leading-relaxed text-muted-foreground"> |
| @@ -22,7 +22,6 @@ import type { · 21 unmodified lines | |||
| 22 | StorageScopeType, | 22 | StorageScopeType, |
| 23 | } from "@/lib/types"; | 23 | } from "@/lib/types"; |
| 24 | import { | 24 | import { |
| 25 | parseWarningSourceName, | ||
| 26 | warningsBySourceName, | 25 | warningsBySourceName, |
| 27 | } from "@/lib/tools/source-helpers"; | 26 | } from "@/lib/tools/source-helpers"; |
| 28 | import { sourceLabel } from "@/lib/tool/source-utils"; | 27 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| @@ -121,6 +120,7 @@ export function ToolsView() { · 92 unmodified lines | |||
| 121 | sourceQuality, | 120 | sourceQuality, |
| 122 | sourceAuthProfiles, | 121 | sourceAuthProfiles, |
| 123 | inventoryStatus, | 122 | inventoryStatus, |
| 123 | inventorySourceStates, | ||
| 124 | loadingSources, | 124 | loadingSources, |
| 125 | loadingTools, | 125 | loadingTools, |
| 126 | refreshingTools, | 126 | refreshingTools, |
| @@ -134,6 +134,7 @@ export function ToolsView() { · 7 unmodified lines | |||
| 134 | loadToolDetails, | 134 | loadToolDetails, |
| 135 | } = useWorkspaceTools(context ?? null, { | 135 | } = useWorkspaceTools(context ?? null, { |
| 136 | includeDetails: false, | 136 | includeDetails: false, |
| 137 | sourceName: activeTab === "catalog" ? catalogSourceValue : null, | ||
| 137 | }); | 138 | }); |
| 138 | 139 | ||
| 139 | const showRegenerationToast = useCallback((kind: "loading" | "success" | "error", message: string) => { | 140 | const showRegenerationToast = useCallback((kind: "loading" | "success" | "error", message: string) => { |
| @@ -212,19 +213,6 @@ export function ToolsView() { · 72 unmodified lines | |||
| 212 | const storagePanelLoading = sessionLoading || storageLoading; | 213 | const storagePanelLoading = sessionLoading || storageLoading; |
| 213 | const shouldRenderShellSkeleton = sessionLoading && !TAB_USES_OWN_LOADING_STATE[activeTab]; | 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 | const mergedLoadingSources = useMemo(() => { | 216 | const mergedLoadingSources = useMemo(() => { |
| 229 | const combined = new Set<string>(); | 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) { | 225 | for (const [sourceName, sourceState] of Object.entries(inventorySourceStates)) { |
| 238 | for (const source of sourceItems) { | 226 | if ( |
| 239 | if (toolSourceNames.has(source.name)) { | 227 | sourceState?.state === "queued" |
| 240 | continue; | 228 | || sourceState?.state === "loading" |
| 241 | } | 229 | || sourceState?.state === "indexing" |
| 242 | combined.add(source.name); | 230 | ) { |
| 231 | combined.add(sourceName); | ||
| 243 | } | 232 | } |
| 244 | } | 233 | } |
| 245 | 234 | ||
| 246 | return Array.from(combined); | 235 | return Array.from(combined); |
| 247 | }, [hasGlobalInventoryWarning, loadingSources, sourceItems, toolSourceNames]); | 236 | }, [inventorySourceStates, loadingSources]); |
| 248 | 237 | ||
| 249 | const visibleLoadingSources = useMemo( | 238 | const visibleLoadingSources = useMemo( |
| 250 | () => mergedLoadingSources.filter((name) => | 239 | () => mergedLoadingSources.filter((name) => |
| @@ -474,6 +463,7 @@ export function ToolsView() { · 223 unmodified lines | |||
| 474 | tools={visibleTools} | 463 | tools={visibleTools} |
| 475 | sources={sourceItems} | 464 | sources={sourceItems} |
| 476 | loadingSources={visibleLoadingSources} | 465 | loadingSources={visibleLoadingSources} |
| 466 | sourceStates={inventorySourceStates} | ||
| 477 | sourceCountsOverride={visibleSourceCounts} | 467 | sourceCountsOverride={visibleSourceCounts} |
| 478 | totalTools={visibleTools.length} | 468 | totalTools={visibleTools.length} |
| 479 | hasMoreTools={hasMoreTools} | 469 | hasMoreTools={hasMoreTools} |
| @@ -22,15 +22,10 @@ interface WorkspaceToolsQueryResult { · 21 unmodified lines | |||
| 22 | /** URL to a workspace-wide Monaco `.d.ts` bundle (may be undefined). */ | 22 | /** URL to a workspace-wide Monaco `.d.ts` bundle (may be undefined). */ |
| 23 | typesUrl?: string; | 23 | typesUrl?: string; |
| 24 | inventoryStatus: { | 24 | inventoryStatus: { |
| 25 | state: "initializing" | "ready" | "rebuilding" | "stale" | "failed"; | 25 | state: "initializing" | "ready" | "rebuilding" | "failed"; |
| 26 | readyBuildId?: string; | ||
| 27 | buildingBuildId?: string; | ||
| 28 | readyToolCount: number; | 26 | readyToolCount: number; |
| 29 | loadingSourceNames: string[]; | 27 | loadingSourceNames: string[]; |
| 30 | sourceToolCounts: Record<string, number>; | 28 | sourceToolCounts: Record<string, number>; |
| 31 | lastBuildStartedAt?: number; | ||
| 32 | lastBuildCompletedAt?: number; | ||
| 33 | lastBuildFailedAt?: number; | ||
| 34 | error?: string; | 29 | error?: string; |
| 35 | updatedAt?: number; | 30 | updatedAt?: number; |
| 36 | }; | 31 | }; |
| @@ -40,6 +35,21 @@ interface WorkspaceToolsQueryResult { · 3 unmodified lines | |||
| 40 | 35 | ||
| 41 | interface UseWorkspaceToolsOptions { | 36 | interface UseWorkspaceToolsOptions { |
| 42 | includeDetails?: boolean; | 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 | type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "display" | "typing">; | 55 | type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "display" | "typing">; |
| @@ -61,7 +71,6 @@ type ListToolsWithWarningsAction = (args: { · 15 unmodified lines | |||
| 61 | sourceName?: string; | 71 | sourceName?: string; |
| 62 | cursor?: string; | 72 | cursor?: string; |
| 63 | limit?: number; | 73 | limit?: number; |
| 64 | buildId?: string; | ||
| 65 | fetchAll?: boolean; | 74 | fetchAll?: boolean; |
| 66 | rebuildInventory?: boolean; | 75 | rebuildInventory?: boolean; |
| 67 | }) => Promise<WorkspaceToolsQueryResult>; | 76 | }) => Promise<WorkspaceToolsQueryResult>; |
| @@ -77,6 +86,7 @@ export function useWorkspaceTools( · 9 unmodified lines | |||
| 77 | options: UseWorkspaceToolsOptions = {}, | 86 | options: UseWorkspaceToolsOptions = {}, |
| 78 | ) { | 87 | ) { |
| 79 | const includeDetails = options.includeDetails ?? false; | 88 | const includeDetails = options.includeDetails ?? false; |
| 89 | const sourceName = options.sourceName?.trim() ? options.sourceName.trim() : undefined; | ||
| 80 | const listToolsWithWarningsRaw = useAction(convexApi.executorNode.listToolsWithWarnings); | 90 | const listToolsWithWarningsRaw = useAction(convexApi.executorNode.listToolsWithWarnings); |
| 81 | const listToolsWithWarnings = listToolsWithWarningsRaw as ListToolsWithWarningsAction; | 91 | const listToolsWithWarnings = listToolsWithWarningsRaw as ListToolsWithWarningsAction; |
| 82 | const listToolDetailsRaw = useMutation(convexApi.workspace.getToolDetails); | 92 | const listToolDetailsRaw = useMutation(convexApi.workspace.getToolDetails); |
| @@ -87,7 +97,7 @@ export function useWorkspaceTools( · 4 unmodified lines | |||
| 87 | const inventoryProgress = useConvexQuery( | 97 | const inventoryProgress = useConvexQuery( |
| 88 | convexApi.workspace.getToolInventoryProgress, | 98 | convexApi.workspace.getToolInventoryProgress, |
| 89 | context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | 99 | context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", |
| 90 | ); | 100 | ) as WorkspaceInventoryProgress | undefined; |
| 91 | 101 | ||
| 92 | const inventoryQuery = useQuery<WorkspaceToolsQueryResult, Error>({ | 102 | const inventoryQuery = useQuery<WorkspaceToolsQueryResult, Error>({ |
| 93 | queryKey: [ | 103 | queryKey: [ |
| @@ -96,6 +106,7 @@ export function useWorkspaceTools( · 2 unmodified lines | |||
| 96 | context?.accountId, | 106 | context?.accountId, |
| 97 | context?.clientId, | 107 | context?.clientId, |
| 98 | includeDetails, | 108 | includeDetails, |
| 109 | sourceName, | ||
| 99 | inventoryProgress?.reactiveKey, | 110 | inventoryProgress?.reactiveKey, |
| 100 | ], | 111 | ], |
| 101 | queryFn: async (): Promise<WorkspaceToolsQueryResult> => { | 112 | queryFn: async (): Promise<WorkspaceToolsQueryResult> => { |
| @@ -123,6 +134,7 @@ export function useWorkspaceTools( · 21 unmodified lines | |||
| 123 | ...(context.sessionId && { sessionId: context.sessionId }), | 134 | ...(context.sessionId && { sessionId: context.sessionId }), |
| 124 | includeDetails, | 135 | includeDetails, |
| 125 | fetchAll: true, | 136 | fetchAll: true, |
| 137 | ...(sourceName ? { sourceName } : {}), | ||
| 126 | }); | 138 | }); |
| 127 | }, | 139 | }, |
| 128 | enabled: !!context, | 140 | enabled: !!context, |
| @@ -131,6 +143,7 @@ export function useWorkspaceTools( · 2 unmodified lines | |||
| 131 | 143 | ||
| 132 | const inventoryData = inventoryQuery.data; | 144 | const inventoryData = inventoryQuery.data; |
| 133 | const inventoryStatus = inventoryProgress?.inventoryStatus ?? inventoryData?.inventoryStatus; | 145 | const inventoryStatus = inventoryProgress?.inventoryStatus ?? inventoryData?.inventoryStatus; |
| 146 | const inventorySourceStates: WorkspaceInventoryProgress["sourceStates"] = inventoryProgress?.sourceStates ?? {}; | ||
| 134 | const tools = inventoryData?.tools ?? []; | 147 | const tools = inventoryData?.tools ?? []; |
| 135 | 148 | ||
| 136 | const loadToolDetails = useCallback(async (toolPaths: string[]): Promise<Record<string, ToolDetailDescriptor>> => { | 149 | const loadToolDetails = useCallback(async (toolPaths: string[]): Promise<Record<string, ToolDetailDescriptor>> => { |
| @@ -171,6 +184,10 @@ export function useWorkspaceTools( · 34 unmodified lines | |||
| 171 | detailsCacheRef.current.clear(); | 184 | detailsCacheRef.current.clear(); |
| 172 | }, [context?.workspaceId, context?.accountId, context?.clientId, context?.sessionId]); | 185 | }, [context?.workspaceId, context?.accountId, context?.clientId, context?.sessionId]); |
| 173 | 186 | ||
| 187 | useEffect(() => { | ||
| 188 | detailsCacheRef.current.clear(); | ||
| 189 | }, [inventoryProgress?.reactiveKey]); | ||
| 190 | |||
| 174 | useEffect(() => { | 191 | useEffect(() => { |
| 175 | if (!inventoryData || !includeDetails) { | 192 | if (!inventoryData || !includeDetails) { |
| 176 | return; | 193 | return; |
| @@ -219,7 +236,7 @@ export function useWorkspaceTools( · 42 unmodified lines | |||
| 219 | sourceQuality: inventoryData?.sourceQuality ?? {}, | 236 | sourceQuality: inventoryData?.sourceQuality ?? {}, |
| 220 | sourceAuthProfiles: inventoryData?.sourceAuthProfiles ?? {}, | 237 | sourceAuthProfiles: inventoryData?.sourceAuthProfiles ?? {}, |
| 221 | inventoryStatus, | 238 | inventoryStatus, |
| 222 | inventorySourceStates: inventoryProgress?.sourceStates ?? {}, | 239 | inventorySourceStates, |
| 223 | loadingSources: inventoryStatus?.loadingSourceNames ?? [], | 240 | loadingSources: inventoryStatus?.loadingSourceNames ?? [], |
| 224 | loadingTools: !!context && inventoryQuery.isLoading, | 241 | loadingTools: !!context && inventoryQuery.isLoading, |
| 225 | refreshingTools: !!context && inventoryQuery.isFetching, | 242 | refreshingTools: !!context && inventoryQuery.isFetching, |
| @@ -27,6 +27,58 @@ export const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [ · 26 unmodified lines | |||
| 27 | rank: 0, | 27 | rank: 0, |
| 28 | addedAt: "2026-02-14", | 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 | id: "github-rest", | 83 | id: "github-rest", |
| 32 | name: "GitHub REST API", | 84 | name: "GitHub REST API", |
| @@ -177,11 +177,14 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 176 unmodified lines | |||
| 177 | return resolveActiveWorkspaceId(workspaces, activeWorkspaceId, accountStoredWorkspace); | 177 | return resolveActiveWorkspaceId(workspaces, activeWorkspaceId, accountStoredWorkspace); |
| 178 | }, [workspaces, activeWorkspaceId, account]); | 178 | }, [workspaces, activeWorkspaceId, account]); |
| 179 | 179 | ||
| 180 | const profileName = workosAuthProfile?.name?.trim(); | ||
| 181 | |||
| 180 | const bootstrapWorkosAccountQuery = useTanstackQuery({ | 182 | const bootstrapWorkosAccountQuery = useTanstackQuery({ |
| 181 | queryKey: [ | 183 | queryKey: [ |
| 182 | "workos-account-bootstrap", | 184 | "workos-account-bootstrap", |
| 183 | storedSessionId ?? "none", | 185 | storedSessionId ?? "none", |
| 184 | workosAuthenticated ? "signed-in" : "signed-out", | 186 | workosAuthenticated ? "signed-in" : "signed-out", |
| 187 | profileName ?? "no-profile-name", | ||
| 185 | ], | 188 | ], |
| 186 | enabled: | 189 | enabled: |
| 187 | workosEnabled | 190 | workosEnabled |
| @@ -192,6 +195,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 4 unmodified lines | |||
| 192 | staleTime: Number.POSITIVE_INFINITY, | 195 | staleTime: Number.POSITIVE_INFINITY, |
| 193 | queryFn: async () => bootstrapCurrentWorkosAccount({ | 196 | queryFn: async () => bootstrapCurrentWorkosAccount({ |
| 194 | sessionId: storedSessionId ?? undefined, | 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 | import { toPlainObject } from "./utils"; | 9 | import { toPlainObject } from "./utils"; |
| 10 | 10 | ||
| 11 | const unknownRecordSchema = z.record(z.unknown()); | 11 | const unknownRecordSchema = z.record(z.unknown()); |
| 12 | const OPENAPI_JSON_FETCH_TIMEOUT_MS = 45_000; | ||
| 12 | 13 | ||
| 13 | const openApiDocumentSchema = z.object({ | 14 | const openApiDocumentSchema = z.object({ |
| 14 | openapi: z.string().optional(), | 15 | openapi: z.string().optional(), |
| @@ -55,6 +56,66 @@ function toOpenApiTsInput(spec: Record<string, unknown>): Parameters<typeof open · 40 unmodified lines | |||
| 55 | return parsed.data as Parameters<typeof openapiTS>[0] & Record<string, unknown>; | 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 | function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null { | 119 | function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null { |
| 59 | let strippedCount = 0; | 120 | let strippedCount = 0; |
| 60 | 121 | ||
| @@ -157,6 +218,7 @@ async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string · 96 unmodified lines | |||
| 157 | export interface PrepareOpenApiSpecOptions { | 218 | export interface PrepareOpenApiSpecOptions { |
| 158 | includeDts?: boolean; | 219 | includeDts?: boolean; |
| 159 | profile?: "full" | "inventory"; | 220 | profile?: "full" | "inventory"; |
| 221 | resolveSchemaRefs?: boolean; | ||
| 160 | } | 222 | } |
| 161 | 223 | ||
| 162 | export async function prepareOpenApiSpec( | 224 | export async function prepareOpenApiSpec( |
| @@ -168,26 +230,31 @@ export async function prepareOpenApiSpec( · 5 unmodified lines | |||
| 168 | const includeDts = options.includeDts ?? true; | 230 | const includeDts = options.includeDts ?? true; |
| 169 | const profile = options.profile ?? (includeDts ? "full" : "inventory"); | 231 | const profile = options.profile ?? (includeDts ? "full" : "inventory"); |
| 170 | const shouldGenerateDts = includeDts && profile === "full"; | 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 | const warnings: string[] = []; | 236 | const warnings: string[] = []; |
| 174 | 237 | ||
| 175 | let parsed: Record<string, unknown>; | 238 | let parsed: Record<string, unknown>; |
| 176 | if (typeof spec === "string") { | 239 | if (typeof spec === "string") { |
| 177 | let parsedValue: unknown; | 240 | if (looksLikeJsonSpecUrl(spec)) { |
| 178 | try { | 241 | parsed = await fetchJsonOpenApiSpec(spec, sourceName); |
| 179 | parsedValue = await parser.parse(spec); | 242 | } else { |
| 180 | } catch (error) { | 243 | let parsedValue: unknown; |
| 181 | const msg = error instanceof Error ? error.message : String(error); | 244 | try { |
| 182 | throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`); | 245 | parsedValue = await parser.parse(spec); |
| 183 | } | 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}'`); | 251 | const parsedRecord = toRecordResult(parsedValue, `Parsed OpenAPI source '${sourceName}'`); |
| 186 | if (parsedRecord.isErr()) { | 252 | if (parsedRecord.isErr()) { |
| 187 | throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`); | 253 | throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`); |
| 188 | } | 254 | } |
| 189 | 255 | ||
| 190 | parsed = parsedRecord.value; | 256 | parsed = parsedRecord.value; |
| 257 | } | ||
| 191 | } else { | 258 | } else { |
| 192 | parsed = spec; | 259 | parsed = spec; |
| 193 | } | 260 | } |
| @@ -234,7 +301,7 @@ export async function prepareOpenApiSpec( · 40 unmodified lines | |||
| 234 | includeSchemas: profile === "full", | 301 | includeSchemas: profile === "full", |
| 235 | includeTypeHints: true, | 302 | includeTypeHints: true, |
| 236 | includeParameterSchemas: true, | 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 | 300_000, | 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 | test( | 730 | test( |
| 663 | "OpenAPI inventory mode still yields usable schemas", | 731 | "OpenAPI inventory mode still yields usable schemas", |
| 664 | async () => { | 732 | async () => { |
| @@ -685,6 +753,49 @@ describe("real-world OpenAPI specs", () => { · 20 unmodified lines | |||
| 685 | 300_000, | 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 | test( | 799 | test( |
| 689 | "openai: create batch hints stay non-lossy in inventory mode", | 800 | "openai: create batch hints stay non-lossy in inventory mode", |
| 690 | async () => { | 801 | async () => { |
| @@ -45,6 +45,7 @@ export const bootstrapCurrentWorkosAccount = customMutation({ · 44 unmodified lines | |||
| 45 | method: "POST", | 45 | method: "POST", |
| 46 | args: { | 46 | args: { |
| 47 | sessionId: v.optional(v.string()), | 47 | sessionId: v.optional(v.string()), |
| 48 | profileName: v.optional(v.string()), | ||
| 48 | }, | 49 | }, |
| 49 | handler: async (ctx, args) => { | 50 | handler: async (ctx, args) => { |
| 50 | return await bootstrapCurrentWorkosAccountImpl(ctx, args); | 51 | return await bootstrapCurrentWorkosAccountImpl(ctx, args); |
| @@ -113,7 +113,6 @@ export const listToolsWithWarnings = workspaceAction({ · 112 unmodified lines | |||
| 113 | sourceName: v.optional(v.string()), | 113 | sourceName: v.optional(v.string()), |
| 114 | cursor: v.optional(v.string()), | 114 | cursor: v.optional(v.string()), |
| 115 | limit: v.optional(v.number()), | 115 | limit: v.optional(v.number()), |
| 116 | buildId: v.optional(v.string()), | ||
| 117 | fetchAll: v.optional(v.boolean()), | 116 | fetchAll: v.optional(v.boolean()), |
| 118 | rebuildInventory: v.optional(v.boolean()), | 117 | rebuildInventory: v.optional(v.boolean()), |
| 119 | }, | 118 | }, |
| @@ -156,7 +155,6 @@ export const listToolsWithWarnings = workspaceAction({ · 36 unmodified lines | |||
| 156 | sourceName: args.sourceName, | 155 | sourceName: args.sourceName, |
| 157 | cursor: args.cursor, | 156 | cursor: args.cursor, |
| 158 | limit: args.limit, | 157 | limit: args.limit, |
| 159 | buildId: args.buildId, | ||
| 160 | fetchAll: args.fetchAll, | 158 | fetchAll: args.fetchAll, |
| 161 | }); | 159 | }); |
| 162 | 160 | ||
| @@ -44,6 +44,7 @@ export const prepareOpenApiSpec = internalAction({ · 43 unmodified lines | |||
| 44 | const prepared = await prepareOpenApiSpecInNode(args.specUrl, args.sourceName, { | 44 | const prepared = await prepareOpenApiSpecInNode(args.specUrl, args.sourceName, { |
| 45 | includeDts: args.includeDts, | 45 | includeDts: args.includeDts, |
| 46 | profile: "full", | 46 | profile: "full", |
| 47 | resolveSchemaRefs: args.includeDts === true, | ||
| 47 | }); | 48 | }); |
| 48 | 49 | ||
| 49 | const json = JSON.stringify(prepared); | 50 | const json = JSON.stringify(prepared); |
| @@ -422,17 +422,13 @@ export default defineSchema({ · 421 unmodified lines | |||
| 422 | .index("by_spec_url_version", ["specUrl", "version"]), | 422 | .index("by_spec_url_version", ["specUrl", "version"]), |
| 423 | 423 | ||
| 424 | // Workspace tool registry state. | 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 | workspaceToolRegistryState: defineTable({ | 426 | workspaceToolRegistryState: defineTable({ |
| 427 | workspaceId: v.id("workspaces"), | 427 | workspaceId: v.id("workspaces"), |
| 428 | signature: v.optional(v.string()), | 428 | signature: v.optional(v.string()), |
| 429 | readyBuildId: v.optional(v.string()), | 429 | lastRefreshCompletedAt: v.optional(v.number()), |
| 430 | buildingBuildId: v.optional(v.string()), | 430 | lastRefreshFailedAt: v.optional(v.number()), |
| 431 | buildingSignature: v.optional(v.string()), | 431 | lastRefreshError: 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()), | ||
| 436 | typesStorageId: v.optional(v.id("_storage")), | 432 | typesStorageId: v.optional(v.id("_storage")), |
| 437 | warnings: v.optional(v.array(v.string())), | 433 | warnings: v.optional(v.array(v.string())), |
| 438 | toolCount: v.optional(v.number()), | 434 | toolCount: v.optional(v.number()), |
| @@ -440,11 +436,7 @@ export default defineSchema({ · 1 unmodified line | |||
| 440 | sourceName: v.string(), | 436 | sourceName: v.string(), |
| 441 | toolCount: v.number(), | 437 | toolCount: v.number(), |
| 442 | }))), | 438 | }))), |
| 443 | sourceVersions: v.optional(v.array(v.object({ | 439 | sourceStates: v.optional(v.array(jsonObjectValidator)), |
| 444 | sourceId: v.string(), | ||
| 445 | sourceName: v.string(), | ||
| 446 | updatedAt: v.number(), | ||
| 447 | }))), | ||
| 448 | sourceQuality: v.optional(v.array(v.object({ | 440 | sourceQuality: v.optional(v.array(v.object({ |
| 449 | sourceKey: v.string(), | 441 | sourceKey: v.string(), |
| 450 | toolCount: v.number(), | 442 | toolCount: v.number(), |
| @@ -479,7 +471,6 @@ export default defineSchema({ · 28 unmodified lines | |||
| 479 | // NOTE: We avoid storing raw JSON Schemas here because Convex forbids `$`-prefixed keys. | 471 | // NOTE: We avoid storing raw JSON Schemas here because Convex forbids `$`-prefixed keys. |
| 480 | workspaceToolRegistry: defineTable({ | 472 | workspaceToolRegistry: defineTable({ |
| 481 | workspaceId: v.id("workspaces"), | 473 | workspaceId: v.id("workspaces"), |
| 482 | buildId: v.string(), | ||
| 483 | path: v.string(), | 474 | path: v.string(), |
| 484 | preferredPath: v.string(), | 475 | preferredPath: v.string(), |
| 485 | namespace: v.string(), | 476 | namespace: v.string(), |
| @@ -500,37 +491,35 @@ export default defineSchema({ · 14 unmodified lines | |||
| 500 | })), | 491 | })), |
| 501 | createdAt: v.number(), | 492 | createdAt: v.number(), |
| 502 | }) | 493 | }) |
| 503 | .index("by_workspace_build_path", ["workspaceId", "buildId", "path"]) | 494 | .index("by_workspace_path", ["workspaceId", "path"]) |
| 504 | .index("by_workspace_build_normalized", ["workspaceId", "buildId", "normalizedPath"]) | 495 | .index("by_workspace_normalized", ["workspaceId", "normalizedPath"]) |
| 505 | .index("by_workspace_build_source", ["workspaceId", "buildId", "source"]) | 496 | .index("by_workspace_source", ["workspaceId", "source"]) |
| 506 | .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]) | 497 | .index("by_workspace_namespace", ["workspaceId", "namespace"]) |
| 507 | .index("by_workspace_build", ["workspaceId", "buildId"]) | 498 | .index("by_workspace", ["workspaceId"]) |
| 508 | .searchIndex("search_text", { | 499 | .searchIndex("search_text", { |
| 509 | searchField: "searchText", | 500 | searchField: "searchText", |
| 510 | filterFields: ["workspaceId", "buildId"], | 501 | filterFields: ["workspaceId"], |
| 511 | }), | 502 | }), |
| 512 | 503 | ||
| 513 | // Heavy per-tool payloads kept separate from searchable metadata. | 504 | // Heavy per-tool payloads kept separate from searchable metadata. |
| 514 | workspaceToolRegistryPayloads: defineTable({ | 505 | workspaceToolRegistryPayloads: defineTable({ |
| 515 | workspaceId: v.id("workspaces"), | 506 | workspaceId: v.id("workspaces"), |
| 516 | buildId: v.string(), | ||
| 517 | path: v.string(), | 507 | path: v.string(), |
| 518 | serializedToolJson: v.string(), | 508 | serializedToolJson: v.string(), |
| 519 | createdAt: v.number(), | 509 | createdAt: v.number(), |
| 520 | }) | 510 | }) |
| 521 | .index("by_workspace_build_path", ["workspaceId", "buildId", "path"]) | 511 | .index("by_workspace_path", ["workspaceId", "path"]) |
| 522 | .index("by_workspace_build", ["workspaceId", "buildId"]), | 512 | .index("by_workspace", ["workspaceId"]), |
| 523 | 513 | ||
| 524 | // Precomputed namespace summaries for fast catalog.namespaces. | 514 | // Precomputed namespace summaries for fast catalog.namespaces. |
| 525 | workspaceToolNamespaces: defineTable({ | 515 | workspaceToolNamespaces: defineTable({ |
| 526 | workspaceId: v.id("workspaces"), | 516 | workspaceId: v.id("workspaces"), |
| 527 | buildId: v.string(), | ||
| 528 | namespace: v.string(), | 517 | namespace: v.string(), |
| 529 | toolCount: v.number(), | 518 | toolCount: v.number(), |
| 530 | samplePaths: v.array(v.string()), | 519 | samplePaths: v.array(v.string()), |
| 531 | createdAt: v.number(), | 520 | createdAt: v.number(), |
| 532 | }) | 521 | }) |
| 533 | .index("by_workspace_build", ["workspaceId", "buildId"]), | 522 | .index("by_workspace", ["workspaceId"]), |
| 534 | 523 | ||
| 535 | // Durable and ephemeral storage instances used by filesystem/kv/sql tools. | 524 | // Durable and ephemeral storage instances used by filesystem/kv/sql tools. |
| 536 | // Rows are scope-aware and can be shared across account/workspace/org contexts. | 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 | import { v } from "convex/values"; | 1 | import { v } from "convex/values"; |
| 2 | import { internal } from "./_generated/api"; | ||
| 3 | import { internalAction, internalMutation, internalQuery } from "./_generated/server"; | 2 | import { internalAction, internalMutation, internalQuery } from "./_generated/server"; |
| 3 | import { internal } from "./_generated/api"; | ||
| 4 | import { vv } from "./typedV"; | 4 | import { vv } from "./typedV"; |
| 5 | 5 | ||
| 6 | export const getState = internalQuery({ | 6 | export const getState = internalQuery({ |
| @@ -16,21 +16,20 @@ export const getState = internalQuery({ · 9 unmodified lines | |||
| 16 | if (!entry) return null; | 16 | if (!entry) return null; |
| 17 | 17 | ||
| 18 | const stateEntry = entry as Record<string, unknown>; | 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 | return { | 23 | return { |
| 21 | signature: entry.signature, | 24 | signature: entry.signature, |
| 22 | readyBuildId: entry.readyBuildId, | 25 | lastRefreshCompletedAt: entry.lastRefreshCompletedAt, |
| 23 | buildingBuildId: entry.buildingBuildId, | 26 | lastRefreshFailedAt: entry.lastRefreshFailedAt, |
| 24 | buildingSignature: entry.buildingSignature, | 27 | lastRefreshError: entry.lastRefreshError, |
| 25 | buildingStartedAt: entry.buildingStartedAt, | ||
| 26 | lastBuildCompletedAt: entry.lastBuildCompletedAt, | ||
| 27 | lastBuildFailedAt: entry.lastBuildFailedAt, | ||
| 28 | lastBuildError: entry.lastBuildError, | ||
| 29 | typesStorageId: entry.typesStorageId, | 28 | typesStorageId: entry.typesStorageId, |
| 30 | warnings: entry.warnings ?? [], | 29 | warnings: entry.warnings ?? [], |
| 31 | toolCount: entry.toolCount, | 30 | toolCount: entry.toolCount, |
| 32 | sourceToolCounts: entry.sourceToolCounts ?? [], | 31 | sourceToolCounts: entry.sourceToolCounts ?? [], |
| 33 | sourceVersions: entry.sourceVersions ?? [], | 32 | sourceStates, |
| 34 | sourceQuality: entry.sourceQuality ?? [], | 33 | sourceQuality: entry.sourceQuality ?? [], |
| 35 | sourceAuthProfiles: entry.sourceAuthProfiles ?? [], | 34 | sourceAuthProfiles: entry.sourceAuthProfiles ?? [], |
| 36 | openApiRefHintTables: Array.isArray(stateEntry.openApiRefHintTables) ? stateEntry.openApiRefHintTables : [], | 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 | export const putToolsBatch = internalMutation({ | 41 | export const putToolsBatch = internalMutation({ |
| 91 | args: { | 42 | args: { |
| 92 | workspaceId: vv.id("workspaces"), | 43 | workspaceId: vv.id("workspaces"), |
| 93 | buildId: v.string(), | ||
| 94 | tools: v.array( | 44 | tools: v.array( |
| 95 | v.object({ | 45 | v.object({ |
| 96 | path: v.string(), | 46 | path: v.string(), |
| @@ -120,9 +70,43 @@ export const putToolsBatch = internalMutation({ · 23 unmodified lines | |||
| 120 | handler: async (ctx, args) => { | 70 | handler: async (ctx, args) => { |
| 121 | const now = Date.now(); | 71 | const now = Date.now(); |
| 122 | for (const tool of args.tools) { | 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 | await ctx.db.insert("workspaceToolRegistry", { | 108 | await ctx.db.insert("workspaceToolRegistry", { |
| 124 | workspaceId: args.workspaceId, | 109 | workspaceId: args.workspaceId, |
| 125 | buildId: args.buildId, | ||
| 126 | path: tool.path, | 110 | path: tool.path, |
| 127 | preferredPath: tool.preferredPath, | 111 | preferredPath: tool.preferredPath, |
| 128 | namespace: tool.namespace, | 112 | namespace: tool.namespace, |
| @@ -142,7 +126,6 @@ export const putToolsBatch = internalMutation({ · 13 unmodified lines | |||
| 142 | 126 | ||
| 143 | await ctx.db.insert("workspaceToolRegistryPayloads", { | 127 | await ctx.db.insert("workspaceToolRegistryPayloads", { |
| 144 | workspaceId: args.workspaceId, | 128 | workspaceId: args.workspaceId, |
| 145 | buildId: args.buildId, | ||
| 146 | path: tool.path, | 129 | path: tool.path, |
| 147 | serializedToolJson: tool.serializedToolJson, | 130 | serializedToolJson: tool.serializedToolJson, |
| 148 | createdAt: now, | 131 | createdAt: now, |
| @@ -154,7 +137,6 @@ export const putToolsBatch = internalMutation({ · 5 unmodified lines | |||
| 154 | export const putNamespacesBatch = internalMutation({ | 137 | export const putNamespacesBatch = internalMutation({ |
| 155 | args: { | 138 | args: { |
| 156 | workspaceId: vv.id("workspaces"), | 139 | workspaceId: vv.id("workspaces"), |
| 157 | buildId: v.string(), | ||
| 158 | namespaces: v.array( | 140 | namespaces: v.array( |
| 159 | v.object({ | 141 | v.object({ |
| 160 | namespace: v.string(), | 142 | namespace: v.string(), |
| @@ -168,7 +150,6 @@ export const putNamespacesBatch = internalMutation({ · 7 unmodified lines | |||
| 168 | for (const ns of args.namespaces) { | 150 | for (const ns of args.namespaces) { |
| 169 | await ctx.db.insert("workspaceToolNamespaces", { | 151 | await ctx.db.insert("workspaceToolNamespaces", { |
| 170 | workspaceId: args.workspaceId, | 152 | workspaceId: args.workspaceId, |
| 171 | buildId: args.buildId, | ||
| 172 | namespace: ns.namespace, | 153 | namespace: ns.namespace, |
| 173 | toolCount: ns.toolCount, | 154 | toolCount: ns.toolCount, |
| 174 | samplePaths: ns.samplePaths, | 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({ | 162 | export const updateRegistryMetadata = 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({ | ||
| 238 | args: { | 163 | args: { |
| 239 | workspaceId: vv.id("workspaces"), | 164 | workspaceId: vv.id("workspaces"), |
| 240 | buildId: v.string(), | ||
| 241 | typesStorageId: v.optional(v.id("_storage")), | 165 | typesStorageId: v.optional(v.id("_storage")), |
| 242 | warnings: v.array(v.string()), | 166 | warnings: v.array(v.string()), |
| 243 | toolCount: v.number(), | 167 | toolCount: v.number(), |
| @@ -245,11 +169,6 @@ export const updateBuildMetadata = internalMutation({ · 1 unmodified line | |||
| 245 | sourceName: v.string(), | 169 | sourceName: v.string(), |
| 246 | toolCount: v.number(), | 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 | sourceQuality: v.array(v.object({ | 172 | sourceQuality: v.array(v.object({ |
| 254 | sourceKey: v.string(), | 173 | sourceKey: v.string(), |
| 255 | toolCount: v.number(), | 174 | toolCount: v.number(), |
| @@ -282,9 +201,23 @@ export const updateBuildMetadata = internalMutation({ · 26 unmodified lines | |||
| 282 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | 201 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 283 | .unique(); | 202 | .unique(); |
| 284 | if (!state) { | 203 | if (!state) { |
| 285 | return; | 204 | await ctx.db.insert("workspaceToolRegistryState", { |
| 286 | } | 205 | workspaceId: args.workspaceId, |
| 287 | if (state.readyBuildId !== args.buildId) { | 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 | return; | 221 | return; |
| 289 | } | 222 | } |
| 290 | 223 | ||
| @@ -293,251 +226,234 @@ export const updateBuildMetadata = internalMutation({ · 2 unmodified lines | |||
| 293 | warnings: args.warnings, | 226 | warnings: args.warnings, |
| 294 | toolCount: args.toolCount, | 227 | toolCount: args.toolCount, |
| 295 | sourceToolCounts: args.sourceToolCounts, | 228 | sourceToolCounts: args.sourceToolCounts, |
| 296 | sourceVersions: args.sourceVersions, | ||
| 297 | sourceQuality: args.sourceQuality, | 229 | sourceQuality: args.sourceQuality, |
| 298 | sourceAuthProfiles: args.sourceAuthProfiles, | 230 | sourceAuthProfiles: args.sourceAuthProfiles, |
| 299 | openApiRefHintTables: args.openApiRefHintTables, | 231 | openApiRefHintTables: args.openApiRefHintTables, |
| 232 | lastRefreshCompletedAt: Date.now(), | ||
| 233 | lastRefreshError: undefined, | ||
| 300 | updatedAt: Date.now(), | 234 | updatedAt: Date.now(), |
| 301 | } as never); | 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 | args: { | 242 | args: { |
| 307 | workspaceId: vv.id("workspaces"), | 243 | workspaceId: vv.id("workspaces"), |
| 308 | buildId: v.string(), | 244 | sourceStates: v.array(sourceStateValidator), |
| 309 | error: v.optional(v.string()), | 245 | signature: v.optional(v.string()), |
| 310 | }, | 246 | }, |
| 311 | handler: async (ctx, args) => { | 247 | handler: async (ctx, args) => { |
| 248 | const now = Date.now(); | ||
| 312 | const state = await ctx.db | 249 | const state = await ctx.db |
| 313 | .query("workspaceToolRegistryState") | 250 | .query("workspaceToolRegistryState") |
| 314 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | 251 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 315 | .unique(); | 252 | .unique(); |
| 253 | |||
| 316 | if (!state) { | 254 | if (!state) { |
| 317 | return; | 255 | await ctx.db.insert("workspaceToolRegistryState", { |
| 318 | } | 256 | workspaceId: args.workspaceId, |
| 319 | if (state.buildingBuildId !== args.buildId) { | 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 | return; | 272 | return; |
| 321 | } | 273 | } |
| 322 | 274 | ||
| 323 | await ctx.db.patch(state._id, { | 275 | await ctx.db.patch(state._id, { |
| 324 | buildingBuildId: undefined, | 276 | ...(args.signature ? { signature: args.signature } : {}), |
| 325 | buildingSignature: undefined, | 277 | sourceStates: args.sourceStates, |
| 326 | buildingStartedAt: undefined, | 278 | updatedAt: now, |
| 327 | lastBuildFailedAt: Date.now(), | ||
| 328 | lastBuildError: args.error, | ||
| 329 | updatedAt: Date.now(), | ||
| 330 | }); | 279 | }); |
| 331 | }, | 280 | }, |
| 332 | }); | 281 | }); |
| 333 | 282 | ||
| 334 | const PRUNE_DELETE_PAGE_SIZE = 10; | 283 | export const setRefreshError = internalMutation({ |
| 335 | const PRUNE_NAMESPACE_SCAN_PAGE_SIZE = 250; | ||
| 336 | |||
| 337 | export const scanNamespaceBuildsForPrune = internalQuery({ | ||
| 338 | args: { | 284 | args: { |
| 339 | workspaceId: vv.id("workspaces"), | 285 | workspaceId: vv.id("workspaces"), |
| 340 | cursor: v.optional(v.string()), | 286 | error: v.string(), |
| 341 | }, | 287 | }, |
| 342 | handler: async (ctx, args) => { | 288 | handler: async (ctx, args) => { |
| 343 | const page = await ctx.db | 289 | const now = Date.now(); |
| 344 | .query("workspaceToolNamespaces") | 290 | const state = await ctx.db |
| 345 | .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId)) | 291 | .query("workspaceToolRegistryState") |
| 346 | .paginate({ numItems: PRUNE_NAMESPACE_SCAN_PAGE_SIZE, cursor: args.cursor ?? null }); | 292 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 293 | .unique(); | ||
| 347 | 294 | ||
| 348 | return { | 295 | if (!state) { |
| 349 | items: page.page.map((entry) => ({ | 296 | await ctx.db.insert("workspaceToolRegistryState", { |
| 350 | buildId: entry.buildId, | 297 | workspaceId: args.workspaceId, |
| 351 | createdAt: entry.createdAt, | 298 | signature: undefined, |
| 352 | })), | 299 | lastRefreshCompletedAt: undefined, |
| 353 | continueCursor: page.isDone ? null : page.continueCursor, | 300 | lastRefreshFailedAt: now, |
| 354 | }; | 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 | args: { | 325 | args: { |
| 360 | workspaceId: vv.id("workspaces"), | 326 | workspaceId: vv.id("workspaces"), |
| 361 | buildId: v.string(), | 327 | source: v.string(), |
| 362 | cursor: v.optional(v.string()), | ||
| 363 | }, | 328 | }, |
| 364 | handler: async (ctx, args) => { | 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 | .query("workspaceToolRegistry") | 339 | .query("workspaceToolRegistry") |
| 367 | .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId)) | 340 | .withIndex("by_workspace_source", (q) => |
| 368 | .paginate({ numItems: PRUNE_DELETE_PAGE_SIZE, cursor: args.cursor ?? null }); | 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 | await ctx.db.delete(entry._id); | 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 | args: { | 376 | args: { |
| 380 | workspaceId: vv.id("workspaces"), | 377 | workspaceId: vv.id("workspaces"), |
| 381 | buildId: v.string(), | 378 | source: v.string(), |
| 382 | cursor: v.optional(v.string()), | ||
| 383 | }, | 379 | }, |
| 384 | handler: async (ctx, args) => { | 380 | handler: async (ctx, args) => { |
| 385 | const page = await ctx.db | 381 | let removed = 0; |
| 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 }); | ||
| 389 | 382 | ||
| 390 | for (const entry of page.page) { | 383 | while (true) { |
| 391 | await ctx.db.delete(entry._id); | 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 | args: { | 403 | args: { |
| 400 | workspaceId: vv.id("workspaces"), | 404 | workspaceId: vv.id("workspaces"), |
| 401 | buildId: v.string(), | ||
| 402 | cursor: v.optional(v.string()), | 405 | cursor: v.optional(v.string()), |
| 406 | limit: v.number(), | ||
| 403 | }, | 407 | }, |
| 404 | handler: async (ctx, args) => { | 408 | handler: async (ctx, args) => { |
| 409 | const limit = Math.max(1, Math.min(250, Math.floor(args.limit))); | ||
| 405 | const page = await ctx.db | 410 | const page = await ctx.db |
| 406 | .query("workspaceToolNamespaces") | 411 | .query("workspaceToolRegistryPayloads") |
| 407 | .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId)) | 412 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 408 | .paginate({ numItems: PRUNE_DELETE_PAGE_SIZE, cursor: args.cursor ?? null }); | 413 | .paginate({ |
| 409 | 414 | numItems: limit, | |
| 410 | for (const entry of page.page) { | 415 | cursor: args.cursor ?? null, |
| 411 | await ctx.db.delete(entry._id); | 416 | }); |
| 412 | } | ||
| 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 | args: { | 429 | args: { |
| 420 | workspaceId: vv.id("workspaces"), | 430 | workspaceId: vv.id("workspaces"), |
| 421 | maxRetainedBuilds: v.optional(v.number()), | 431 | cursor: v.optional(v.string()), |
| 422 | }, | 432 | }, |
| 423 | handler: async (ctx, args) => { | 433 | handler: async (ctx, args) => { |
| 424 | const maxRetainedBuilds = Math.max(1, Math.min(10, Math.floor(args.maxRetainedBuilds ?? 2))); | 434 | const page = await ctx.db |
| 425 | const state = await ctx.runQuery(internal.toolRegistry.getState, { | 435 | .query("workspaceToolNamespaces") |
| 426 | workspaceId: args.workspaceId, | 436 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 427 | }); | 437 | .paginate({ numItems: 100, cursor: args.cursor ?? null }); |
| 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); | ||
| 462 | 438 | ||
| 463 | const retained = new Set<string>(buildIdsByRecency.slice(0, maxRetainedBuilds)); | 439 | for (const entry of page.page) { |
| 464 | for (const protectedBuildId of protectedBuildIds) { | 440 | await ctx.db.delete(entry._id); |
| 465 | retained.add(protectedBuildId); | ||
| 466 | } | 441 | } |
| 467 | 442 | ||
| 468 | for (const buildId of buildIdsByRecency) { | 443 | return { continueCursor: page.isDone ? null : page.continueCursor }; |
| 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 | } | ||
| 527 | }, | 444 | }, |
| 528 | }); | 445 | }); |
| 529 | 446 | ||
| 530 | export const getToolByPath = internalQuery({ | 447 | export const getToolByPath = internalQuery({ |
| 531 | args: { | 448 | args: { |
| 532 | workspaceId: vv.id("workspaces"), | 449 | workspaceId: vv.id("workspaces"), |
| 533 | buildId: v.string(), | ||
| 534 | path: v.string(), | 450 | path: v.string(), |
| 535 | }, | 451 | }, |
| 536 | handler: async (ctx, args) => { | 452 | handler: async (ctx, args) => { |
| 537 | const entry = await ctx.db | 453 | const entry = await ctx.db |
| 538 | .query("workspaceToolRegistry") | 454 | .query("workspaceToolRegistry") |
| 539 | .withIndex("by_workspace_build_path", (q) => | 455 | .withIndex("by_workspace_path", (q) => |
| 540 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", args.path), | 456 | q.eq("workspaceId", args.workspaceId).eq("path", args.path), |
| 541 | ) | 457 | ) |
| 542 | .first(); | 458 | .first(); |
| 543 | 459 | ||
| @@ -545,8 +461,8 @@ export const getToolByPath = internalQuery({ · 1 unmodified line | |||
| 545 | 461 | ||
| 546 | const payload = await ctx.db | 462 | const payload = await ctx.db |
| 547 | .query("workspaceToolRegistryPayloads") | 463 | .query("workspaceToolRegistryPayloads") |
| 548 | .withIndex("by_workspace_build_path", (q) => | 464 | .withIndex("by_workspace_path", (q) => |
| 549 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", entry.path), | 465 | q.eq("workspaceId", args.workspaceId).eq("path", entry.path), |
| 550 | ) | 466 | ) |
| 551 | .first(); | 467 | .first(); |
| 552 | 468 | ||
| @@ -571,7 +487,6 @@ export const getToolByPath = internalQuery({ · 18 unmodified lines | |||
| 571 | export const getSerializedToolsByPaths = internalQuery({ | 487 | export const getSerializedToolsByPaths = internalQuery({ |
| 572 | args: { | 488 | args: { |
| 573 | workspaceId: vv.id("workspaces"), | 489 | workspaceId: vv.id("workspaces"), |
| 574 | buildId: v.string(), | ||
| 575 | paths: v.array(v.string()), | 490 | paths: v.array(v.string()), |
| 576 | }, | 491 | }, |
| 577 | handler: async (ctx, args) => { | 492 | handler: async (ctx, args) => { |
| @@ -584,8 +499,8 @@ export const getSerializedToolsByPaths = internalQuery({ · 6 unmodified lines | |||
| 584 | const payloads = await Promise.all(paths.map(async (path) => { | 499 | const payloads = await Promise.all(paths.map(async (path) => { |
| 585 | const payload = await ctx.db | 500 | const payload = await ctx.db |
| 586 | .query("workspaceToolRegistryPayloads") | 501 | .query("workspaceToolRegistryPayloads") |
| 587 | .withIndex("by_workspace_build_path", (q) => | 502 | .withIndex("by_workspace_path", (q) => |
| 588 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", path), | 503 | q.eq("workspaceId", args.workspaceId).eq("path", path), |
| 589 | ) | 504 | ) |
| 590 | .first(); | 505 | .first(); |
| 591 | 506 | ||
| @@ -603,7 +518,6 @@ export const getSerializedToolsByPaths = internalQuery({ · 11 unmodified lines | |||
| 603 | export const listToolsByNamespace = internalQuery({ | 518 | export const listToolsByNamespace = internalQuery({ |
| 604 | args: { | 519 | args: { |
| 605 | workspaceId: vv.id("workspaces"), | 520 | workspaceId: vv.id("workspaces"), |
| 606 | buildId: v.string(), | ||
| 607 | namespace: v.string(), | 521 | namespace: v.string(), |
| 608 | limit: v.number(), | 522 | limit: v.number(), |
| 609 | }, | 523 | }, |
| @@ -614,8 +528,8 @@ export const listToolsByNamespace = internalQuery({ · 4 unmodified lines | |||
| 614 | 528 | ||
| 615 | const entries = await ctx.db | 529 | const entries = await ctx.db |
| 616 | .query("workspaceToolRegistry") | 530 | .query("workspaceToolRegistry") |
| 617 | .withIndex("by_workspace_build_namespace", (q) => | 531 | .withIndex("by_workspace_namespace", (q) => |
| 618 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("namespace", namespace), | 532 | q.eq("workspaceId", args.workspaceId).eq("namespace", namespace), |
| 619 | ) | 533 | ) |
| 620 | .take(limit); | 534 | .take(limit); |
| 621 | 535 | ||
| @@ -638,7 +552,6 @@ export const listToolsByNamespace = internalQuery({ · 16 unmodified lines | |||
| 638 | export const listToolsPage = internalQuery({ | 552 | export const listToolsPage = internalQuery({ |
| 639 | args: { | 553 | args: { |
| 640 | workspaceId: vv.id("workspaces"), | 554 | workspaceId: vv.id("workspaces"), |
| 641 | buildId: v.string(), | ||
| 642 | cursor: v.optional(v.string()), | 555 | cursor: v.optional(v.string()), |
| 643 | limit: v.number(), | 556 | limit: v.number(), |
| 644 | }, | 557 | }, |
| @@ -646,9 +559,7 @@ export const listToolsPage = internalQuery({ · 1 unmodified line | |||
| 646 | const limit = Math.max(1, Math.min(250, Math.floor(args.limit))); | 559 | const limit = Math.max(1, Math.min(250, Math.floor(args.limit))); |
| 647 | const page = await ctx.db | 560 | const page = await ctx.db |
| 648 | .query("workspaceToolRegistry") | 561 | .query("workspaceToolRegistry") |
| 649 | .withIndex("by_workspace_build", (q) => | 562 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 650 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId), | ||
| 651 | ) | ||
| 652 | .paginate({ | 563 | .paginate({ |
| 653 | numItems: limit, | 564 | numItems: limit, |
| 654 | cursor: args.cursor ?? null, | 565 | cursor: args.cursor ?? null, |
| @@ -676,7 +587,6 @@ export const listToolsPage = internalQuery({ · 21 unmodified lines | |||
| 676 | export const listToolsBySourcePage = internalQuery({ | 587 | export const listToolsBySourcePage = internalQuery({ |
| 677 | args: { | 588 | args: { |
| 678 | workspaceId: vv.id("workspaces"), | 589 | workspaceId: vv.id("workspaces"), |
| 679 | buildId: v.string(), | ||
| 680 | source: v.string(), | 590 | source: v.string(), |
| 681 | cursor: v.optional(v.string()), | 591 | cursor: v.optional(v.string()), |
| 682 | limit: v.number(), | 592 | limit: v.number(), |
| @@ -709,8 +619,8 @@ export const listToolsBySourcePage = internalQuery({ · 26 unmodified lines | |||
| 709 | const limit = Math.max(1, Math.min(250, Math.floor(args.limit))); | 619 | const limit = Math.max(1, Math.min(250, Math.floor(args.limit))); |
| 710 | const page = await ctx.db | 620 | const page = await ctx.db |
| 711 | .query("workspaceToolRegistry") | 621 | .query("workspaceToolRegistry") |
| 712 | .withIndex("by_workspace_build_source", (q) => | 622 | .withIndex("by_workspace_source", (q) => |
| 713 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("source", source), | 623 | q.eq("workspaceId", args.workspaceId).eq("source", source), |
| 714 | ) | 624 | ) |
| 715 | .paginate({ | 625 | .paginate({ |
| 716 | numItems: limit, | 626 | numItems: limit, |
| @@ -739,7 +649,6 @@ export const listToolsBySourcePage = internalQuery({ · 22 unmodified lines | |||
| 739 | export const getToolsByNormalizedPath = internalQuery({ | 649 | export const getToolsByNormalizedPath = internalQuery({ |
| 740 | args: { | 650 | args: { |
| 741 | workspaceId: vv.id("workspaces"), | 651 | workspaceId: vv.id("workspaces"), |
| 742 | buildId: v.string(), | ||
| 743 | normalizedPath: v.string(), | 652 | normalizedPath: v.string(), |
| 744 | limit: v.number(), | 653 | limit: v.number(), |
| 745 | }, | 654 | }, |
| @@ -750,16 +659,16 @@ export const getToolsByNormalizedPath = internalQuery({ · 4 unmodified lines | |||
| 750 | 659 | ||
| 751 | const entries = await ctx.db | 660 | const entries = await ctx.db |
| 752 | .query("workspaceToolRegistry") | 661 | .query("workspaceToolRegistry") |
| 753 | .withIndex("by_workspace_build_normalized", (q) => | 662 | .withIndex("by_workspace_normalized", (q) => |
| 754 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("normalizedPath", normalized), | 663 | q.eq("workspaceId", args.workspaceId).eq("normalizedPath", normalized), |
| 755 | ) | 664 | ) |
| 756 | .take(limit); | 665 | .take(limit); |
| 757 | 666 | ||
| 758 | const payloads = await Promise.all(entries.map(async (entry) => { | 667 | const payloads = await Promise.all(entries.map(async (entry) => { |
| 759 | const payload = await ctx.db | 668 | const payload = await ctx.db |
| 760 | .query("workspaceToolRegistryPayloads") | 669 | .query("workspaceToolRegistryPayloads") |
| 761 | .withIndex("by_workspace_build_path", (q) => | 670 | .withIndex("by_workspace_path", (q) => |
| 762 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", entry.path), | 671 | q.eq("workspaceId", args.workspaceId).eq("path", entry.path), |
| 763 | ) | 672 | ) |
| 764 | .first(); | 673 | .first(); |
| 765 | if (!payload) return null; | 674 | if (!payload) return null; |
| @@ -783,7 +692,6 @@ export const getToolsByNormalizedPath = internalQuery({ · 17 unmodified lines | |||
| 783 | export const searchTools = internalQuery({ | 692 | export const searchTools = internalQuery({ |
| 784 | args: { | 693 | args: { |
| 785 | workspaceId: vv.id("workspaces"), | 694 | workspaceId: vv.id("workspaces"), |
| 786 | buildId: v.string(), | ||
| 787 | query: v.string(), | 695 | query: v.string(), |
| 788 | limit: v.number(), | 696 | limit: v.number(), |
| 789 | }, | 697 | }, |
| @@ -796,8 +704,7 @@ export const searchTools = internalQuery({ · 6 unmodified lines | |||
| 796 | .query("workspaceToolRegistry") | 704 | .query("workspaceToolRegistry") |
| 797 | .withSearchIndex("search_text", (q) => | 705 | .withSearchIndex("search_text", (q) => |
| 798 | q.search("searchText", term) | 706 | q.search("searchText", term) |
| 799 | .eq("workspaceId", args.workspaceId) | 707 | .eq("workspaceId", args.workspaceId), |
| 800 | .eq("buildId", args.buildId), | ||
| 801 | ) | 708 | ) |
| 802 | .take(limit); | 709 | .take(limit); |
| 803 | 710 | ||
| @@ -820,16 +727,13 @@ export const searchTools = internalQuery({ · 16 unmodified lines | |||
| 820 | export const listNamespaces = internalQuery({ | 727 | export const listNamespaces = internalQuery({ |
| 821 | args: { | 728 | args: { |
| 822 | workspaceId: vv.id("workspaces"), | 729 | workspaceId: vv.id("workspaces"), |
| 823 | buildId: v.string(), | ||
| 824 | limit: v.number(), | 730 | limit: v.number(), |
| 825 | }, | 731 | }, |
| 826 | handler: async (ctx, args) => { | 732 | handler: async (ctx, args) => { |
| 827 | const limit = Math.max(1, Math.min(200, Math.floor(args.limit))); | 733 | const limit = Math.max(1, Math.min(200, Math.floor(args.limit))); |
| 828 | const entries = await ctx.db | 734 | const entries = await ctx.db |
| 829 | .query("workspaceToolNamespaces") | 735 | .query("workspaceToolNamespaces") |
| 830 | .withIndex("by_workspace_build", (q) => | 736 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) |
| 831 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId), | ||
| 832 | ) | ||
| 833 | .take(limit); | 737 | .take(limit); |
| 834 | 738 | ||
| 835 | return entries.map((entry) => ({ | 739 | return entries.map((entry) => ({ |
| @@ -108,7 +108,7 @@ async function deleteWorkspaceDataBatch( · 107 unmodified lines | |||
| 108 | const remaining = maxDeletes - deleted; | 108 | const remaining = maxDeletes - deleted; |
| 109 | const docs = await ctx.db | 109 | const docs = await ctx.db |
| 110 | .query(table) | 110 | .query(table) |
| 111 | .withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId)) | 111 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) |
| 112 | .take(remaining); | 112 | .take(remaining); |
| 113 | if (docs.length === 0) { | 113 | if (docs.length === 0) { |
| 114 | return false; | 114 | return false; |
| @@ -149,9 +149,9 @@ async function deleteWorkspaceDataBatch( · 34 unmodified lines | |||
| 149 | ctx.db.query("toolSources").withIndex("by_workspace_updated", (q) => q.eq("workspaceId", workspaceId)).first(), | 149 | ctx.db.query("toolSources").withIndex("by_workspace_updated", (q) => q.eq("workspaceId", workspaceId)).first(), |
| 150 | ctx.db.query("anonymousSessions").withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId)).first(), | 150 | ctx.db.query("anonymousSessions").withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId)).first(), |
| 151 | ctx.db.query("storageInstances").withIndex("by_workspace_updated", (q) => q.eq("workspaceId", workspaceId)).first(), | 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(), | 152 | ctx.db.query("workspaceToolRegistry").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(), |
| 153 | ctx.db.query("workspaceToolRegistryPayloads").withIndex("by_workspace_build", (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_build", (q) => q.eq("workspaceId", workspaceId)).first(), | 154 | ctx.db.query("workspaceToolNamespaces").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(), |
| 155 | ctx.db.query("workspaceToolRegistryState").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(), | 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 | import { getOrCreatePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace"; | 7 | import { getOrCreatePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace"; |
| 8 | import type { AccountId } from "./types"; | 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 | async function seedHintedOrganizationMembership( | 54 | async function seedHintedOrganizationMembership( |
| 11 | ctx: MutationCtx, | 55 | ctx: MutationCtx, |
| 12 | args: { | 56 | args: { |
| @@ -56,7 +100,7 @@ async function hasActiveWorkspaceAccess(ctx: MutationCtx, args: { accountId: Acc · 43 unmodified lines | |||
| 56 | 100 | ||
| 57 | export async function bootstrapCurrentWorkosAccountImpl( | 101 | export async function bootstrapCurrentWorkosAccountImpl( |
| 58 | ctx: MutationCtx, | 102 | ctx: MutationCtx, |
| 59 | args?: { sessionId?: string }, | 103 | args?: { sessionId?: string; profileName?: string }, |
| 60 | ) { | 104 | ) { |
| 61 | const identity = await ctx.auth.getUserIdentity(); | 105 | const identity = await ctx.auth.getUserIdentity(); |
| 62 | if (!identity) return null; | 106 | if (!identity) return null; |
| @@ -64,18 +108,22 @@ export async function bootstrapCurrentWorkosAccountImpl( · 1 unmodified line | |||
| 64 | const now = Date.now(); | 108 | const now = Date.now(); |
| 65 | const subject = identity.subject; | 109 | const subject = identity.subject; |
| 66 | const authKitProfile = await getAuthKitUserProfile(ctx, subject); | 110 | const authKitProfile = await getAuthKitUserProfile(ctx, subject); |
| 67 | const identityProfile = resolveIdentityProfile({ | 111 | const resolvedIdentityProfile = withProfileNameHint( |
| 68 | identity: { ...identity, subject }, | 112 | resolveIdentityProfile({ |
| 69 | authKitProfile, | 113 | identity: { ...identity, subject }, |
| 70 | }); | 114 | authKitProfile, |
| 115 | }), | ||
| 116 | subject, | ||
| 117 | args?.profileName, | ||
| 118 | ); | ||
| 71 | 119 | ||
| 72 | const account = await upsertWorkosAccount(ctx, { | 120 | const account = await upsertWorkosAccount(ctx, { |
| 73 | workosUserId: subject, | 121 | workosUserId: subject, |
| 74 | email: identityProfile.email, | 122 | email: resolvedIdentityProfile.email, |
| 75 | fullName: identityProfile.fullName, | 123 | fullName: resolvedIdentityProfile.fullName, |
| 76 | firstName: identityProfile.firstName, | 124 | firstName: resolvedIdentityProfile.firstName, |
| 77 | lastName: identityProfile.lastName, | 125 | lastName: resolvedIdentityProfile.lastName, |
| 78 | avatarUrl: identityProfile.avatarUrl, | 126 | avatarUrl: resolvedIdentityProfile.avatarUrl, |
| 79 | now, | 127 | now, |
| 80 | includeLastLoginAt: true, | 128 | includeLastLoginAt: true, |
| 81 | }); | 129 | }); |
| @@ -88,17 +136,17 @@ export async function bootstrapCurrentWorkosAccountImpl( · 6 unmodified lines | |||
| 88 | }); | 136 | }); |
| 89 | 137 | ||
| 90 | await refreshGeneratedPersonalWorkspaceNames(ctx, account._id, { | 138 | await refreshGeneratedPersonalWorkspaceNames(ctx, account._id, { |
| 91 | email: identityProfile.email, | 139 | email: resolvedIdentityProfile.email, |
| 92 | firstName: identityProfile.firstName, | 140 | firstName: resolvedIdentityProfile.firstName, |
| 93 | fullName: identityProfile.fullName, | 141 | fullName: resolvedIdentityProfile.fullName, |
| 94 | workosUserId: subject, | 142 | workosUserId: subject, |
| 95 | now, | 143 | now, |
| 96 | }); | 144 | }); |
| 97 | 145 | ||
| 98 | await seedHintedOrganizationMembership(ctx, { | 146 | await seedHintedOrganizationMembership(ctx, { |
| 99 | accountId: account._id, | 147 | accountId: account._id, |
| 100 | hintedWorkosOrgId: identityProfile.hintedWorkosOrgId, | 148 | hintedWorkosOrgId: resolvedIdentityProfile.hintedWorkosOrgId, |
| 101 | email: identityProfile.email, | 149 | email: resolvedIdentityProfile.email, |
| 102 | now, | 150 | now, |
| 103 | }); | 151 | }); |
| 104 | 152 | ||
| @@ -108,9 +156,9 @@ export async function bootstrapCurrentWorkosAccountImpl( · 3 unmodified lines | |||
| 108 | 156 | ||
| 109 | if (!hasWorkspaceMembership) { | 157 | if (!hasWorkspaceMembership) { |
| 110 | await getOrCreatePersonalWorkspace(ctx, account._id, { | 158 | await getOrCreatePersonalWorkspace(ctx, account._id, { |
| 111 | email: identityProfile.email, | 159 | email: resolvedIdentityProfile.email, |
| 112 | firstName: identityProfile.firstName, | 160 | firstName: resolvedIdentityProfile.firstName, |
| 113 | fullName: identityProfile.fullName, | 161 | fullName: resolvedIdentityProfile.fullName, |
| 114 | workosUserId: subject, | 162 | workosUserId: subject, |
| 115 | now, | 163 | now, |
| 116 | }); | 164 | }); |
| @@ -6,7 +6,7 @@ import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation- · 5 unmodified lines | |||
| 6 | import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; | 6 | import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 7 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; | 7 | import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 8 | import { getDecisionForContext, getToolDecision } from "./policy"; | 8 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 9 | import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; | 9 | import { getRegistryReadyResult } from "./tool_registry_state"; |
| 10 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; | 10 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| 11 | import { baseTools } from "./base_tools"; | 11 | import { baseTools } from "./base_tools"; |
| 12 | 12 | ||
| @@ -43,7 +43,7 @@ function getGraphqlQueryFromInput(input: unknown): string { · 30 unmodified lines | |||
| 43 | 43 | ||
| 44 | async function searchRegistryEntries( | 44 | async function searchRegistryEntries( |
| 45 | ctx: ActionCtx, | 45 | ctx: ActionCtx, |
| 46 | args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number }, | 46 | args: { workspaceId: TaskRecord["workspaceId"]; query: string; limit: number }, |
| 47 | ): Promise<RegistrySearchEntry[]> { | 47 | ): Promise<RegistrySearchEntry[]> { |
| 48 | const entries = await ctx.runQuery(internal.toolRegistry.searchTools, args); | 48 | const entries = await ctx.runQuery(internal.toolRegistry.searchTools, args); |
| 49 | const parsed = z.array(registrySearchEntrySchema).safeParse(entries); | 49 | const parsed = z.array(registrySearchEntrySchema).safeParse(entries); |
| @@ -52,7 +52,7 @@ async function searchRegistryEntries( · 2 unmodified lines | |||
| 52 | 52 | ||
| 53 | async function getRegistryToolByPath( | 53 | async function getRegistryToolByPath( |
| 54 | ctx: ActionCtx, | 54 | ctx: ActionCtx, |
| 55 | args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; path: string }, | 55 | args: { workspaceId: TaskRecord["workspaceId"]; path: string }, |
| 56 | ): Promise<RegistrySerializedToolEntry | null> { | 56 | ): Promise<RegistrySerializedToolEntry | null> { |
| 57 | const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, args); | 57 | const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, args); |
| 58 | const parsed = registrySerializedToolEntrySchema.safeParse(entry); | 58 | const parsed = registrySerializedToolEntrySchema.safeParse(entry); |
| @@ -63,7 +63,6 @@ async function getRegistryToolsByNormalizedPath( · 4 unmodified lines | |||
| 63 | ctx: ActionCtx, | 63 | ctx: ActionCtx, |
| 64 | args: { | 64 | args: { |
| 65 | workspaceId: TaskRecord["workspaceId"]; | 65 | workspaceId: TaskRecord["workspaceId"]; |
| 66 | buildId: string; | ||
| 67 | normalizedPath: string; | 66 | normalizedPath: string; |
| 68 | limit: number; | 67 | limit: number; |
| 69 | }, | 68 | }, |
| @@ -139,13 +138,11 @@ export function getGraphqlDecision( · 69 unmodified lines | |||
| 139 | async function suggestFromRegistry( | 138 | async function suggestFromRegistry( |
| 140 | ctx: ActionCtx, | 139 | ctx: ActionCtx, |
| 141 | workspaceId: TaskRecord["workspaceId"], | 140 | workspaceId: TaskRecord["workspaceId"], |
| 142 | buildId: string, | ||
| 143 | toolPath: string, | 141 | toolPath: string, |
| 144 | ): Promise<string[]> { | 142 | ): Promise<string[]> { |
| 145 | const term = toolPath.split(".").filter(Boolean).join(" "); | 143 | const term = toolPath.split(".").filter(Boolean).join(" "); |
| 146 | const hits = await searchRegistryEntries(ctx, { | 144 | const hits = await searchRegistryEntries(ctx, { |
| 147 | workspaceId, | 145 | workspaceId, |
| 148 | buildId, | ||
| 149 | query: term, | 146 | query: term, |
| 150 | limit: 3, | 147 | limit: 3, |
| 151 | }); | 148 | }); |
| @@ -185,20 +182,15 @@ export async function resolveToolForCall( · 33 unmodified lines | |||
| 185 | return Result.ok({ kind: "builtin", tool: builtin, resolvedToolPath: toolPath }); | 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 | workspaceId: task.workspaceId, | 186 | workspaceId: task.workspaceId, |
| 190 | accountId: task.accountId, | ||
| 191 | clientId: task.clientId, | ||
| 192 | }); | 187 | }); |
| 193 | if (buildIdResult.isErr()) { | 188 | if (readyResult.isErr()) { |
| 194 | return Result.err(buildIdResult.error); | 189 | return Result.err(readyResult.error); |
| 195 | } | 190 | } |
| 196 | const buildId = buildIdResult.value; | ||
| 197 | |||
| 198 | let resolvedToolPath = toolPath; | 191 | let resolvedToolPath = toolPath; |
| 199 | let entry = await getRegistryToolByPath(ctx, { | 192 | let entry = await getRegistryToolByPath(ctx, { |
| 200 | workspaceId: task.workspaceId, | 193 | workspaceId: task.workspaceId, |
| 201 | buildId, | ||
| 202 | path: toolPath, | 194 | path: toolPath, |
| 203 | }); | 195 | }); |
| 204 | 196 | ||
| @@ -206,7 +198,6 @@ export async function resolveToolForCall( · 1 unmodified line | |||
| 206 | const normalized = normalizeToolPathForLookup(toolPath); | 198 | const normalized = normalizeToolPathForLookup(toolPath); |
| 207 | const hits = await getRegistryToolsByNormalizedPath(ctx, { | 199 | const hits = await getRegistryToolsByNormalizedPath(ctx, { |
| 208 | workspaceId: task.workspaceId, | 200 | workspaceId: task.workspaceId, |
| 209 | buildId, | ||
| 210 | normalizedPath: normalized, | 201 | normalizedPath: normalized, |
| 211 | limit: 5, | 202 | limit: 5, |
| 212 | }); | 203 | }); |
| @@ -224,7 +215,7 @@ export async function resolveToolForCall( · 11 unmodified lines | |||
| 224 | } | 215 | } |
| 225 | 216 | ||
| 226 | if (!entry) { | 217 | if (!entry) { |
| 227 | const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath); | 218 | const suggestions = await suggestFromRegistry(ctx, task.workspaceId, toolPath); |
| 228 | return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions))); | 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 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; | 27 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; |
| 28 | import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials"; | 28 | import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials"; |
| 29 | import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; | 29 | import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; |
| 30 | import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; | 30 | import { getRegistryReadyResult } from "./tool_registry_state"; |
| 31 | import { | 31 | import { |
| 32 | catalogNamespaceSchema, | 32 | catalogNamespaceSchema, |
| 33 | catalogNamespacesInputSchema, | 33 | catalogNamespacesInputSchema, |
| @@ -214,7 +214,7 @@ async function listWorkspaceToolPolicies( · 180 unmodified lines | |||
| 214 | 214 | ||
| 215 | async function listRegistryNamespaces( | 215 | async function listRegistryNamespaces( |
| 216 | ctx: ActionCtx, | 216 | ctx: ActionCtx, |
| 217 | args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; limit: number }, | 217 | args: { workspaceId: TaskRecord["workspaceId"]; limit: number }, |
| 218 | ): Promise<CatalogNamespace[]> { | 218 | ): Promise<CatalogNamespace[]> { |
| 219 | const namespaces = await ctx.runQuery(internal.toolRegistry.listNamespaces, args); | 219 | const namespaces = await ctx.runQuery(internal.toolRegistry.listNamespaces, args); |
| 220 | const parsed = z.array(catalogNamespaceSchema).safeParse(namespaces); | 220 | const parsed = z.array(catalogNamespaceSchema).safeParse(namespaces); |
| @@ -272,7 +272,6 @@ async function searchRegistryTools( · 51 unmodified lines | |||
| 272 | ctx: ActionCtx, | 272 | ctx: ActionCtx, |
| 273 | args: { | 273 | args: { |
| 274 | workspaceId: TaskRecord["workspaceId"]; | 274 | workspaceId: TaskRecord["workspaceId"]; |
| 275 | buildId: string; | ||
| 276 | query: string; | 275 | query: string; |
| 277 | limit: number; | 276 | limit: number; |
| 278 | includeSchemas?: boolean; | 277 | includeSchemas?: boolean; |
| @@ -280,7 +279,6 @@ async function searchRegistryTools( · 1 unmodified line | |||
| 280 | ): Promise<RegistryToolEntry[]> { | 279 | ): Promise<RegistryToolEntry[]> { |
| 281 | const entries = await ctx.runQuery(internal.toolRegistry.searchTools, { | 280 | const entries = await ctx.runQuery(internal.toolRegistry.searchTools, { |
| 282 | workspaceId: args.workspaceId, | 281 | workspaceId: args.workspaceId, |
| 283 | buildId: args.buildId, | ||
| 284 | query: args.query, | 282 | query: args.query, |
| 285 | limit: args.limit, | 283 | limit: args.limit, |
| 286 | }); | 284 | }); |
| @@ -290,7 +288,6 @@ async function searchRegistryTools( · 3 unmodified lines | |||
| 290 | 288 | ||
| 291 | const payloads = await ctx.runQuery(internal.toolRegistry.getSerializedToolsByPaths, { | 289 | const payloads = await ctx.runQuery(internal.toolRegistry.getSerializedToolsByPaths, { |
| 292 | workspaceId: args.workspaceId, | 290 | workspaceId: args.workspaceId, |
| 293 | buildId: args.buildId, | ||
| 294 | paths: parsed.data.map((entry) => entry.path), | 291 | paths: parsed.data.map((entry) => entry.path), |
| 295 | }); | 292 | }); |
| 296 | const parsedPayloads = z.array(registryToolPayloadEntrySchema).safeParse(payloads); | 293 | const parsedPayloads = z.array(registryToolPayloadEntrySchema).safeParse(payloads); |
| @@ -310,7 +307,6 @@ async function listRegistryToolsByNamespace( · 13 unmodified lines | |||
| 310 | ctx: ActionCtx, | 307 | ctx: ActionCtx, |
| 311 | args: { | 308 | args: { |
| 312 | workspaceId: TaskRecord["workspaceId"]; | 309 | workspaceId: TaskRecord["workspaceId"]; |
| 313 | buildId: string; | ||
| 314 | namespace: string; | 310 | namespace: string; |
| 315 | limit: number; | 311 | limit: number; |
| 316 | includeSchemas?: boolean; | 312 | includeSchemas?: boolean; |
| @@ -318,7 +314,6 @@ async function listRegistryToolsByNamespace( · 1 unmodified line | |||
| 318 | ): Promise<RegistryToolEntry[]> { | 314 | ): Promise<RegistryToolEntry[]> { |
| 319 | const entries = await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, { | 315 | const entries = await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, { |
| 320 | workspaceId: args.workspaceId, | 316 | workspaceId: args.workspaceId, |
| 321 | buildId: args.buildId, | ||
| 322 | namespace: args.namespace, | 317 | namespace: args.namespace, |
| 323 | limit: args.limit, | 318 | limit: args.limit, |
| 324 | }); | 319 | }); |
| @@ -328,7 +323,6 @@ async function listRegistryToolsByNamespace( · 3 unmodified lines | |||
| 328 | 323 | ||
| 329 | const payloads = await ctx.runQuery(internal.toolRegistry.getSerializedToolsByPaths, { | 324 | const payloads = await ctx.runQuery(internal.toolRegistry.getSerializedToolsByPaths, { |
| 330 | workspaceId: args.workspaceId, | 325 | workspaceId: args.workspaceId, |
| 331 | buildId: args.buildId, | ||
| 332 | paths: parsed.data.map((entry) => entry.path), | 326 | paths: parsed.data.map((entry) => entry.path), |
| 333 | }); | 327 | }); |
| 334 | const parsedPayloads = z.array(registryToolPayloadEntrySchema).safeParse(payloads); | 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 | return output; | 533 | return output; |
| 540 | } | 534 | } |
| 541 | 535 | ||
| 542 | const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { | 536 | const readyResult = await getRegistryReadyResult(ctx, { |
| 543 | workspaceId: task.workspaceId, | 537 | workspaceId: task.workspaceId, |
| 544 | accountId: task.accountId, | ||
| 545 | clientId: task.clientId, | ||
| 546 | }); | 538 | }); |
| 547 | const buildId = buildIdResult.isErr() ? undefined : buildIdResult.value; | 539 | const registryReady = readyResult.isOk(); |
| 548 | const state = buildId | 540 | const state = registryReady |
| 549 | ? await ctx.runQuery(internal.toolRegistry.getState, { | 541 | ? await ctx.runQuery(internal.toolRegistry.getState, { |
| 550 | workspaceId: task.workspaceId, | 542 | workspaceId: task.workspaceId, |
| 551 | }) as unknown as { openApiRefHintTables?: unknown } | null | 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 | const parsedInput = catalogNamespacesInputSchema.safeParse(payload); | 571 | const parsedInput = catalogNamespacesInputSchema.safeParse(payload); |
| 580 | const limitInput = parsedInput.success ? parsedInput.data.limit : undefined; | 572 | const limitInput = parsedInput.success ? parsedInput.data.limit : undefined; |
| 581 | const limit = Math.max(1, Math.min(200, Number(limitInput ?? 200))); | 573 | const limit = Math.max(1, Math.min(200, Number(limitInput ?? 200))); |
| 582 | const registryNamespaces = buildId | 574 | const registryNamespaces = registryReady |
| 583 | ? await listRegistryNamespaces(ctx, { | 575 | ? await listRegistryNamespaces(ctx, { |
| 584 | workspaceId: task.workspaceId, | 576 | workspaceId: task.workspaceId, |
| 585 | buildId, | ||
| 586 | limit: 200, | 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 | const limit = Math.max(1, Math.min(200, Number(limitInput ?? 50))); | 624 | const limit = Math.max(1, Math.min(200, Number(limitInput ?? 50))); |
| 634 | const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false; | 625 | const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false; |
| 635 | 626 | ||
| 636 | const raw = !buildId | 627 | const raw = !registryReady |
| 637 | ? [] | 628 | ? [] |
| 638 | : query | 629 | : query |
| 639 | ? await searchRegistryTools(ctx, { | 630 | ? await searchRegistryTools(ctx, { |
| 640 | workspaceId: task.workspaceId, | 631 | workspaceId: task.workspaceId, |
| 641 | buildId, | ||
| 642 | query, | 632 | query, |
| 643 | limit, | 633 | limit, |
| 644 | includeSchemas, | 634 | includeSchemas, |
| @@ -646,7 +636,6 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 1 unmodified line | |||
| 646 | : namespace | 636 | : namespace |
| 647 | ? await listRegistryToolsByNamespace(ctx, { | 637 | ? await listRegistryToolsByNamespace(ctx, { |
| 648 | workspaceId: task.workspaceId, | 638 | workspaceId: task.workspaceId, |
| 649 | buildId, | ||
| 650 | namespace, | 639 | namespace, |
| 651 | limit, | 640 | limit, |
| 652 | includeSchemas, | 641 | includeSchemas, |
| @@ -739,10 +728,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 86 unmodified lines | |||
| 739 | const limit = Math.max(1, Math.min(50, Number(limitInput ?? 8))); | 728 | const limit = Math.max(1, Math.min(50, Number(limitInput ?? 8))); |
| 740 | const compact = parsedInput.success ? (parsedInput.data.compact ?? true) : true; | 729 | const compact = parsedInput.success ? (parsedInput.data.compact ?? true) : true; |
| 741 | const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false; | 730 | const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false; |
| 742 | const hits = buildId | 731 | const hits = registryReady |
| 743 | ? await searchRegistryTools(ctx, { | 732 | ? await searchRegistryTools(ctx, { |
| 744 | workspaceId: task.workspaceId, | 733 | workspaceId: task.workspaceId, |
| 745 | buildId, | ||
| 746 | query, | 734 | query, |
| 747 | limit: Math.max(limit * 2, limit), | 735 | limit: Math.max(limit * 2, limit), |
| 748 | includeSchemas, | 736 | includeSchemas, |
| @@ -17,8 +17,17 @@ export function registrySignatureForWorkspace( · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | const registryStateSchema = z.object({ | 18 | const registryStateSchema = z.object({ |
| 19 | signature: z.string().optional(), | 19 | signature: z.string().optional(), |
| 20 | readyBuildId: z.string().optional(), | 20 | sourceStates: z.array(z.object({ |
| 21 | buildingBuildId: z.string().optional(), | 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 | type RegistryState = z.infer<typeof registryStateSchema> | null; | 33 | type RegistryState = z.infer<typeof registryStateSchema> | null; |
| @@ -70,7 +79,7 @@ function toToolSourceStateList(value: unknown): ToolSourceState[] { · 45 unmodified lines | |||
| 70 | async function readRegistryState( | 79 | async function readRegistryState( |
| 71 | ctx: Pick<ActionCtx, "runQuery">, | 80 | ctx: Pick<ActionCtx, "runQuery">, |
| 72 | workspaceId: Id<"workspaces">, | 81 | workspaceId: Id<"workspaces">, |
| 73 | ): Promise<{ buildId?: string; isReady: boolean }> { | 82 | ): Promise<{ isReady: boolean }> { |
| 74 | const [rawState, rawSources] = await Promise.all([ | 83 | const [rawState, rawSources] = await Promise.all([ |
| 75 | ctx.runQuery(internal.toolRegistry.getState, { workspaceId }), | 84 | ctx.runQuery(internal.toolRegistry.getState, { workspaceId }), |
| 76 | ctx.runQuery(internal.database.listToolSources, { workspaceId }), | 85 | ctx.runQuery(internal.database.listToolSources, { workspaceId }), |
| @@ -79,44 +88,44 @@ async function readRegistryState( · 2 unmodified lines | |||
| 79 | const sources = toToolSourceStateList(rawSources); | 88 | const sources = toToolSourceStateList(rawSources); |
| 80 | 89 | ||
| 81 | const expectedSignature = registrySignatureForWorkspace(workspaceId, sources); | 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 | return { | 112 | return { |
| 85 | buildId, | 113 | isReady: Boolean(state?.signature === expectedSignature && !hasPendingOrMismatchedSource), |
| 86 | isReady: Boolean(buildId && !state?.buildingBuildId && state?.signature === expectedSignature), | ||
| 87 | }; | 114 | }; |
| 88 | } | 115 | } |
| 89 | 116 | ||
| 90 | export async function getReadyRegistryBuildIdResult( | 117 | export async function getRegistryReadyResult( |
| 91 | ctx: Pick<ActionCtx, "runQuery" | "runAction">, | 118 | ctx: Pick<ActionCtx, "runQuery">, |
| 92 | args: { | 119 | args: { |
| 93 | workspaceId: Id<"workspaces">; | 120 | workspaceId: Id<"workspaces">; |
| 94 | accountId?: Id<"accounts">; | ||
| 95 | clientId?: string; | ||
| 96 | }, | 121 | }, |
| 97 | ): Promise<Result<string, Error>> { | 122 | ): Promise<Result<void, Error>> { |
| 98 | const initial = await readRegistryState(ctx, args.workspaceId); | 123 | const initial = await readRegistryState(ctx, args.workspaceId); |
| 99 | if (initial.isReady && initial.buildId) { | 124 | if (initial.isReady) { |
| 100 | return Result.ok(initial.buildId); | 125 | return Result.ok(undefined); |
| 101 | } | 126 | } |
| 102 | 127 | ||
| 103 | return Result.err( | 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 | } from "./tool_source_artifact"; | 28 | } from "./tool_source_artifact"; |
| 29 | 29 | ||
| 30 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 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 | const openApiAuthModeSchema = z.enum(["account", "workspace", "organization"]); | 36 | const openApiAuthModeSchema = z.enum(["account", "workspace", "organization"]); |
| 35 | 37 | ||
| @@ -329,11 +331,34 @@ async function loadCachedOpenApiSpec( · 293 unmodified lines | |||
| 329 | console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`); | 331 | console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`); |
| 330 | } | 332 | } |
| 331 | 333 | ||
| 332 | const preparedResponse = await ctx.runAction(internal.runtimeNode.prepareOpenApiSpec, { | 334 | let preparedResponse: unknown = undefined; |
| 333 | specUrl, | 335 | let lastPrepareError: string | undefined; |
| 334 | sourceName, | 336 | for (let attempt = 1; attempt <= OPENAPI_PREPARE_MAX_ATTEMPTS; attempt += 1) { |
| 335 | includeDts, | 337 | try { |
| 336 | }); | 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 | let preparedPayload: unknown; | 363 | let preparedPayload: unknown; |
| 339 | let preparedStorageId: Id<"_storage"> | undefined; | 364 | let preparedStorageId: Id<"_storage"> | undefined; |
| @@ -37,7 +37,6 @@ function clampPreview(items: string[]): { items: string[]; truncated: boolean } · 36 unmodified lines | |||
| 37 | async function listCurrentSourceToolMap( | 37 | async function listCurrentSourceToolMap( |
| 38 | ctx: ActionCtx, | 38 | ctx: ActionCtx, |
| 39 | workspaceId: Id<"workspaces">, | 39 | workspaceId: Id<"workspaces">, |
| 40 | buildId: string, | ||
| 41 | sourceLabel: string, | 40 | sourceLabel: string, |
| 42 | ): Promise<Map<string, string>> { | 41 | ): Promise<Map<string, string>> { |
| 43 | const tools = new Map<string, string>(); | 42 | const tools = new Map<string, string>(); |
| @@ -49,7 +48,6 @@ async function listCurrentSourceToolMap( · 5 unmodified lines | |||
| 49 | items: Array<{ path: string }>; | 48 | items: Array<{ path: string }>; |
| 50 | } = await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { | 49 | } = await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { |
| 51 | workspaceId, | 50 | workspaceId, |
| 52 | buildId, | ||
| 53 | source: sourceLabel, | 51 | source: sourceLabel, |
| 54 | cursor, | 52 | cursor, |
| 55 | limit: 500, | 53 | limit: 500, |
| @@ -59,7 +57,6 @@ async function listCurrentSourceToolMap( · 3 unmodified lines | |||
| 59 | internal.toolRegistry.getSerializedToolsByPaths, | 57 | internal.toolRegistry.getSerializedToolsByPaths, |
| 60 | { | 58 | { |
| 61 | workspaceId, | 59 | workspaceId, |
| 62 | buildId, | ||
| 63 | paths: page.items.map((item) => item.path), | 60 | paths: page.items.map((item) => item.path), |
| 64 | }, | 61 | }, |
| 65 | ); | 62 | ); |
| @@ -134,12 +131,11 @@ export async function previewOpenApiSourceUpgradeForContext( · 68 unmodified lines | |||
| 134 | proposedByPath.set(tool.path, JSON.stringify(tool)); | 131 | proposedByPath.set(tool.path, JSON.stringify(tool)); |
| 135 | } | 132 | } |
| 136 | 133 | ||
| 137 | const state = await ctx.runQuery(internal.toolRegistry.getState, { | 134 | const currentByPath = await listCurrentSourceToolMap( |
| 138 | workspaceId: context.workspaceId, | 135 | ctx, |
| 139 | }); | 136 | context.workspaceId, |
| 140 | const currentByPath = state?.readyBuildId | 137 | `openapi:${existing.name}`, |
| 141 | ? await listCurrentSourceToolMap(ctx, context.workspaceId, state.readyBuildId, `openapi:${existing.name}`) | 138 | ); |
| 142 | : new Map<string, string>(); | ||
| 143 | 139 | ||
| 144 | const added: string[] = []; | 140 | const added: string[] = []; |
| 145 | const removed: string[] = []; | 141 | const removed: string[] = []; |
| @@ -12,10 +12,9 @@ import { · 11 unmodified lines | |||
| 12 | } from "../../../core/src/type-hints"; | 12 | } from "../../../core/src/type-hints"; |
| 13 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils"; | 13 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils"; |
| 14 | import { | 14 | import { |
| 15 | materializeCompiledToolSource, | ||
| 16 | type CompiledToolSourceArtifact, | 15 | type CompiledToolSourceArtifact, |
| 17 | } from "./tool_source_artifact"; | 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 | import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types"; | 18 | import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types"; |
| 20 | import type { | 19 | import type { |
| 21 | ToolPolicyRecord, | 20 | ToolPolicyRecord, |
| @@ -75,35 +74,17 @@ async function listWorkspaceToolPolicies( · 53 unmodified lines | |||
| 75 | return policies; | 74 | return policies; |
| 76 | } | 75 | } |
| 77 | 76 | ||
| 78 | interface WorkspaceToolsResult { | 77 | export type ToolInventoryState = "initializing" | "ready" | "rebuilding" | "failed"; |
| 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"; | ||
| 86 | 78 | ||
| 87 | export interface ToolInventoryStatus { | 79 | export interface ToolInventoryStatus { |
| 88 | state: ToolInventoryState; | 80 | state: ToolInventoryState; |
| 89 | readyBuildId?: string; | ||
| 90 | buildingBuildId?: string; | ||
| 91 | readyToolCount: number; | 81 | readyToolCount: number; |
| 92 | loadingSourceNames: string[]; | 82 | loadingSourceNames: string[]; |
| 93 | sourceToolCounts: Record<string, number>; | 83 | sourceToolCounts: Record<string, number>; |
| 94 | lastBuildStartedAt?: number; | ||
| 95 | lastBuildCompletedAt?: number; | ||
| 96 | lastBuildFailedAt?: number; | ||
| 97 | error?: string; | 84 | error?: string; |
| 98 | updatedAt?: number; | 85 | updatedAt?: number; |
| 99 | } | 86 | } |
| 100 | 87 | ||
| 101 | interface GetWorkspaceToolsOptions { | ||
| 102 | sourceTimeoutMs?: number; | ||
| 103 | accountId?: Id<"accounts">; | ||
| 104 | includeDts?: boolean; | ||
| 105 | } | ||
| 106 | |||
| 107 | interface WorkspaceToolInventory { | 88 | interface WorkspaceToolInventory { |
| 108 | tools: ToolDescriptor[]; | 89 | tools: ToolDescriptor[]; |
| 109 | warnings: string[]; | 90 | warnings: string[]; |
| @@ -119,10 +100,10 @@ export type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | · 9 unmodified lines | |||
| 119 | 100 | ||
| 120 | const MAX_TOOLS_IN_ACTION_RESULT = 8_000; | 101 | const MAX_TOOLS_IN_ACTION_RESULT = 8_000; |
| 121 | const MAX_TOOL_DETAILS_LOOKUP_PATHS = 100; | 102 | const MAX_TOOL_DETAILS_LOOKUP_PATHS = 100; |
| 122 | const REGISTRY_BUILD_STALE_MS = 2 * 60_000; | ||
| 123 | const MAX_REF_HINTS_PER_SOURCE = 300; | 103 | const MAX_REF_HINTS_PER_SOURCE = 300; |
| 124 | const MAX_REF_HINT_HINT_CHARS = 240; | 104 | const MAX_REF_HINT_HINT_CHARS = 240; |
| 125 | const MAX_REF_HINT_METADATA_BYTES = 220_000; | 105 | const MAX_REF_HINT_METADATA_BYTES = 220_000; |
| 106 | const SOURCE_LOAD_HEARTBEAT_MS = 5_000; | ||
| 126 | 107 | ||
| 127 | function truncateToolsForActionResult( | 108 | function truncateToolsForActionResult( |
| 128 | tools: ToolDescriptor[], | 109 | tools: ToolDescriptor[], |
| @@ -355,20 +336,20 @@ function toDescriptorFromRegistryEntry( · 226 unmodified lines | |||
| 355 | source: entry.source, | 336 | source: entry.source, |
| 356 | ...(includeDetails | 337 | ...(includeDetails |
| 357 | ? { | 338 | ? { |
| 358 | typing: { | 339 | typing: { |
| 359 | requiredInputKeys: entry.requiredInputKeys, | 340 | requiredInputKeys: entry.requiredInputKeys, |
| 360 | previewInputKeys: entry.previewInputKeys, | 341 | previewInputKeys: entry.previewInputKeys, |
| 361 | ...(inputSchemaJson ? { inputSchemaJson } : {}), | 342 | ...(inputSchemaJson ? { inputSchemaJson } : {}), |
| 362 | ...(outputSchemaJson ? { outputSchemaJson } : {}), | 343 | ...(outputSchemaJson ? { outputSchemaJson } : {}), |
| 363 | ...(refHintResolution.refHintKeys.length > 0 ? { refHintKeys: refHintResolution.refHintKeys } : {}), | 344 | ...(refHintResolution.refHintKeys.length > 0 ? { refHintKeys: refHintResolution.refHintKeys } : {}), |
| 364 | ...(Object.keys(refHintResolution.refHints).length > 0 ? { refHints: refHintResolution.refHints } : {}), | 345 | ...(Object.keys(refHintResolution.refHints).length > 0 ? { refHints: refHintResolution.refHints } : {}), |
| 365 | typedRef: entry.typedRef, | 346 | typedRef: entry.typedRef, |
| 366 | }, | 347 | }, |
| 367 | display: { | 348 | display: { |
| 368 | input: resolvedDisplayInput, | 349 | input: resolvedDisplayInput, |
| 369 | output: resolvedDisplayOutput, | 350 | output: resolvedDisplayOutput, |
| 370 | }, | 351 | }, |
| 371 | } | 352 | } |
| 372 | : {}), | 353 | : {}), |
| 373 | }; | 354 | }; |
| 374 | } | 355 | } |
| @@ -626,299 +607,625 @@ function getPathAliases(path: string): string[] { · 251 unmodified lines | |||
| 626 | return [...aliases].slice(0, 4); | 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( | 657 | function sourceStateSourceKey(source: Pick<ToolSourceRecord, "type" | "name">): string { |
| 631 | ctx: ActionCtx, | 658 | return `${source.type}:${source.name}`; |
| 632 | args: { | 659 | } |
| 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 | }); | ||
| 644 | 660 | ||
| 645 | try { | 661 | function sourceLoadingMessage(source: ExternalToolSourceConfig, elapsedSeconds: number): string { |
| 646 | const entries = args.serializedTools.map((st) => { | 662 | if (source.type === "openapi") { |
| 647 | if (st.path === "discover" || st.path.startsWith("catalog.")) { | 663 | let hostSuffix = ""; |
| 648 | return null; | 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); | 673 | } |
| 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)); | ||
| 669 | 674 | ||
| 670 | const displayInput = useInputHint && inputHint | 675 | if (elapsedSeconds < 10) { |
| 671 | ? displayArgTypeHint(inputHint) | 676 | return `Downloading OpenAPI document${hostSuffix}`; |
| 672 | : compactArgTypeHintFromSchema(inputSchema); | 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 | 684 | if (source.type === "graphql") { |
| 675 | ? displayReturnTypeHint(outputHint) | 685 | return elapsedSeconds < 12 |
| 676 | : compactReturnTypeHintFromSchema(outputSchema); | 686 | ? "Loading GraphQL schema" |
| 687 | : "Building GraphQL operations"; | ||
| 688 | } | ||
| 677 | 689 | ||
| 678 | const typedRef = st.typing?.typedRef && st.typing.typedRef.kind === "openapi_operation" | 690 | return elapsedSeconds < 12 |
| 679 | ? { | 691 | ? "Connecting to MCP source" |
| 680 | kind: "openapi_operation" as const, | 692 | : "Discovering MCP tools"; |
| 681 | sourceKey: st.typing.typedRef.sourceKey, | 693 | } |
| 682 | operationId: st.typing.typedRef.operationId, | ||
| 683 | } | ||
| 684 | : undefined; | ||
| 685 | 694 | ||
| 686 | return { | 695 | function sleep(ms: number): Promise<void> { |
| 687 | path: st.path, | 696 | return new Promise((resolve) => { |
| 688 | preferredPath, | 697 | setTimeout(resolve, ms); |
| 689 | namespace, | 698 | }); |
| 690 | normalizedPath, | 699 | } |
| 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 | }); | ||
| 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[] }>(); | 704 | for (const st of serializedTools) { |
| 708 | for (const entry of filteredEntries) { | 705 | if (st.path === "discover" || st.path.startsWith("catalog.")) { |
| 709 | const current = namespaceMap.get(entry.namespace) ?? { toolCount: 0, samplePaths: [] }; | 706 | continue; |
| 710 | current.toolCount += 1; | 707 | } |
| 711 | if (current.samplePaths.length < 6) { | 708 | const preferredPath = preferredToolPath(st.path); |
| 712 | current.samplePaths.push(entry.preferredPath); | 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()] | 812 | return entries; |
| 718 | .map(([namespace, meta]) => ({ | 813 | } |
| 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)); | ||
| 724 | 814 | ||
| 725 | const TOOL_BATCH = 100; | 815 | async function listAllSerializedTools( |
| 726 | for (let i = 0; i < filteredEntries.length; i += TOOL_BATCH) { | 816 | ctx: ActionCtx, |
| 727 | await ctx.runMutation(internal.toolRegistry.putToolsBatch, { | 817 | workspaceId: Id<"workspaces">, |
| 728 | workspaceId: args.workspaceId, | 818 | ): Promise<SerializedTool[]> { |
| 729 | buildId, | 819 | const serializedTools: SerializedTool[] = []; |
| 730 | tools: filteredEntries.slice(i, i + TOOL_BATCH), | 820 | let cursor: string | undefined; |
| 731 | }); | 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; | 844 | if (page.continueCursor === null) { |
| 735 | for (let i = 0; i < namespaces.length; i += NS_BATCH) { | 845 | break; |
| 736 | await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, { | ||
| 737 | workspaceId: args.workspaceId, | ||
| 738 | buildId, | ||
| 739 | namespaces: namespaces.slice(i, i + NS_BATCH), | ||
| 740 | }); | ||
| 741 | } | 846 | } |
| 847 | cursor = page.continueCursor; | ||
| 848 | } | ||
| 742 | 849 | ||
| 743 | await ctx.runMutation(internal.toolRegistry.finishBuild, { | 850 | return serializedTools; |
| 744 | workspaceId: args.workspaceId, | 851 | } |
| 745 | buildId, | ||
| 746 | signature: args.registrySignature, | ||
| 747 | }); | ||
| 748 | 852 | ||
| 749 | await ctx.runAction(internal.toolRegistry.pruneBuilds, { | 853 | async function rebuildNamespaceSummaries( |
| 750 | workspaceId: args.workspaceId, | 854 | ctx: ActionCtx, |
| 751 | maxRetainedBuilds: 2, | 855 | workspaceId: Id<"workspaces">, |
| 752 | }); | 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 }; | 873 | const namespaceMap = new Map<string, { toolCount: number; samplePaths: string[] }>(); |
| 755 | } catch (error) { | 874 | for (const entry of entries) { |
| 756 | await ctx.runMutation(internal.toolRegistry.failBuild, { | 875 | const namespace = (entry.preferredPath.split(".")[0] ?? "default").toLowerCase(); |
| 757 | workspaceId: args.workspaceId, | 876 | const current = namespaceMap.get(namespace) ?? { toolCount: 0, samplePaths: [] }; |
| 758 | buildId, | 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. | 901 | async function refreshWorkspaceToolRegistryIncremental( |
| 765 | |||
| 766 | export async function getWorkspaceTools( | ||
| 767 | ctx: ActionCtx, | 902 | ctx: ActionCtx, |
| 768 | workspaceId: Id<"workspaces">, | 903 | context: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string }, |
| 769 | options: GetWorkspaceToolsOptions = {}, | 904 | ): Promise<void> { |
| 770 | ): Promise<WorkspaceToolsResult> { | 905 | const workspaceId = context.workspaceId; |
| 771 | const includeDts = options.includeDts ?? false; | ||
| 772 | const sourceTimeoutMs = options.sourceTimeoutMs; | ||
| 773 | const accountId = options.accountId; | ||
| 774 | const sources = (await listWorkspaceToolSources(ctx, workspaceId)) | 906 | const sources = (await listWorkspaceToolSources(ctx, workspaceId)) |
| 775 | .filter((source) => source.enabled); | 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 | const warnings: string[] = []; | 931 | const warnings: string[] = []; |
| 932 | |||
| 780 | for (const source of sources) { | 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 | const normalizedResult = normalizeExternalToolSource(source); | 956 | const normalizedResult = normalizeExternalToolSource(source); |
| 782 | if (normalizedResult.isErr()) { | 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 | continue; | 975 | continue; |
| 785 | } | 976 | } |
| 786 | configs.push(normalizedResult.value); | ||
| 787 | } | ||
| 788 | 977 | ||
| 789 | const loadedSources = await Promise.all(configs.map(async (config) => { | 978 | const initialLoadMessage = sourceLoadingMessage(normalizedResult.value, 0); |
| 790 | if (!sourceTimeoutMs || sourceTimeoutMs <= 0) { | 979 | sourceStatesById.set(source.id, { |
| 791 | return { | 980 | sourceId: source.id, |
| 792 | ...(await loadSourceArtifact(ctx, config, { includeDts, workspaceId, accountId })), | 981 | sourceName: source.name, |
| 793 | timedOut: false, | 982 | sourceKey, |
| 794 | sourceName: config.name, | 983 | signature, |
| 795 | }; | 984 | state: "loading", |
| 796 | } | 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; | 1001 | const elapsedMs = Date.now() - startedAt; |
| 799 | const timeoutResult = new Promise<{ | 1002 | const elapsedSeconds = Math.max(1, Math.floor(elapsedMs / 1000)); |
| 800 | artifact?: CompiledToolSourceArtifact; | 1003 | const loadingMessage = `${sourceLoadingMessage(normalizedResult.value, elapsedSeconds)} (${elapsedSeconds}s)`; |
| 801 | warnings: string[]; | 1004 | sourceStatesById.set(source.id, { |
| 802 | timedOut: boolean; | 1005 | sourceId: source.id, |
| 803 | sourceName: string; | 1006 | sourceName: source.name, |
| 804 | openApiDts?: string; | 1007 | sourceKey, |
| 805 | openApiSourceKey?: string; | 1008 | signature, |
| 806 | }>((resolve) => { | 1009 | state: "loading", |
| 807 | timer = setTimeout(() => { | 1010 | toolCount: previous?.toolCount ?? 0, |
| 808 | resolve({ | 1011 | processedTools: 0, |
| 809 | artifact: undefined, | 1012 | message: loadingMessage, |
| 810 | warnings: [`Source '${config.name}' is still loading; showing partial results.`], | 1013 | startedAt, |
| 811 | timedOut: true, | 1014 | updatedAt: Date.now(), |
| 812 | sourceName: config.name, | ||
| 813 | openApiDts: undefined, | ||
| 814 | openApiSourceKey: config.type === "openapi" ? (config.sourceKey ?? `openapi:${config.name}`) : undefined, | ||
| 815 | }); | 1015 | }); |
| 816 | }, sourceTimeoutMs); | 1016 | await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); |
| 817 | }); | 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 }) | 1029 | try { |
| 820 | .then((result) => ({ ...result, timedOut: false, sourceName: config.name })); | 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]); | 1039 | const message = error instanceof Error ? error.message : String(error); |
| 823 | if (timer && !result.timedOut) { | 1040 | const failedAt = Date.now(); |
| 824 | clearTimeout(timer); | 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; | 1059 | keepHeartbeatRunning = false; |
| 840 | try { | 1060 | await heartbeat; |
| 841 | if (hasTimedOutSource) { | 1061 | |
| 842 | return { | 1062 | if (!loaded.artifact) { |
| 843 | tools: merged, | 1063 | const message = loaded.warnings[0] ?? `No tools generated for source '${source.name}'`; |
| 844 | warnings, | 1064 | const failedAt = Date.now(); |
| 845 | typesStorageId, | 1065 | sourceStatesById.set(source.id, { |
| 846 | timedOutSources, | 1066 | sourceId: source.id, |
| 847 | }; | 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. | 1087 | sourceStatesById.set(source.id, { |
| 853 | const { buildId } = await buildWorkspaceToolRegistry(ctx, { | 1088 | sourceId: source.id, |
| 854 | workspaceId, | 1089 | sourceName: source.name, |
| 855 | registrySignature, | 1090 | sourceKey, |
| 856 | serializedTools: externalArtifacts.flatMap((artifact) => artifact.tools), | 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. | 1101 | if (previous?.sourceKey && previous.sourceKey !== sourceKey) { |
| 860 | const openApiDtsBySource: Record<string, string> = {}; | 1102 | await ctx.runAction(internal.toolRegistry.deleteToolsBySource, { |
| 861 | for (const loaded of loadedSources) { | 1103 | workspaceId, |
| 862 | if (loaded.openApiDts && loaded.openApiDts.trim().length > 0) { | 1104 | source: previous.sourceKey, |
| 863 | const sourceKey = loaded.openApiSourceKey ?? `openapi:${loaded.sourceName}`; | 1105 | }); |
| 864 | openApiDtsBySource[sourceKey] = loaded.openApiDts; | ||
| 865 | } | ||
| 866 | } | 1106 | } |
| 867 | const typeBundle = buildWorkspaceTypeBundle({ | 1107 | |
| 868 | tools: allTools, | 1108 | await ctx.runAction(internal.toolRegistry.deleteToolsBySource, { |
| 869 | openApiDtsBySource, | 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>(); | 1113 | const TOOL_BATCH = 100; |
| 875 | for (const source of sources) { | 1114 | let processedTools = 0; |
| 876 | sourceToolCountsByName.set(source.name, 0); | 1115 | for (let i = 0; i < entries.length; i += TOOL_BATCH) { |
| 877 | } | 1116 | await ctx.runMutation(internal.toolRegistry.putToolsBatch, { |
| 878 | for (const tool of allTools) { | 1117 | workspaceId, |
| 879 | const sourceName = toSourceName(tool.source); | 1118 | tools: entries.slice(i, i + TOOL_BATCH), |
| 880 | if (!sourceName) continue; | 1119 | }); |
| 881 | sourceToolCountsByName.set(sourceName, (sourceToolCountsByName.get(sourceName) ?? 0) + 1); | ||
| 882 | } | ||
| 883 | 1120 | ||
| 884 | await ctx.runMutation(internal.toolRegistry.updateBuildMetadata, { | 1121 | processedTools = Math.min(entries.length, processedTools + TOOL_BATCH); |
| 885 | workspaceId, | 1122 | sourceStatesById.set(source.id, { |
| 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) => ({ | ||
| 895 | sourceId: source.id, | 1123 | sourceId: source.id, |
| 896 | sourceName: source.name, | 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 | sourceKey, | 1125 | sourceKey, |
| 904 | type: profile.type, | 1126 | signature, |
| 905 | mode: profile.mode, | 1127 | state: "indexing", |
| 906 | header: profile.header, | 1128 | toolCount: entries.length, |
| 907 | inferred: profile.inferred, | 1129 | processedTools, |
| 908 | })), | 1130 | message: `Indexing ${processedTools}/${entries.length} tools`, |
| 909 | openApiRefHintTables: buildBoundedOpenApiRefHintTables(externalArtifacts), | 1131 | startedAt, |
| 910 | } as never); | 1132 | updatedAt: Date.now(), |
| 911 | } catch (error) { | 1133 | }); |
| 912 | const msg = error instanceof Error ? error.message : String(error); | 1134 | await persistSourceStates(ctx, workspaceId, sourceStatesById.values()); |
| 913 | console.warn(`[executor] workspace tool registry metadata write failed for '${workspaceId}': ${msg}`); | 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 { | 1160 | const allRegistryEntries = await listAllRegistryEntries(ctx, workspaceId); |
| 917 | tools: merged, | 1161 | await rebuildNamespaceSummaries(ctx, workspaceId, allRegistryEntries); |
| 918 | warnings, | 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 | typesStorageId, | 1205 | typesStorageId, |
| 920 | timedOutSources, | 1206 | warnings, |
| 921 | }; | 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 | interface WorkspaceRegistryReadResult { | 1231 | interface WorkspaceRegistryReadResult { |
| @@ -934,12 +1241,15 @@ interface WorkspaceRegistryReadResult { · 9 unmodified lines | |||
| 934 | openApiRefHintLookup: Record<string, Record<string, string>>; | 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 | export interface ToolSourceGenerationStatus { | 1246 | export interface ToolSourceGenerationStatus { |
| 940 | state: ToolSourceGenerationState; | 1247 | state: ToolSourceGenerationState; |
| 941 | toolCount: number; | 1248 | toolCount: number; |
| 942 | changed: boolean; | 1249 | processedTools?: number; |
| 1250 | message?: string; | ||
| 1251 | error?: string; | ||
| 1252 | updatedAt?: number; | ||
| 943 | } | 1253 | } |
| 944 | 1254 | ||
| 945 | export interface WorkspaceInventoryProgress { | 1255 | export interface WorkspaceInventoryProgress { |
| @@ -986,37 +1296,29 @@ function toSourceAuthProfileRecord( · 40 unmodified lines | |||
| 986 | return result; | 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 | type WorkspaceRegistryStateRecord = { | 1299 | type WorkspaceRegistryStateRecord = { |
| 1007 | signature?: string; | 1300 | signature?: string; |
| 1008 | readyBuildId?: string; | 1301 | lastRefreshError?: string; |
| 1009 | buildingBuildId?: string; | ||
| 1010 | buildingSignature?: string; | ||
| 1011 | buildingStartedAt?: number; | ||
| 1012 | lastBuildCompletedAt?: number; | ||
| 1013 | lastBuildFailedAt?: number; | ||
| 1014 | lastBuildError?: string; | ||
| 1015 | typesStorageId?: Id<"_storage">; | 1302 | typesStorageId?: Id<"_storage">; |
| 1016 | warnings?: string[]; | 1303 | warnings?: string[]; |
| 1017 | toolCount?: number; | 1304 | toolCount?: number; |
| 1018 | sourceToolCounts?: Array<{ sourceName: string; toolCount: number }>; | 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 | sourceQuality?: OpenApiSourceQuality[]; | 1322 | sourceQuality?: OpenApiSourceQuality[]; |
| 1021 | sourceAuthProfiles?: Array<{ | 1323 | sourceAuthProfiles?: Array<{ |
| 1022 | sourceKey: string; | 1324 | sourceKey: string; |
| @@ -1032,86 +1334,129 @@ type WorkspaceRegistryStateRecord = { · 9 unmodified lines | |||
| 1032 | updatedAt?: number; | 1334 | updatedAt?: number; |
| 1033 | } | null; | 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 | function computeWorkspaceInventoryProgress( | 1380 | function computeWorkspaceInventoryProgress( |
| 1036 | workspaceId: Id<"workspaces">, | 1381 | workspaceId: Id<"workspaces">, |
| 1037 | sources: ToolSourceRecord[], | 1382 | sources: ToolSourceRecord[], |
| 1038 | registryState: WorkspaceRegistryStateRecord, | 1383 | registryState: WorkspaceRegistryStateRecord, |
| 1039 | ): WorkspaceInventoryProgress { | 1384 | ): WorkspaceInventoryProgress { |
| 1040 | const expectedRegistrySignature = registrySignatureForWorkspace(workspaceId, sources); | 1385 | const expectedRegistrySignature = registrySignatureForWorkspace(workspaceId, sources); |
| 1041 | const readyBuildId = registryState?.readyBuildId; | 1386 | const isFresh = Boolean(registryState?.signature && registryState.signature === expectedRegistrySignature); |
| 1042 | const building = Boolean(registryState?.buildingBuildId); | ||
| 1043 | const isFresh = Boolean(registryState?.signature && registryState.signature === expectedRegistrySignature && !building); | ||
| 1044 | const sourceCounts = toSourceToolCountRecord(registryState?.sourceToolCounts ?? []); | 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 | let state: ToolInventoryState; | 1434 | let state: ToolInventoryState; |
| 1048 | if (building && readyBuildId) { | 1435 | if (loadingSourceNames.length > 0 && (registryState?.toolCount ?? 0) > 0) { |
| 1049 | state = "rebuilding"; | 1436 | state = "rebuilding"; |
| 1050 | } else if (building && !readyBuildId) { | 1437 | } else if (loadingSourceNames.length > 0) { |
| 1051 | state = "initializing"; | 1438 | state = "initializing"; |
| 1052 | } else if (!readyBuildId && registryState?.lastBuildError) { | 1439 | } else if (failedSourceError || registryState?.lastRefreshError) { |
| 1053 | state = "failed"; | 1440 | state = "failed"; |
| 1054 | } else if (!readyBuildId) { | ||
| 1055 | state = "initializing"; | ||
| 1056 | } else if (isFresh) { | 1441 | } else if (isFresh) { |
| 1057 | state = "ready"; | 1442 | state = "ready"; |
| 1058 | } else if (registryState?.lastBuildError) { | ||
| 1059 | state = "failed"; | ||
| 1060 | } else { | 1443 | } else { |
| 1061 | state = "stale"; | 1444 | state = "ready"; |
| 1062 | } | 1445 | } |
| 1063 | 1446 | ||
| 1064 | const warnings = [...(registryState?.warnings ?? [])]; | 1447 | const warnings = [...(registryState?.warnings ?? [])]; |
| 1065 | const allSourceNames = sources.map((source) => source.name); | 1448 | for (const [sourceName, sourceStatus] of Object.entries(sourceStates)) { |
| 1066 | const loadingSourceNames = state === "initializing" | 1449 | if (sourceStatus.state === "failed" && sourceStatus.error) { |
| 1067 | ? [...allSourceNames] | 1450 | warnings.push(`Source '${sourceName}' failed: ${sourceStatus.error}`); |
| 1068 | : (state === "rebuilding" || state === "stale" || state === "failed") | 1451 | continue; |
| 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"; | ||
| 1102 | } | 1452 | } |
| 1103 | 1453 | } | |
| 1104 | sourceStates[source.name] = { | 1454 | if (state === "failed" && registryState?.lastRefreshError) { |
| 1105 | state: sourceState, | 1455 | warnings.push(`Tool inventory refresh failed: ${registryState.lastRefreshError}`); |
| 1106 | toolCount: sourceCounts[source.name] ?? 0, | ||
| 1107 | changed, | ||
| 1108 | }; | ||
| 1109 | } | 1456 | } |
| 1110 | 1457 | ||
| 1111 | const inventoryStatus: ToolInventoryStatus = { | 1458 | const inventoryStatus: ToolInventoryStatus = { |
| 1112 | state, | 1459 | state, |
| 1113 | readyBuildId, | ||
| 1114 | buildingBuildId: registryState?.buildingBuildId, | ||
| 1115 | readyToolCount: registryState?.toolCount ?? 0, | 1460 | readyToolCount: registryState?.toolCount ?? 0, |
| 1116 | loadingSourceNames, | 1461 | loadingSourceNames, |
| 1117 | sourceToolCounts: { | 1462 | sourceToolCounts: { |
| @@ -1119,22 +1464,19 @@ function computeWorkspaceInventoryProgress( · 1 unmodified line | |||
| 1119 | // Ensure system tools are always visible even for pre-existing registry states. | 1464 | // Ensure system tools are always visible even for pre-existing registry states. |
| 1120 | system: sourceCounts["system"] ?? baseTools.size, | 1465 | system: sourceCounts["system"] ?? baseTools.size, |
| 1121 | }, | 1466 | }, |
| 1122 | lastBuildStartedAt: registryState?.buildingStartedAt, | 1467 | ...(failedSourceError || registryState?.lastRefreshError |
| 1123 | lastBuildCompletedAt: registryState?.lastBuildCompletedAt, | 1468 | ? { error: failedSourceError ?? registryState?.lastRefreshError } |
| 1124 | lastBuildFailedAt: registryState?.lastBuildFailedAt, | 1469 | : {}), |
| 1125 | ...(registryState?.lastBuildError ? { error: registryState.lastBuildError } : {}), | ||
| 1126 | updatedAt: registryState?.updatedAt, | 1470 | updatedAt: registryState?.updatedAt, |
| 1127 | }; | 1471 | }; |
| 1128 | 1472 | ||
| 1129 | const sourceTokens = Object.entries(sourceStates) | 1473 | const sourceTokens = Object.entries(sourceStates) |
| 1130 | .sort(([left], [right]) => left.localeCompare(right)) | 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 | .join(","); | 1476 | .join(","); |
| 1133 | 1477 | ||
| 1134 | const reactiveKey = [ | 1478 | const reactiveKey = [ |
| 1135 | inventoryStatus.state, | 1479 | inventoryStatus.state, |
| 1136 | inventoryStatus.readyBuildId ?? "", | ||
| 1137 | inventoryStatus.buildingBuildId ?? "", | ||
| 1138 | String(inventoryStatus.updatedAt ?? 0), | 1480 | String(inventoryStatus.updatedAt ?? 0), |
| 1139 | sourceTokens, | 1481 | sourceTokens, |
| 1140 | ].join("|"); | 1482 | ].join("|"); |
| @@ -1162,14 +1504,14 @@ export async function getWorkspaceInventoryProgressForContext( · 21 unmodified lines | |||
| 1162 | 1504 | ||
| 1163 | const progress = computeWorkspaceInventoryProgress( | 1505 | const progress = computeWorkspaceInventoryProgress( |
| 1164 | workspaceId, | 1506 | workspaceId, |
| 1165 | sources.filter((source) => source.enabled), | 1507 | sources.filter((source: ToolSourceRecord) => source.enabled), |
| 1166 | registryState, | 1508 | registryState, |
| 1167 | ); | 1509 | ); |
| 1168 | 1510 | ||
| 1169 | // Append a lightweight policy fingerprint so the reactive key changes | 1511 | // Append a lightweight policy fingerprint so the reactive key changes |
| 1170 | // whenever any policy is created, updated, or deleted. | 1512 | // whenever any policy is created, updated, or deleted. |
| 1171 | const policyToken = policies | 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 | .sort() | 1515 | .sort() |
| 1174 | .join(","); | 1516 | .join(","); |
| 1175 | 1517 | ||
| @@ -1188,26 +1530,30 @@ async function getWorkspaceToolsFromRegistry( · 12 unmodified lines | |||
| 1188 | sourceName?: string; | 1530 | sourceName?: string; |
| 1189 | cursor?: string; | 1531 | cursor?: string; |
| 1190 | limit?: number; | 1532 | limit?: number; |
| 1191 | buildId?: string; | ||
| 1192 | fetchAll?: boolean; | 1533 | fetchAll?: boolean; |
| 1193 | } = {}, | 1534 | } = {}, |
| 1194 | ): Promise<WorkspaceRegistryReadResult> { | 1535 | ): Promise<WorkspaceRegistryReadResult> { |
| 1195 | const sources = (await listWorkspaceToolSources(ctx, workspaceId)) | 1536 | const sources = (await listWorkspaceToolSources(ctx, workspaceId)) |
| 1196 | .filter((source) => source.enabled); | 1537 | .filter((source: ToolSourceRecord) => source.enabled); |
| 1197 | 1538 | ||
| 1198 | const registryState = await ctx.runQuery(internal.toolRegistry.getState, { | 1539 | const registryState = await ctx.runQuery(internal.toolRegistry.getState, { |
| 1199 | workspaceId, | 1540 | workspaceId, |
| 1200 | }); | 1541 | }); |
| 1201 | 1542 | ||
| 1202 | const progress = computeWorkspaceInventoryProgress(workspaceId, sources, registryState); | 1543 | const progress = computeWorkspaceInventoryProgress(workspaceId, sources, registryState); |
| 1203 | const readyBuildId = progress.inventoryStatus.readyBuildId; | ||
| 1204 | const activeBuildId = options.buildId ?? readyBuildId; | ||
| 1205 | const sourceCounts = progress.inventoryStatus.sourceToolCounts; | 1544 | const sourceCounts = progress.inventoryStatus.sourceToolCounts; |
| 1206 | const sourceQuality = toSourceQualityRecord(registryState?.sourceQuality ?? []); | 1545 | const sourceQuality = toSourceQualityRecord(registryState?.sourceQuality ?? []); |
| 1207 | const sourceAuthProfiles = toSourceAuthProfileRecord(registryState?.sourceAuthProfiles ?? []); | 1546 | const sourceAuthProfiles = toSourceAuthProfileRecord(registryState?.sourceAuthProfiles ?? []); |
| 1208 | const openApiRefHintLookup = toOpenApiRefHintLookup(registryState?.openApiRefHintTables ?? []); | 1547 | const openApiRefHintLookup = toOpenApiRefHintLookup(registryState?.openApiRefHintTables ?? []); |
| 1209 | const warnings = [...progress.warnings]; | 1548 | const warnings = [...progress.warnings]; |
| 1210 | const scopedSourceName = options.sourceName ?? toSourceName(options.source) ?? undefined; | 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 | const allSourceNames = sources.map((source) => source.name); | 1557 | const allSourceNames = sources.map((source) => source.name); |
| 1212 | const relevantSourceNames = scopedSourceName | 1558 | const relevantSourceNames = scopedSourceName |
| 1213 | ? allSourceNames.filter((name) => name === scopedSourceName) | 1559 | ? allSourceNames.filter((name) => name === scopedSourceName) |
| @@ -1227,21 +1573,6 @@ async function getWorkspaceToolsFromRegistry( · 13 unmodified lines | |||
| 1227 | ? (sourceCounts[scopedSourceName] ?? 0) | 1573 | ? (sourceCounts[scopedSourceName] ?? 0) |
| 1228 | : inventoryStatus.readyToolCount; | 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 | const registryTools: RegistryToolEntry[] = []; | 1576 | const registryTools: RegistryToolEntry[] = []; |
| 1246 | const requestedPaths = [...new Set((options.toolPaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0))]; | 1577 | const requestedPaths = [...new Set((options.toolPaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0))]; |
| 1247 | let nextCursor: string | null | undefined; | 1578 | let nextCursor: string | null | undefined; |
| @@ -1250,7 +1581,6 @@ async function getWorkspaceToolsFromRegistry( · 2 unmodified lines | |||
| 1250 | const entries = await Promise.all(requestedPaths.map(async (path) => { | 1581 | const entries = await Promise.all(requestedPaths.map(async (path) => { |
| 1251 | const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, { | 1582 | const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, { |
| 1252 | workspaceId, | 1583 | workspaceId, |
| 1253 | buildId: activeBuildId, | ||
| 1254 | path, | 1584 | path, |
| 1255 | }); | 1585 | }); |
| 1256 | if (!entry) return null; | 1586 | if (!entry) return null; |
| @@ -1277,20 +1607,18 @@ async function getWorkspaceToolsFromRegistry( · 20 unmodified lines | |||
| 1277 | const page: { | 1607 | const page: { |
| 1278 | continueCursor: string | null; | 1608 | continueCursor: string | null; |
| 1279 | items: RegistryToolEntry[]; | 1609 | items: RegistryToolEntry[]; |
| 1280 | } = options.source | 1610 | } = sourceKeyFilter |
| 1281 | ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { | 1611 | ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { |
| 1282 | workspaceId, | 1612 | workspaceId, |
| 1283 | buildId: activeBuildId, | 1613 | source: sourceKeyFilter, |
| 1284 | source: options.source, | 1614 | cursor, |
| 1285 | cursor, | 1615 | limit: 100, |
| 1286 | limit: 100, | 1616 | }) |
| 1287 | }) | 1617 | : await ctx.runQuery(internal.toolRegistry.listToolsPage, { |
| 1288 | : await ctx.runQuery(internal.toolRegistry.listToolsPage, { | 1618 | workspaceId, |
| 1289 | workspaceId, | 1619 | cursor, |
| 1290 | buildId: activeBuildId, | 1620 | limit: 100, |
| 1291 | cursor, | 1621 | }); |
| 1292 | limit: 100, | ||
| 1293 | }); | ||
| 1294 | for (const entry of page.items) { | 1622 | for (const entry of page.items) { |
| 1295 | registryTools.push(entry); | 1623 | registryTools.push(entry); |
| 1296 | } | 1624 | } |
| @@ -1304,20 +1632,18 @@ async function getWorkspaceToolsFromRegistry( · 7 unmodified lines | |||
| 1304 | const page: { | 1632 | const page: { |
| 1305 | continueCursor: string | null; | 1633 | continueCursor: string | null; |
| 1306 | items: RegistryToolEntry[]; | 1634 | items: RegistryToolEntry[]; |
| 1307 | } = options.source | 1635 | } = sourceKeyFilter |
| 1308 | ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { | 1636 | ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, { |
| 1309 | workspaceId, | 1637 | workspaceId, |
| 1310 | buildId: activeBuildId, | 1638 | source: sourceKeyFilter, |
| 1311 | source: options.source, | 1639 | cursor: options.cursor, |
| 1312 | cursor: options.cursor, | 1640 | limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))), |
| 1313 | limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))), | 1641 | }) |
| 1314 | }) | 1642 | : await ctx.runQuery(internal.toolRegistry.listToolsPage, { |
| 1315 | : await ctx.runQuery(internal.toolRegistry.listToolsPage, { | 1643 | workspaceId, |
| 1316 | workspaceId, | 1644 | cursor: options.cursor, |
| 1317 | buildId: activeBuildId, | 1645 | limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))), |
| 1318 | cursor: options.cursor, | 1646 | }); |
| 1319 | limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))), | ||
| 1320 | }); | ||
| 1321 | registryTools.push(...page.items); | 1647 | registryTools.push(...page.items); |
| 1322 | nextCursor = page.continueCursor; | 1648 | nextCursor = page.continueCursor; |
| 1323 | } | 1649 | } |
| @@ -1348,7 +1674,6 @@ async function loadWorkspaceToolInventoryForContext( · 24 unmodified lines | |||
| 1348 | sourceName?: string; | 1674 | sourceName?: string; |
| 1349 | cursor?: string; | 1675 | cursor?: string; |
| 1350 | limit?: number; | 1676 | limit?: number; |
| 1351 | buildId?: string; | ||
| 1352 | fetchAll?: boolean; | 1677 | fetchAll?: boolean; |
| 1353 | } = {}, | 1678 | } = {}, |
| 1354 | ): Promise<WorkspaceToolInventory> { | 1679 | ): Promise<WorkspaceToolInventory> { |
| @@ -1366,7 +1691,6 @@ async function loadWorkspaceToolInventoryForContext( · 11 unmodified lines | |||
| 1366 | sourceName: options.sourceName, | 1691 | sourceName: options.sourceName, |
| 1367 | cursor: options.cursor, | 1692 | cursor: options.cursor, |
| 1368 | limit: options.limit, | 1693 | limit: options.limit, |
| 1369 | buildId: options.buildId, | ||
| 1370 | fetchAll: options.fetchAll, | 1694 | fetchAll: options.fetchAll, |
| 1371 | }), | 1695 | }), |
| 1372 | listWorkspaceToolPolicies(ctx, context.workspaceId, context.accountId), | 1696 | listWorkspaceToolPolicies(ctx, context.workspaceId, context.accountId), |
| @@ -1385,8 +1709,8 @@ async function loadWorkspaceToolInventoryForContext( · 12 unmodified lines | |||
| 1385 | const includeBaseTools = options.source || options.sourceName | 1709 | const includeBaseTools = options.source || options.sourceName |
| 1386 | ? false | 1710 | ? false |
| 1387 | : boundedRequestedPaths.length > 0 | 1711 | : boundedRequestedPaths.length > 0 |
| 1388 | ? true | 1712 | ? true |
| 1389 | : Boolean(options.fetchAll || !options.cursor); | 1713 | : Boolean(options.fetchAll || !options.cursor); |
| 1390 | const baseDescriptors = includeBaseTools | 1714 | const baseDescriptors = includeBaseTools |
| 1391 | ? listVisibleToolDescriptors(baseTools, context, policies, { | 1715 | ? listVisibleToolDescriptors(baseTools, context, policies, { |
| 1392 | includeDetails, | 1716 | includeDetails, |
| @@ -1443,7 +1767,6 @@ export async function listToolsForContext( · 50 unmodified lines | |||
| 1443 | sourceName?: string; | 1767 | sourceName?: string; |
| 1444 | cursor?: string; | 1768 | cursor?: string; |
| 1445 | limit?: number; | 1769 | limit?: number; |
| 1446 | buildId?: string; | ||
| 1447 | } = {}, | 1770 | } = {}, |
| 1448 | ): Promise<ToolDescriptor[]> { | 1771 | ): Promise<ToolDescriptor[]> { |
| 1449 | const inventory = await loadWorkspaceToolInventoryForContext(ctx, context, { | 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 | const openApiRefHintLookup = toOpenApiRefHintLookup(registryState?.openApiRefHintTables ?? []); | 1820 | const openApiRefHintLookup = toOpenApiRefHintLookup(registryState?.openApiRefHintTables ?? []); |
| 1503 | const registryPaths = boundedRequestedPaths.filter((path) => !baseTools.has(path)); | 1821 | const registryPaths = boundedRequestedPaths.filter((path) => !baseTools.has(path)); |
| 1504 | const entries = await Promise.all(registryPaths.map((path) => | 1822 | const entries = await Promise.all(registryPaths.map((path) => |
| 1505 | ctx.runQuery(internal.toolRegistry.getToolByPath, { | 1823 | ctx.runQuery(internal.toolRegistry.getToolByPath, { |
| 1506 | workspaceId: context.workspaceId, | 1824 | workspaceId: context.workspaceId, |
| 1507 | buildId: readyBuildId, | ||
| 1508 | path, | 1825 | path, |
| 1509 | }) | 1826 | }) |
| 1510 | )); | 1827 | )); |
| @@ -1550,49 +1867,17 @@ export async function rebuildWorkspaceToolInventoryForContext( · 39 unmodified lines | |||
| 1550 | ctx: ActionCtx, | 1867 | ctx: ActionCtx, |
| 1551 | context: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string }, | 1868 | context: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string }, |
| 1552 | ): Promise<{ rebuilt: boolean }> { | 1869 | ): Promise<{ rebuilt: boolean }> { |
| 1553 | const [sources, state] = await Promise.all([ | 1870 | try { |
| 1554 | listWorkspaceToolSources(ctx, context.workspaceId), | 1871 | await refreshWorkspaceToolRegistryIncremental(ctx, context); |
| 1555 | ctx.runQuery(internal.toolRegistry.getState, { | 1872 | } catch (error) { |
| 1556 | workspaceId: context.workspaceId, | 1873 | const message = error instanceof Error ? error.message : String(error); |
| 1557 | }), | 1874 | await ctx.runMutation(internal.toolRegistry.setRefreshError, { |
| 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, { | ||
| 1585 | workspaceId: context.workspaceId, | 1875 | workspaceId: context.workspaceId, |
| 1586 | buildId: state.buildingBuildId, | 1876 | error: message, |
| 1587 | error: "Tool inventory build timed out; retrying", | ||
| 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 | return { rebuilt: true }; | 1881 | return { rebuilt: true }; |
| 1597 | } | 1882 | } |
| 1598 | 1883 | ||
| @@ -1607,7 +1892,6 @@ export async function listToolsWithWarningsForContext( · 8 unmodified lines | |||
| 1607 | sourceName?: string; | 1892 | sourceName?: string; |
| 1608 | cursor?: string; | 1893 | cursor?: string; |
| 1609 | limit?: number; | 1894 | limit?: number; |
| 1610 | buildId?: string; | ||
| 1611 | fetchAll?: boolean; | 1895 | fetchAll?: boolean; |
| 1612 | } = {}, | 1896 | } = {}, |
| 1613 | ): Promise<{ | 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 | "scripts": { | 12 | "scripts": { |
| 13 | "with-env": "bun run --env-file=.env --", | 13 | "with-env": "bun run --env-file=.env --", |
| 14 | "dev": "bun run dev.ts", | 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 | "test": "bun run test:executor && bun run test:assistant", | 15 | "test": "bun run test:executor && bun run test:assistant", |
| 25 | "test:executor": "bun run --cwd executor test", | 16 | "test:executor": "bun run --cwd executor test", |
| 26 | "test:executor:integration": "bun run --cwd executor test:integration", | 17 | "test:executor:integration": "bun run --cwd executor test:integration", |
| @@ -30,18 +21,10 @@ · 3 unmodified lines | |||
| 30 | "typecheck": "bun run typecheck:workspaces", | 21 | "typecheck": "bun run typecheck:workspaces", |
| 31 | "typecheck:slow": "bun run typecheck:workspaces:slow", | 22 | "typecheck:slow": "bun run typecheck:workspaces:slow", |
| 32 | "typecheck:workspaces": "bun run --filter \"@executor/*\" --filter \"@proto/sources\" --filter \"@assistant/*\" --filter \"@openassistant/reacord\" --if-present typecheck", | 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 | "clean": "bun run --filter '*' --if-present clean && rm -rf node_modules .turbo", | 24 | "clean": "bun run --filter '*' --if-present clean && rm -rf node_modules .turbo", |
| 39 | "knip": "bunx knip --config knip.json", | 25 | "knip": "bunx knip --config knip.json", |
| 40 | "db:clear:executor": "bun with-env bun run executor/scripts/dev/wipe/convex-db.ts", | 26 | "db:clear:executor": "bun with-env bun run executor/scripts/dev/wipe/convex-db.ts", |
| 41 | "convex:codegen": "bunx --cwd executor convex codegen", | 27 | "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" | ||
| 45 | }, | 28 | }, |
| 46 | "dependencies": { | 29 | "dependencies": { |
| 47 | "@apidevtools/swagger-parser": "^12.1.0", | 30 | "@apidevtools/swagger-parser": "^12.1.0", |