History

Refactor tool catalog rebuild and source sync UX

Rhys Sullivan committed 8 months ago

Commit 7c8b0011 parent 8dac82c36 files · +1834 −2922Browse files

Unified · Split

36 files
  1. README.md +0 −5
  2. assistant/package.json +0 −2
  3. executor/apps/web/src/components/tools/explorer-derived.ts +5 −0
  4. executor/apps/web/src/components/tools/explorer-groups.tsx +34 −21
  5. executor/apps/web/src/components/tools/explorer.tsx +134 −65
  6. executor/apps/web/src/components/tools/explorer/source-form-panel.tsx +121 −1
  7. executor/apps/web/src/components/tools/explorer/tool-detail.tsx +8 −5
  8. executor/apps/web/src/components/tools/view.tsx +11 −21
  9. executor/apps/web/src/hooks/use/workspace-tools.ts +26 −9
  10. executor/apps/web/src/lib/catalog-collections.ts +52 −0
  11. executor/apps/web/src/lib/session-context.tsx +4 −0
  12. executor/packages/core/src/openapi-prepare.ts +81 −14
  13. executor/packages/core/src/openapi/real-specs.test.ts +111 −0
  14. executor/packages/database/convex/auth.ts +1 −0
  15. executor/packages/database/convex/executorNode.ts +0 −2
  16. executor/packages/database/convex/runtimeNode.ts +1 −0
  17. executor/packages/database/convex/schema.ts +14 −25
  18. executor/packages/database/convex/toolRegistry.cleanup.e2e.test.ts +99 −0
  19. executor/packages/database/convex/toolRegistry.ts +221 −317
  20. executor/packages/database/src/accounts/delete-current-account-batch.ts +4 −4
  21. executor/packages/database/src/auth/bootstrap.ts +66 −18
  22. executor/packages/database/src/runtime/tool_call_resolution.ts +7 −16
  23. executor/packages/database/src/runtime/tool_invocation.ts +8 −20
  24. executor/packages/database/src/runtime/tool_registry_state.ts +39 −30
  25. executor/packages/database/src/runtime/tool_source_loading.ts +31 −6
  26. executor/packages/database/src/runtime/tool_upgrade.ts +5 −9
  27. executor/packages/database/src/runtime/workspace_tools.ts +750 −466
  28. executor/scripts/dev/security/probe.token-access.ts +0 −146
  29. executor/scripts/dev/storage/smoke.execute-flow.ts +0 −390
  30. executor/scripts/prod/deploy-prod.test.ts +0 −23
  31. executor/scripts/prod/deploy-prod.ts +0 −371
  32. executor/scripts/prod/doctor-prod.test.ts +0 −81
  33. executor/scripts/prod/doctor-prod.ts +0 −382
  34. executor/scripts/prod/setup/prod-cloudflare.ts +0 −290
  35. executor/scripts/prod/setup/prod-env.ts +0 −165
  36. package.json +1 −18
README.md+0−5
@@ -79,11 +79,6 @@ bun run kill:all · 78 unmodified lines
79# Development79# Development
80bun run dev80bun run dev
81bun run kill:all81bun run kill:all
82bun run dev:executor:convex
83bun run dev:executor:web
84bun run dev:executor:menubar
85bun run dev:assistant
86bun run dev:bot
87 82
88# Quality83# Quality
89bun run test84bun run test
assistant/package.json+0−2
@@ -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"
executor/apps/web/src/components/tools/explorer-derived.ts+5−0
@@ -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,
executor/apps/web/src/components/tools/explorer-groups.tsx+34−21
@@ -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" = source261 const sourceTypeFallback: ToolSourceRecord["type"] | "local" | "system" = source
251 ? source.type262 ? 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 <ToolLoadingRows435 <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 regenerationInProgress584 regenerationInProgress
568 ? "Rebuilding..."585 ? "Rebuilding..."
569 : inventoryStale586 : "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 regenerationInProgress590 regenerationInProgress
576 ? "text-terminal-amber cursor-not-allowed"591 ? "text-terminal-amber cursor-not-allowed"
577 : inventoryStale592 : "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")} />
executor/apps/web/src/components/tools/explorer.tsx+134−65
@@ -18,7 +18,6 @@ import { · 17 unmodified lines
18 type FilterApproval,18 type FilterApproval,
19} from "./explorer-derived";19} from "./explorer-derived";
20import { sourceLabel } from "@/lib/tool/source-utils";20import { sourceLabel } from "@/lib/tool/source-utils";
21import { traverseSchema } from "@/lib/tool/schema-traverse";
22import {21import {
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
40function asRecord(value: unknown): Record<string, unknown> {
41 return value && typeof value === "object" && !Array.isArray(value)
42 ? value as Record<string, unknown>
43 : {};
44}
45
46function 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
57function hasRenderableSchemaFields(schemaJson: string): boolean {
58 return traverseSchema(parseSchemaJson(schemaJson), { maxEntries: 1 }).entries.length > 0;
59}
60
61interface ToolExplorerProps {39interface 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 panel115 // "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 regenerationInProgress517 regenerationInProgress
521 ? "Rebuilding..."518 ? "Rebuilding..."
522 : inventoryStale519 : "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 regenerationInProgress523 regenerationInProgress
529 ? "text-terminal-amber cursor-not-allowed"524 ? "text-terminal-amber cursor-not-allowed"
530 : inventoryStale525 : "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 <SourceFormPanel609 <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 )}
executor/apps/web/src/components/tools/explorer/source-form-panel.tsx+121−1
@@ -7,6 +7,7 @@ import { toast } from "sonner"; · 6 unmodified lines
7import {7import {
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
53type SourceProgressState = {
54 state: "queued" | "loading" | "indexing" | "ready" | "failed";
55 toolCount: number;
56 processedTools?: number;
57 message?: string;
58 error?: string;
59};
60
61function 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
54export function SourceFormPanel({113export 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) => (
executor/apps/web/src/components/tools/explorer/tool-detail.tsx+8−5
@@ -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">
executor/apps/web/src/components/tools/view.tsx+11−21
@@ -22,7 +22,6 @@ import type { · 21 unmodified lines
22 StorageScopeType,22 StorageScopeType,
23} from "@/lib/types";23} from "@/lib/types";
24import {24import {
25 parseWarningSourceName,
26 warningsBySourceName,25 warningsBySourceName,
27} from "@/lib/tools/source-helpers";26} from "@/lib/tools/source-helpers";
28import { sourceLabel } from "@/lib/tool/source-utils";27import { 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}
executor/apps/web/src/hooks/use/workspace-tools.ts+26−9
@@ -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
41interface UseWorkspaceToolsOptions {36interface UseWorkspaceToolsOptions {
42 includeDetails?: boolean;37 includeDetails?: boolean;
38 sourceName?: string | null;
39}
40
41interface 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
45type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "display" | "typing">;55type 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,
executor/apps/web/src/lib/catalog-collections.ts+52−0
@@ -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",
executor/apps/web/src/lib/session-context.tsx+4−0
@@ -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 workosEnabled190 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
executor/packages/core/src/openapi-prepare.ts+81−14
@@ -9,6 +9,7 @@ import type { PreparedOpenApiSpec } from "./tool/source-types"; · 8 unmodified lines
9import { toPlainObject } from "./utils";9import { toPlainObject } from "./utils";
10 10
11const unknownRecordSchema = z.record(z.unknown());11const unknownRecordSchema = z.record(z.unknown());
12const OPENAPI_JSON_FETCH_TIMEOUT_MS = 45_000;
12 13
13const openApiDocumentSchema = z.object({14const 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
59function 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
68function looksLikeJsonSpecUrl(value: string): boolean {
69 if (!isHttpUrl(value)) {
70 return false;
71 }
72
73 return /\.json($|[?#])/i.test(value);
74}
75
76async 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
58function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null {119function 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
157export interface PrepareOpenApiSpecOptions {218export interface PrepareOpenApiSpecOptions {
158 includeDts?: boolean;219 includeDts?: boolean;
159 profile?: "full" | "inventory";220 profile?: "full" | "inventory";
221 resolveSchemaRefs?: boolean;
160}222}
161 223
162export async function prepareOpenApiSpec(224export 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
executor/packages/core/src/openapi/real-specs.test.ts+111−0
@@ -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 () => {
executor/packages/database/convex/auth.ts+1−0
@@ -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);
executor/packages/database/convex/executorNode.ts+0−2
@@ -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
executor/packages/database/convex/runtimeNode.ts+1−0
@@ -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);
executor/packages/database/convex/schema.ts+14−25
@@ -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.
executor/packages/database/convex/toolRegistry.cleanup.e2e.test.ts+99−0
@@ -0,0 +1,99 @@
1import { describe, expect, test } from "bun:test";
2import { convexTest } from "convex-test";
3import { internal } from "./_generated/api";
4import schema from "./schema";
5import { registerRateLimiterComponent } from "./testHelpers";
6
7function 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
19function 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
43describe("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});
executor/packages/database/convex/toolRegistry.ts+221−317
@@ -1,6 +1,6 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import { internal } from "./_generated/api";
3import { internalAction, internalMutation, internalQuery } from "./_generated/server";2import { internalAction, internalMutation, internalQuery } from "./_generated/server";
3import { internal } from "./_generated/api";
4import { vv } from "./typedV";4import { vv } from "./typedV";
5 5
6export const getState = internalQuery({6export 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
42export 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
90export const putToolsBatch = internalMutation({41export 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
154export const putNamespacesBatch = internalMutation({137export 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
181export const finishBuild = internalMutation({162export 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
237export 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
305export const failBuild = internalMutation({239const sourceStateValidator = v.any();
240
241export 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.db249 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
334const PRUNE_DELETE_PAGE_SIZE = 10;283export const setRefreshError = internalMutation({
335const PRUNE_NAMESPACE_SCAN_PAGE_SIZE = 250;
336
337export 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.db289 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
358export const deleteToolRegistryToolsPage = internalMutation({324export 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.db330 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
378export const deleteToolRegistryPayloadsPage = internalMutation({375export 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.db381 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
398export const deleteToolRegistryNamespacesPage = internalMutation({402export 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.db410 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
418export const pruneBuilds = internalAction({428export 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
530export const getToolByPath = internalQuery({447export 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.db453 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.db462 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
571export const getSerializedToolsByPaths = internalQuery({487export 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.db500 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
603export const listToolsByNamespace = internalQuery({518export 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.db529 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
638export const listToolsPage = internalQuery({552export 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.db560 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
676export const listToolsBySourcePage = internalQuery({587export 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.db620 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
739export const getToolsByNormalizedPath = internalQuery({649export 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.db660 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.db668 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
783export const searchTools = internalQuery({692export 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
820export const listNamespaces = internalQuery({727export 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.db734 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) => ({
executor/packages/database/src/accounts/delete-current-account-batch.ts+4−4
@@ -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.db109 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
executor/packages/database/src/auth/bootstrap.ts+66−18
@@ -7,6 +7,50 @@ import { claimAnonymousSessionToWorkosAccount } from "./account_links"; · 6 unmodified lines
7import { getOrCreatePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace";7import { getOrCreatePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace";
8import type { AccountId } from "./types";8import type { AccountId } from "./types";
9 9
10function readNonEmptyTrimmed(value: string | undefined): string | undefined {
11 const normalized = value?.trim();
12 return normalized && normalized.length > 0 ? normalized : undefined;
13}
14
15function 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
29function 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
10async function seedHintedOrganizationMembership(54async 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
57export async function bootstrapCurrentWorkosAccountImpl(101export 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 });
executor/packages/database/src/runtime/tool_call_resolution.ts+7−16
@@ -6,7 +6,7 @@ import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation- · 5 unmodified lines
6import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";6import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
7import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";7import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";
8import { getDecisionForContext, getToolDecision } from "./policy";8import { getDecisionForContext, getToolDecision } from "./policy";
9import { getReadyRegistryBuildIdResult } from "./tool_registry_state";9import { getRegistryReadyResult } from "./tool_registry_state";
10import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";10import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
11import { baseTools } from "./base_tools";11import { baseTools } from "./base_tools";
12 12
@@ -43,7 +43,7 @@ function getGraphqlQueryFromInput(input: unknown): string { · 30 unmodified lines
43 43
44async function searchRegistryEntries(44async 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
53async function getRegistryToolByPath(53async 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
139async function suggestFromRegistry(138async 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
executor/packages/database/src/runtime/tool_invocation.ts+8−20
@@ -27,7 +27,7 @@ import { publishTaskEvent } from "./events"; · 26 unmodified lines
27import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";27import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
28import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials";28import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials";
29import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";29import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
30import { getReadyRegistryBuildIdResult } from "./tool_registry_state";30import { getRegistryReadyResult } from "./tool_registry_state";
31import {31import {
32 catalogNamespaceSchema,32 catalogNamespaceSchema,
33 catalogNamespacesInputSchema,33 catalogNamespacesInputSchema,
@@ -214,7 +214,7 @@ async function listWorkspaceToolPolicies( · 180 unmodified lines
214 214
215async function listRegistryNamespaces(215async 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 = buildId540 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 } | null543 }) 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 = buildId574 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 = !buildId627 const raw = !registryReady
637 ? []628 ? []
638 : query629 : 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 : namespace636 : 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 = buildId731 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,
executor/packages/database/src/runtime/tool_registry_state.ts+39−30
@@ -17,8 +17,17 @@ export function registrySignatureForWorkspace( · 16 unmodified lines
17 17
18const registryStateSchema = z.object({18const 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
24type RegistryState = z.infer<typeof registryStateSchema> | null;33type RegistryState = z.infer<typeof registryStateSchema> | null;
@@ -70,7 +79,7 @@ function toToolSourceStateList(value: unknown): ToolSourceState[] { · 45 unmodified lines
70async function readRegistryState(79async 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
90export async function getReadyRegistryBuildIdResult(117export 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
108export 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}
executor/packages/database/src/runtime/tool_source_loading.ts+31−6
@@ -28,8 +28,10 @@ import { · 27 unmodified lines
28} from "./tool_source_artifact";28} from "./tool_source_artifact";
29 29
30const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;30const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
31const OPENAPI_PREPARE_MAX_ATTEMPTS = 3;
32const OPENAPI_PREPARE_RETRY_BASE_DELAY_MS = 1_500;
31 33
32const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v8";34const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v9";
33 35
34const openApiAuthModeSchema = z.enum(["account", "workspace", "organization"]);36const 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;
executor/packages/database/src/runtime/tool_upgrade.ts+5−9
@@ -37,7 +37,6 @@ function clampPreview(items: string[]): { items: string[]; truncated: boolean } · 36 unmodified lines
37async function listCurrentSourceToolMap(37async 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?.readyBuildId137 `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[] = [];
executor/packages/database/src/runtime/workspace_tools.ts+750−466
@@ -12,10 +12,9 @@ import { · 11 unmodified lines
12} from "../../../core/src/type-hints";12} from "../../../core/src/type-hints";
13import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils";13import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils";
14import {14import {
15 materializeCompiledToolSource,
16 type CompiledToolSourceArtifact,15 type CompiledToolSourceArtifact,
17} from "./tool_source_artifact";16} from "./tool_source_artifact";
18import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";17import { parseSerializedTool, rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";
19import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types";18import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types";
20import type {19import 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
78interface WorkspaceToolsResult {77export type ToolInventoryState = "initializing" | "ready" | "rebuilding" | "failed";
79 tools: Map<string, ToolDefinition>;
80 warnings: string[];
81 typesStorageId?: Id<"_storage">;
82 timedOutSources: string[];
83}
84
85export type ToolInventoryState = "initializing" | "ready" | "rebuilding" | "stale" | "failed";
86 78
87export interface ToolInventoryStatus {79export 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
101interface GetWorkspaceToolsOptions {
102 sourceTimeoutMs?: number;
103 accountId?: Id<"accounts">;
104 includeDts?: boolean;
105}
106
107interface WorkspaceToolInventory {88interface 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
120const MAX_TOOLS_IN_ACTION_RESULT = 8_000;101const MAX_TOOLS_IN_ACTION_RESULT = 8_000;
121const MAX_TOOL_DETAILS_LOOKUP_PATHS = 100;102const MAX_TOOL_DETAILS_LOOKUP_PATHS = 100;
122const REGISTRY_BUILD_STALE_MS = 2 * 60_000;
123const MAX_REF_HINTS_PER_SOURCE = 300;103const MAX_REF_HINTS_PER_SOURCE = 300;
124const MAX_REF_HINT_HINT_CHARS = 240;104const MAX_REF_HINT_HINT_CHARS = 240;
125const MAX_REF_HINT_METADATA_BYTES = 220_000;105const MAX_REF_HINT_METADATA_BYTES = 220_000;
106const SOURCE_LOAD_HEARTBEAT_MS = 5_000;
126 107
127function truncateToolsForActionResult(108function 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 ...(includeDetails337 ...(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
610type 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
632type 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
649function 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
630async function buildWorkspaceToolRegistry(657function 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 {661function 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 && inputHint675 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 && outputHint684 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 {695function 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));701function 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
767function 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
775async 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
788async 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;815async 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, {853async 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.901async function refreshWorkspaceToolRegistryIncremental(
765
766export 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
924interface WorkspaceRegistryReadResult {1231interface 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
937export type ToolSourceGenerationState = "ready" | "loading" | "refreshing" | "failed";1244export type ToolSourceGenerationState = "queued" | "loading" | "indexing" | "ready" | "failed";
938 1245
939export interface ToolSourceGenerationStatus {1246export 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
945export interface WorkspaceInventoryProgress {1255export interface WorkspaceInventoryProgress {
@@ -986,37 +1296,29 @@ function toSourceAuthProfileRecord( · 40 unmodified lines
986 return result;1296 return result;
987}1297}
988 1298
989function 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
1006type WorkspaceRegistryStateRecord = {1299type 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
1337type WorkspaceSourceStateRow = NonNullable<Exclude<WorkspaceRegistryStateRecord, null>["sourceStates"]>[number];
1338
1339function 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
1035function computeWorkspaceInventoryProgress(1380function 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 changes1511 // 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 = policies1513 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 = scopedSourceName1558 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.source1610 } = 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.source1635 } = 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.sourceName1709 const includeBaseTools = options.source || options.sourceName
1386 ? false1710 ? false
1387 : boundedRequestedPaths.length > 01711 : boundedRequestedPaths.length > 0
1388 ? true1712 ? true
1389 : Boolean(options.fetchAll || !options.cursor);1713 : Boolean(options.fetchAll || !options.cursor);
1390 const baseDescriptors = includeBaseTools1714 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<{
executor/scripts/dev/security/probe.token-access.ts+0−146
@@ -1,146 +0,0 @@
1#!/usr/bin/env bun
2
3import { ConvexHttpClient } from "convex/browser";
4import { api } from "../../../packages/database/convex/_generated/api";
5
6type RuntimeId = "local-bun" | "cloudflare-worker-loader";
7
8function 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
17function 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
64function 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
83async 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
109async 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
146await main();
executor/scripts/dev/storage/smoke.execute-flow.ts+0−390
@@ -1,390 +0,0 @@
1#!/usr/bin/env bun
2
3import assert from "node:assert/strict";
4import { ConvexHttpClient } from "convex/browser";
5import { $ } from "bun";
6import { api } from "../../../packages/database/convex/_generated/api";
7
8type RuntimeId = "local-bun" | "cloudflare-worker-loader";
9const EXECUTOR_ROOT = new URL("../../../", import.meta.url).pathname;
10const DATABASE_ROOT = new URL("../../../packages/database/", import.meta.url).pathname;
11
12function 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
61function 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
69function makeClient(convexUrl: string): ConvexHttpClient {
70 return new ConvexHttpClient(convexUrl, {
71 skipConvexDeploymentUrlCheck: true,
72 });
73}
74
75function sleep(ms: number): Promise<void> {
76 return new Promise((resolve) => setTimeout(resolve, ms));
77}
78
79function parseWorkersDevUrl(output: string): string | null {
80 const match = output.match(/https:\/\/[\w.-]+\.workers\.dev/);
81 return match ? match[0] : null;
82}
83
84function 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
93async 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
109async 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
117async 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
129async 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
136async 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
176async 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
390await run();
executor/scripts/prod/deploy-prod.test.ts+0−23
@@ -1,23 +0,0 @@
1import { describe, expect, test } from "bun:test";
2import { parseArgs, resolveSteps } from "./deploy-prod";
3
4describe("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});
executor/scripts/prod/deploy-prod.ts+0−371
@@ -1,371 +0,0 @@
1import path from "node:path";
2import { createInterface } from "node:readline/promises";
3import { parseConvexEnvList } from "./doctor-prod";
4
5type StepId = "cloudflare" | "env" | "convex" | "doctor";
6
7interface Options {
8 apply: boolean;
9 yes: boolean;
10 force: boolean;
11 only?: Set<StepId>;
12 skip: Set<StepId>;
13}
14
15interface CommandResult {
16 exitCode: number;
17 stdout: string;
18 stderr: string;
19}
20
21interface PreflightCheck {
22 id: string;
23 ok: boolean;
24 detail: string;
25 hint?: string;
26 blocking: boolean;
27}
28
29interface DeployStep {
30 id: StepId;
31 name: string;
32 command: string[];
33 cwd?: string;
34}
35
36const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", "..");
37const RUNNER_HOST_DIR = path.join(EXECUTOR_ROOT, "packages", "runner-sandbox-host");
38
39const ALL_STEPS: StepId[] = ["cloudflare", "env", "convex", "doctor"];
40const 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
51function printHelp(): void {
52 console.log(`Guided production deploy for executor
53
54Usage:
55 bun run scripts/prod/deploy-prod.ts [options]
56
57Options:
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
65Steps:
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
71Examples:
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
79function 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
92export 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
139export 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
144async 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
166function 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
172function 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
203async 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
211function missingLocalEnvKeys(): string[] {
212 return REQUIRED_ENV_SETUP_KEYS.filter((key) => !Bun.env[key]?.trim());
213}
214
215async 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
284function 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
301async 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
311async 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
327async 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
363if (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}
executor/scripts/prod/doctor-prod.test.ts+0−81
@@ -1,81 +0,0 @@
1import { describe, expect, test } from "bun:test";
2import { buildEnvChecks, parseConvexEnvList } from "./doctor-prod";
3
4describe("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
19describe("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});
executor/scripts/prod/doctor-prod.ts+0−382
@@ -1,382 +0,0 @@
1import path from "node:path";
2
3export interface DoctorCheck {
4 name: string;
5 ok: boolean;
6 detail: string;
7}
8
9export interface ProductionDoctorReport {
10 checks: DoctorCheck[];
11}
12
13interface CommandResult {
14 exitCode: number;
15 stdout: string;
16 stderr: string;
17}
18
19const 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
26const REQUIRED_APP_ENV_KEYS = [
27 "WORKOS_CLIENT_ID",
28 "WORKOS_API_KEY",
29 "WORKOS_WEBHOOK_SECRET",
30 "WORKOS_COOKIE_PASSWORD",
31] as const;
32
33const REQUIRED_BILLING_ENV_KEYS = [
34 "STRIPE_SECRET_KEY",
35 "STRIPE_WEBHOOK_SECRET",
36 "STRIPE_PRICE_ID",
37] as const;
38
39const OPTIONAL_BILLING_ENV_KEYS = [
40 "BILLING_SUCCESS_URL",
41 "BILLING_CANCEL_URL",
42 "BILLING_RETURN_URL",
43] as const;
44
45const 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
52const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", "..");
53
54function addCheck(report: ProductionDoctorReport, name: string, ok: boolean, detail: string): void {
55 report.checks.push({ name, ok, detail });
56}
57
58async 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
80async function runConvex(args: string[]): Promise<CommandResult> {
81 return await runCommand(["bunx", "convex", ...args]);
82}
83
84export 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
108function parseUrl(input: string): URL | null {
109 try {
110 return new URL(input);
111 } catch {
112 return null;
113 }
114}
115
116function 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
123function 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
128export 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
270function 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
324export 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
342export function hasDoctorFailures(report: ProductionDoctorReport): boolean {
343 return report.checks.some((check) => !check.ok);
344}
345
346async 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
374if (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}
executor/scripts/prod/setup/prod-cloudflare.ts+0−290
@@ -1,290 +0,0 @@
1import crypto from "node:crypto";
2import fs from "node:fs/promises";
3import path from "node:path";
4import { parseConvexEnvList } from "../doctor-prod";
5
6interface Options {
7 runUrl?: string;
8 convexUrl?: string;
9 authToken?: string;
10 internalToken?: string;
11 deploy: boolean;
12 runDoctor: boolean;
13}
14
15interface CommandResult {
16 exitCode: number;
17 stdout: string;
18 stderr: string;
19}
20
21const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", "..");
22const SANDBOX_HOST_DIR = path.join(EXECUTOR_ROOT, "packages", "runner-sandbox-host");
23
24function printHelp(): void {
25 console.log(`Setup Cloudflare runtime for production
26
27Usage:
28 bun run scripts/prod/setup/prod-cloudflare.ts [options]
29
30Options:
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
41function 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
92function randomToken(bytes = 32): string {
93 return crypto.randomBytes(bytes).toString("base64url");
94}
95
96function redact(value: string): string {
97 if (value.length <= 8) return "********";
98 return `${value.slice(0, 4)}...${value.slice(-4)}`;
99}
100
101async 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
131async function runConvex(args: string[]): Promise<CommandResult> {
132 return await runCommand(["bunx", "convex", ...args]);
133}
134
135function 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
148async 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
158function 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
166async 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
185async 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
198async 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
205async 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
213function 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
228function 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
246async 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
284try {
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}
executor/scripts/prod/setup/prod-env.ts+0−165
@@ -1,165 +0,0 @@
1interface CommandResult {
2 exitCode: number;
3 stdout: string;
4 stderr: string;
5}
6
7interface Options {
8 fromEnv: boolean;
9 strict: boolean;
10}
11
12const 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
23const OPTIONAL_KEYS = [
24 "BILLING_SUCCESS_URL",
25 "BILLING_CANCEL_URL",
26 "BILLING_RETURN_URL",
27] as const;
28
29function printHelp(): void {
30 console.log(`Setup production Convex app env vars
31
32Usage:
33 bun run scripts/prod/setup/prod-env.ts --from-env [--strict]
34
35Options:
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
40Required local env keys:
41 ${REQUIRED_KEYS.join("\n ")}
42
43Optional local env keys:
44 ${OPTIONAL_KEYS.join("\n ")}
45`);
46}
47
48function 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
77async 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
98async 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
105function redact(value: string): string {
106 if (value.length < 8) return "********";
107 return `${value.slice(0, 4)}...${value.slice(-4)}`;
108}
109
110async 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
157try {
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
165export {};
package.json+1−18
@@ -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",

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t