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
7979 # Development
8080 bun run dev
8181 bun run kill:all
82−bun run dev:executor:convex
83−bun run dev:executor:web
84−bun run dev:executor:menubar
85−bun run dev:assistant
86−bun run dev:bot
8782
8883 # Quality
8984 bun run test
assistant/package.json+0−2
@@ -4,8 +4,6 @@ · 3 unmodified lines
44 "type": "module",
55 "scripts": {
66 "test": "bun test packages/core/src/",
7− "dev:server": "bun run --filter @assistant/server dev",
8− "dev:bot": "bun run --filter @assistant/bot dev",
97 "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",
108 "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",
119 "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
213213 continue;
214214 }
215215
216+ const hasVisibleChildren = existing.children.length > 0 || existing.childCount > 0;
217+ if (hasVisibleChildren) {
218+ continue;
219+ }
220+
216221 groupsBySource.set(sourceName, {
217222 ...existing,
218223 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
229229 onFocusTool,
230230 onSourceClick,
231231 source,
232+ sourceState,
232233 }: {
233234 group: ToolGroup;
234235 depth: number;
@@ -239,14 +240,24 @@ export function NavGroupNode({ · 4 unmodified lines
239240 onFocusTool: (tool: ToolDescriptor) => void;
240241 onSourceClick?: (sourceName: string) => void;
241242 source?: ToolSourceRecord;
243+ sourceState?: {
244+ state: "queued" | "loading" | "indexing" | "ready" | "failed";
245+ toolCount: number;
246+ processedTools?: number;
247+ message?: string;
248+ error?: string;
249+ };
242250 }) {
243251 const isExpanded = expandedKeys.has(group.key);
244252 const isSource = group.type === "source";
245253 const isSourceFocused = isSource && focusedSource === group.label;
246− const isLoading =
254+ const isLoadingFromTree =
247255 group.type === "source" &&
248256 typeof group.loadingPlaceholderCount === "number" &&
249257 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;
250261 const sourceTypeFallback: ToolSourceRecord["type"] | "local" | "system" = source
251262 ? source.type
252263 : group.label === "system"
@@ -317,8 +328,22 @@ export function NavGroupNode({ · 64 unmodified lines
317328 </span>
318329
319330 <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground/60">
320− {isLoading ? (
321− <Loader2 className="h-2.5 w-2.5 animate-spin" />
331+ {sourceState?.state === "failed" ? (
332+ <span className="inline-flex items-center gap-0.5 text-terminal-red" title={sourceState.error ?? "Source failed"}>
333+ <AlertTriangle className="h-2.5 w-2.5" />
334+ </span>
335+ ) : isLoading ? (
336+ <span
337+ className="inline-flex items-center gap-1"
338+ title={sourceState?.message}
339+ >
340+ <Loader2 className="h-2.5 w-2.5 animate-spin" />
341+ {sourceState?.state === "indexing" && typeof sourceState.processedTools === "number" && sourceState.toolCount > 0 ? (
342+ <span className="tabular-nums text-[10px] text-muted-foreground/70">
343+ {sourceState.processedTools}/{sourceState.toolCount}
344+ </span>
345+ ) : null}
346+ </span>
322347 ) : (
323348 <>
324349 {group.approvalCount > 0 && (
@@ -327,7 +352,7 @@ export function NavGroupNode({ · 2 unmodified lines
327352 {group.approvalCount}
328353 </span>
329354 )}
330− <span className="tabular-nums">{group.childCount}</span>
355+ <span className="tabular-nums">{sourceState?.toolCount ?? group.childCount}</span>
331356 </>
332357 )}
333358 </span>
@@ -388,6 +413,7 @@ export function NavGroupNode({ · 54 unmodified lines
388413 focusedSource={focusedSource}
389414 onFocusTool={onFocusTool}
390415 onSourceClick={onSourceClick}
416+ sourceState={sourceState}
391417 />
392418 );
393419 }
@@ -405,7 +431,7 @@ export function NavGroupNode({ · 11 unmodified lines
405431 })
406432 : null}
407433
408− {isLoading ? (
434+ {showLoadingRows ? (
409435 <ToolLoadingRows
410436 source={displayLabel}
411437 count={group.loadingPlaceholderCount ?? 3}
@@ -445,7 +471,7 @@ export function SourceSidebar({ · 33 unmodified lines
445471 onSourceDeleted?: (sourceName: string) => void;
446472 onRegenerate?: () => void;
447473 isRebuilding?: boolean;
448− inventoryState?: "initializing" | "ready" | "rebuilding" | "stale" | "failed";
474+ inventoryState?: "initializing" | "ready" | "rebuilding" | "failed";
449475 inventoryError?: string;
450476 }) {
451477 const warningCountsBySource = useMemo(() => {
@@ -464,8 +490,6 @@ export function SourceSidebar({ · 12 unmodified lines
464490 );
465491 const loadingSourceCount = loadingSources.size;
466492 const regenerationInProgress = isRebuilding;
467− const inventoryStale = inventoryState === "stale";
468−
469493 const inventoryStatus = useMemo(() => {
470494 if (!inventoryState) {
471495 return {
@@ -494,13 +518,6 @@ export function SourceSidebar({ · 22 unmodified lines
494518 };
495519 }
496520
497− if (inventoryState === "stale") {
498− return {
499− label: "Inventory out of date",
500− tone: "muted" as const,
501− };
502− }
503−
504521 if (inventoryState === "failed") {
505522 return {
506523 label: inventoryError ? `Refresh failed: ${inventoryError}` : "Refresh failed",
@@ -566,17 +583,13 @@ export function SourceSidebar({ · 59 unmodified lines
566583 title={
567584 regenerationInProgress
568585 ? "Rebuilding..."
569− : inventoryStale
570− ? "Inventory is out of date"
571− : "Regenerate inventory"
586+ : "Regenerate inventory"
572587 }
573588 className={cn(
574589 "p-0.5 rounded transition-colors",
575590 regenerationInProgress
576591 ? "text-terminal-amber cursor-not-allowed"
577− : inventoryStale
578− ? "text-muted-foreground/70 hover:text-muted-foreground"
579− : "text-muted-foreground/40 hover:text-muted-foreground/70",
592+ : "text-muted-foreground/40 hover:text-muted-foreground/70",
580593 )}
581594 >
582595 <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
1818 type FilterApproval,
1919 } from "./explorer-derived";
2020 import { sourceLabel } from "@/lib/tool/source-utils";
21−import { traverseSchema } from "@/lib/tool/schema-traverse";
2221 import {
2322 EmptyState,
2423 LoadingState,
@@ -37,27 +36,6 @@ import { warningsBySourceName } from "@/lib/tools/source-helpers"; · 12 unmodified lines
3736
3837 // ── Main Explorer ──
3938
40−function asRecord(value: unknown): Record<string, unknown> {
41− return value && typeof value === "object" && !Array.isArray(value)
42− ? value as Record<string, unknown>
43− : {};
44−}
45−
46−function parseSchemaJson(value: string): Record<string, unknown> {
47− if (!value) return {};
48−
49− try {
50− const parsed = JSON.parse(value) as unknown;
51− return asRecord(parsed);
52− } catch {
53− return {};
54− }
55−}
56−
57−function hasRenderableSchemaFields(schemaJson: string): boolean {
58− return traverseSchema(parseSchemaJson(schemaJson), { maxEntries: 1 }).entries.length > 0;
59−}
60−
6139 interface ToolExplorerProps {
6240 tools: ToolDescriptor[];
6341 sources: ToolSourceRecord[];
@@ -71,6 +49,13 @@ interface ToolExplorerProps { · 7 unmodified lines
7149 onLoadMoreToolsForSource?: (source: { source: string; sourceName: string }) => Promise<void>;
7250 loading?: boolean;
7351 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+ }>;
7459 onLoadToolDetails?: (toolPaths: string[]) => Promise<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>;
7560 warnings?: string[];
7661 activeSource: string | null;
@@ -89,7 +74,7 @@ interface ToolExplorerProps { · 12 unmodified lines
8974 onSourceDeleted?: (sourceName: string) => void;
9075 onRegenerate?: () => void;
9176 isRebuilding?: boolean;
92− inventoryState?: "initializing" | "ready" | "rebuilding" | "stale" | "failed";
77+ inventoryState?: "initializing" | "ready" | "rebuilding" | "failed";
9378 inventoryError?: string;
9479 }
9580
@@ -103,6 +88,7 @@ export function ToolExplorer({ · 7 unmodified lines
10388 sourceLoadingMoreTools,
10489 loading = false,
10590 loadingSources = [],
91+ sourceStates = {},
10692 onLoadToolDetails,
10793 warnings = [],
10894 activeSource,
@@ -124,21 +110,6 @@ export function ToolExplorer({ · 15 unmodified lines
124110 inventoryState,
125111 inventoryError,
126112 }: 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−
142113 const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({});
143114 const [loadingDetailPaths, setLoadingDetailPaths] = useState<Set<string>>(new Set());
144115 // "new" = add source form, ToolSourceRecord = edit/view existing source, null = no source panel
@@ -280,9 +251,7 @@ export function ToolExplorer({ · 135 unmodified lines
280251 return;
281252 }
282253
283− const hasDetails = hasRenderableToolDetails(tool);
284−
285− if (hasDetails || toolDetailsByPath[tool.path]) {
254+ if (toolDetailsByPath[tool.path]) {
286255 return;
287256 }
288257
@@ -309,7 +278,7 @@ export function ToolExplorer({ · 20 unmodified lines
309278 return next;
310279 });
311280 }
312− }, [hasRenderableToolDetails, loadingDetailPaths, onLoadToolDetails, toolDetailsByPath]);
281+ }, [loadingDetailPaths, onLoadToolDetails, toolDetailsByPath]);
313282
314283 // ── Focus tool handler ──────────────────────────────────────────────────
315284
@@ -359,12 +328,16 @@ export function ToolExplorer({ · 43 unmodified lines
359328 options?: SourceAddedOptions,
360329 ) => {
361330 onSourceAdded?.(source, options);
362− if (options?.isNew) {
363− return;
364− }
365−
366− // After editing, keep the updated source open for quick follow-up.
367331 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+ });
368341 }, [onSourceAdded, setSourceFocusState]);
369342
370343 const focusedTool = useMemo(() => {
@@ -377,6 +350,20 @@ export function ToolExplorer({ · 6 unmodified lines
377350 return sourceByName.get(focusedSourceName) ?? null;
378351 }, [focusedSourceName, sourceByName]);
379352
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+
380367 useEffect(() => {
381368 if (formSource !== null && focusedToolPath) {
382369 setFocusedToolPath(null);
@@ -446,39 +433,49 @@ export function ToolExplorer({ · 63 unmodified lines
446433
447434 const inventoryStatus = useMemo(() => {
448435 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;
449440
450441 if (!inventoryState) {
451442 return { label: "Checking...", tone: "muted" as const };
452443 }
453444 if (inventoryState === "initializing") {
454− const w = loadingSourceCount === 1 ? "source" : "sources";
445+ const progress = totalSourceCount > 0
446+ ? `${readySourceCount}/${totalSourceCount}`
447+ : `${loadingSourceCount}`;
455448 return {
456− label: loadingSourceCount > 0 ? `Building (${loadingSourceCount} ${w})` : "Building...",
449+ label: `Building ${progress}`,
457450 tone: "loading" as const,
458451 };
459452 }
460453 if (inventoryState === "rebuilding") {
461− const w = loadingSourceCount === 1 ? "source" : "sources";
454+ const progress = totalSourceCount > 0
455+ ? `${readySourceCount}/${totalSourceCount}`
456+ : `${loadingSourceCount}`;
462457 return {
463− label: loadingSourceCount > 0 ? `Refreshing (${loadingSourceCount} ${w})` : "Refreshing...",
458+ label: `Refreshing ${progress}`,
464459 tone: "loading" as const,
465460 };
466461 }
467− if (inventoryState === "stale") {
468− return { label: "Out of date", tone: "muted" as const };
469− }
470462 if (inventoryState === "failed") {
471463 return {
472− label: inventoryError ? `Failed: ${inventoryError}` : "Failed",
464+ label: inventoryError
465+ ? `Failed: ${inventoryError}`
466+ : failedSourceCount > 0
467+ ? `Failed (${failedSourceCount} sources)`
468+ : "Failed",
473469 tone: "error" as const,
474470 };
475471 }
476− return { label: "Up to date", tone: "muted" as const };
477− }, [inventoryError, inventoryState, loadingSourceSet.size]);
472+ return {
473+ label: totalSourceCount > 0 ? `Up to date (${readySourceCount}/${totalSourceCount})` : "Up to date",
474+ tone: "muted" as const,
475+ };
476+ }, [inventoryError, inventoryState, loadingSourceSet.size, sourceStates]);
478477
479478 const regenerationInProgress = isRebuilding;
480− const inventoryStale = inventoryState === "stale";
481−
482479 // ── Render ──────────────────────────────────────────────────────────────
483480
484481 return (
@@ -519,17 +516,13 @@ export function ToolExplorer({ · 34 unmodified lines
519516 title={
520517 regenerationInProgress
521518 ? "Rebuilding..."
522− : inventoryStale
523− ? "Out of date — click to refresh"
524− : "Regenerate inventory"
519+ : "Regenerate inventory"
525520 }
526521 className={cn(
527522 "p-0.5 rounded transition-colors",
528523 regenerationInProgress
529524 ? "text-terminal-amber cursor-not-allowed"
530− : inventoryStale
531− ? "text-muted-foreground/70 hover:text-muted-foreground"
532− : "text-muted-foreground/30 hover:text-muted-foreground/60",
525+ : "text-muted-foreground/30 hover:text-muted-foreground/60",
533526 )}
534527 >
535528 <RefreshCcw className={cn("h-3 w-3", regenerationInProgress && "animate-spin")} />
@@ -599,6 +592,7 @@ export function ToolExplorer({ · 63 unmodified lines
599592 onFocusTool={handleFocusTool}
600593 onSourceClick={handleSourceClick}
601594 source={sourceByName.get(group.label) ?? undefined}
595+ sourceState={sourceStates[group.label]}
602596 />
603597 ))}
604598
@@ -615,6 +609,7 @@ export function ToolExplorer({ · 10 unmodified lines
615609 <SourceFormPanel
616610 existingSourceNames={sidebarExistingSourceNames}
617611 sourceToEdit={formSource === "new" ? undefined : formSource}
612+ sourceProgress={formSourceProgress}
618613 sourceDialogMeta={formSource !== "new" ? sourceDialogMeta?.[formSource.name] : undefined}
619614 sourceAuthProfiles={sourceAuthProfiles}
620615 onSourceAdded={handleSourceFormAdded}
@@ -626,6 +621,80 @@ export function ToolExplorer({ · 5 unmodified lines
626621 tool={focusedTool}
627622 loading={loadingDetailPaths.has(focusedTool.path)}
628623 />
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>
629698 ) : (
630699 <ToolDetailEmpty />
631700 )}
executor/apps/web/src/components/tools/explorer/source-form-panel.tsx+121−1
@@ -7,6 +7,7 @@ import { toast } from "sonner"; · 6 unmodified lines
77 import {
88 AlertTriangle,
99 ArrowLeft,
10+ CheckCircle2,
1011 Loader2,
1112 Trash2,
1213 } from "lucide-react";
@@ -49,11 +50,70 @@ function ownerScopeBadge(scopeType: ToolSourceRecord["scopeType"] | undefined): · 36 unmodified lines
4950 return scopeType === "organization" ? "org shared" : "workspace only";
5051 }
5152
53+type SourceProgressState = {
54+ state: "queued" | "loading" | "indexing" | "ready" | "failed";
55+ toolCount: number;
56+ processedTools?: number;
57+ message?: string;
58+ error?: string;
59+};
60+
61+function progressStepStates(sourceType: ToolSourceRecord["type"], progress?: SourceProgressState) {
62+ const isOpenApi = sourceType === "openapi";
63+ const labels = isOpenApi
64+ ? ["Download spec", "Extract operations", "Index tools"]
65+ : ["Connect", "Discover tools", "Index tools"];
66+
67+ const defaultSteps = labels.map((label, index) => ({
68+ id: index + 1,
69+ label,
70+ status: "pending" as "pending" | "active" | "done" | "failed",
71+ }));
72+
73+ if (!progress) {
74+ return defaultSteps;
75+ }
76+
77+ if (progress.state === "ready") {
78+ return defaultSteps.map((step) => ({ ...step, status: "done" as const }));
79+ }
80+
81+ if (progress.state === "indexing") {
82+ return defaultSteps.map((step) => {
83+ if (step.id < 3) return { ...step, status: "done" as const };
84+ return { ...step, status: "active" as const };
85+ });
86+ }
87+
88+ if (progress.state === "loading") {
89+ const message = progress.message?.toLowerCase() ?? "";
90+ const activeStep = message.includes("download") ? 1 : 2;
91+ return defaultSteps.map((step) => {
92+ if (step.id < activeStep) return { ...step, status: "done" as const };
93+ if (step.id === activeStep) return { ...step, status: "active" as const };
94+ return step;
95+ });
96+ }
97+
98+ if (progress.state === "failed") {
99+ const message = progress.message?.toLowerCase() ?? "";
100+ const failedStep = message.includes("index") ? 3 : message.includes("download") ? 1 : 2;
101+ return defaultSteps.map((step) => {
102+ if (step.id < failedStep) return { ...step, status: "done" as const };
103+ if (step.id === failedStep) return { ...step, status: "failed" as const };
104+ return step;
105+ });
106+ }
107+
108+ return defaultSteps;
109+}
110+
52111 // ── Main Panel ──────────────────────────────────────────────────────────────
53112
54113 export function SourceFormPanel({
55114 existingSourceNames,
56115 sourceToEdit,
116+ sourceProgress,
57117 sourceDialogMeta,
58118 sourceAuthProfiles,
59119 onSourceAdded,
@@ -62,6 +122,7 @@ export function SourceFormPanel({ · 2 unmodified lines
62122 }: {
63123 existingSourceNames: Set<string>;
64124 sourceToEdit?: ToolSourceRecord;
125+ sourceProgress?: SourceProgressState;
65126 sourceDialogMeta?: SourceDialogMeta;
66127 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
67128 onSourceAdded?: OnSourceAdded;
@@ -247,7 +308,6 @@ export function SourceFormPanel({ · 179 unmodified lines
247308 if (!sourceToEdit) {
248309 form.reserveSourceName(form.name.trim());
249310 }
250− onClose();
251311 };
252312
253313 const handleMcpOAuthConnect = async () => {
@@ -388,6 +448,66 @@ export function SourceFormPanel({ · 134 unmodified lines
388448 </div>
389449 </div>
390450
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+
391511 {editSourceWarnings.length > 0 ? (
392512 <div className="rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2 space-y-0.5">
393513 {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
397397 || canRenderOutputSchema;
398398 const showLoading = Boolean(loading);
399399
400− return (
401− <div className="pt-2 pb-4 flex flex-col gap-4">
402− {/* Loading state */}
403− {showLoading ? (
400+ if (showLoading) {
401+ return (
402+ <div className="pt-2 pb-4 flex flex-col gap-4">
404403 <div className="space-y-3">
405404 <Skeleton className="h-3.5 w-72" />
406405 <div>
@@ -412,8 +411,12 @@ function ToolDetailContent({ · 5 unmodified lines
412411 <Skeleton className="h-14 w-full rounded-md mt-1" />
413412 </div>
414413 </div>
415− ) : null}
414+ </div>
415+ );
416+ }
416417
418+ return (
419+ <div className="pt-2 pb-4 flex flex-col gap-4">
417420 {/* Description */}
418421 {description ? (
419422 <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
2222 StorageScopeType,
2323 } from "@/lib/types";
2424 import {
25− parseWarningSourceName,
2625 warningsBySourceName,
2726 } from "@/lib/tools/source-helpers";
2827 import { sourceLabel } from "@/lib/tool/source-utils";
@@ -121,6 +120,7 @@ export function ToolsView() { · 92 unmodified lines
121120 sourceQuality,
122121 sourceAuthProfiles,
123122 inventoryStatus,
123+ inventorySourceStates,
124124 loadingSources,
125125 loadingTools,
126126 refreshingTools,
@@ -134,6 +134,7 @@ export function ToolsView() { · 7 unmodified lines
134134 loadToolDetails,
135135 } = useWorkspaceTools(context ?? null, {
136136 includeDetails: false,
137+ sourceName: activeTab === "catalog" ? catalogSourceValue : null,
137138 });
138139
139140 const showRegenerationToast = useCallback((kind: "loading" | "success" | "error", message: string) => {
@@ -212,19 +213,6 @@ export function ToolsView() { · 72 unmodified lines
212213 const storagePanelLoading = sessionLoading || storageLoading;
213214 const shouldRenderShellSkeleton = sessionLoading && !TAB_USES_OWN_LOADING_STATE[activeTab];
214215
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
228216 const mergedLoadingSources = useMemo(() => {
229217 const combined = new Set<string>();
230218
@@ -234,17 +222,18 @@ export function ToolsView() { · 3 unmodified lines
234222 }
235223 }
236224
237− if (hasGlobalInventoryWarning) {
238− for (const source of sourceItems) {
239− if (toolSourceNames.has(source.name)) {
240− continue;
241− }
242− combined.add(source.name);
225+ for (const [sourceName, sourceState] of Object.entries(inventorySourceStates)) {
226+ if (
227+ sourceState?.state === "queued"
228+ || sourceState?.state === "loading"
229+ || sourceState?.state === "indexing"
230+ ) {
231+ combined.add(sourceName);
243232 }
244233 }
245234
246235 return Array.from(combined);
247− }, [hasGlobalInventoryWarning, loadingSources, sourceItems, toolSourceNames]);
236+ }, [inventorySourceStates, loadingSources]);
248237
249238 const visibleLoadingSources = useMemo(
250239 () => mergedLoadingSources.filter((name) =>
@@ -474,6 +463,7 @@ export function ToolsView() { · 223 unmodified lines
474463 tools={visibleTools}
475464 sources={sourceItems}
476465 loadingSources={visibleLoadingSources}
466+ sourceStates={inventorySourceStates}
477467 sourceCountsOverride={visibleSourceCounts}
478468 totalTools={visibleTools.length}
479469 hasMoreTools={hasMoreTools}
executor/apps/web/src/hooks/use/workspace-tools.ts+26−9
@@ -22,15 +22,10 @@ interface WorkspaceToolsQueryResult { · 21 unmodified lines
2222 /** URL to a workspace-wide Monaco `.d.ts` bundle (may be undefined). */
2323 typesUrl?: string;
2424 inventoryStatus: {
25− state: "initializing" | "ready" | "rebuilding" | "stale" | "failed";
26− readyBuildId?: string;
27− buildingBuildId?: string;
25+ state: "initializing" | "ready" | "rebuilding" | "failed";
2826 readyToolCount: number;
2927 loadingSourceNames: string[];
3028 sourceToolCounts: Record<string, number>;
31− lastBuildStartedAt?: number;
32− lastBuildCompletedAt?: number;
33− lastBuildFailedAt?: number;
3429 error?: string;
3530 updatedAt?: number;
3631 };
@@ -40,6 +35,21 @@ interface WorkspaceToolsQueryResult { · 3 unmodified lines
4035
4136 interface UseWorkspaceToolsOptions {
4237 includeDetails?: boolean;
38+ sourceName?: string | null;
39+}
40+
41+interface WorkspaceInventoryProgress {
42+ inventoryStatus: WorkspaceToolsQueryResult["inventoryStatus"];
43+ sourceStates: Record<string, {
44+ state: "queued" | "loading" | "indexing" | "ready" | "failed";
45+ toolCount: number;
46+ processedTools?: number;
47+ message?: string;
48+ error?: string;
49+ updatedAt?: number;
50+ }>;
51+ warnings: string[];
52+ reactiveKey: string;
4353 }
4454
4555 type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "display" | "typing">;
@@ -61,7 +71,6 @@ type ListToolsWithWarningsAction = (args: { · 15 unmodified lines
6171 sourceName?: string;
6272 cursor?: string;
6373 limit?: number;
64− buildId?: string;
6574 fetchAll?: boolean;
6675 rebuildInventory?: boolean;
6776 }) => Promise<WorkspaceToolsQueryResult>;
@@ -77,6 +86,7 @@ export function useWorkspaceTools( · 9 unmodified lines
7786 options: UseWorkspaceToolsOptions = {},
7887 ) {
7988 const includeDetails = options.includeDetails ?? false;
89+ const sourceName = options.sourceName?.trim() ? options.sourceName.trim() : undefined;
8090 const listToolsWithWarningsRaw = useAction(convexApi.executorNode.listToolsWithWarnings);
8191 const listToolsWithWarnings = listToolsWithWarningsRaw as ListToolsWithWarningsAction;
8292 const listToolDetailsRaw = useMutation(convexApi.workspace.getToolDetails);
@@ -87,7 +97,7 @@ export function useWorkspaceTools( · 4 unmodified lines
8797 const inventoryProgress = useConvexQuery(
8898 convexApi.workspace.getToolInventoryProgress,
8999 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
90− );
100+ ) as WorkspaceInventoryProgress | undefined;
91101
92102 const inventoryQuery = useQuery<WorkspaceToolsQueryResult, Error>({
93103 queryKey: [
@@ -96,6 +106,7 @@ export function useWorkspaceTools( · 2 unmodified lines
96106 context?.accountId,
97107 context?.clientId,
98108 includeDetails,
109+ sourceName,
99110 inventoryProgress?.reactiveKey,
100111 ],
101112 queryFn: async (): Promise<WorkspaceToolsQueryResult> => {
@@ -123,6 +134,7 @@ export function useWorkspaceTools( · 21 unmodified lines
123134 ...(context.sessionId && { sessionId: context.sessionId }),
124135 includeDetails,
125136 fetchAll: true,
137+ ...(sourceName ? { sourceName } : {}),
126138 });
127139 },
128140 enabled: !!context,
@@ -131,6 +143,7 @@ export function useWorkspaceTools( · 2 unmodified lines
131143
132144 const inventoryData = inventoryQuery.data;
133145 const inventoryStatus = inventoryProgress?.inventoryStatus ?? inventoryData?.inventoryStatus;
146+ const inventorySourceStates: WorkspaceInventoryProgress["sourceStates"] = inventoryProgress?.sourceStates ?? {};
134147 const tools = inventoryData?.tools ?? [];
135148
136149 const loadToolDetails = useCallback(async (toolPaths: string[]): Promise<Record<string, ToolDetailDescriptor>> => {
@@ -171,6 +184,10 @@ export function useWorkspaceTools( · 34 unmodified lines
171184 detailsCacheRef.current.clear();
172185 }, [context?.workspaceId, context?.accountId, context?.clientId, context?.sessionId]);
173186
187+ useEffect(() => {
188+ detailsCacheRef.current.clear();
189+ }, [inventoryProgress?.reactiveKey]);
190+
174191 useEffect(() => {
175192 if (!inventoryData || !includeDetails) {
176193 return;
@@ -219,7 +236,7 @@ export function useWorkspaceTools( · 42 unmodified lines
219236 sourceQuality: inventoryData?.sourceQuality ?? {},
220237 sourceAuthProfiles: inventoryData?.sourceAuthProfiles ?? {},
221238 inventoryStatus,
222− inventorySourceStates: inventoryProgress?.sourceStates ?? {},
239+ inventorySourceStates,
223240 loadingSources: inventoryStatus?.loadingSourceNames ?? [],
224241 loadingTools: !!context && inventoryQuery.isLoading,
225242 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
2727 rank: 0,
2828 addedAt: "2026-02-14",
2929 },
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+ },
3082 {
3183 id: "github-rest",
3284 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
177177 return resolveActiveWorkspaceId(workspaces, activeWorkspaceId, accountStoredWorkspace);
178178 }, [workspaces, activeWorkspaceId, account]);
179179
180+ const profileName = workosAuthProfile?.name?.trim();
181+
180182 const bootstrapWorkosAccountQuery = useTanstackQuery({
181183 queryKey: [
182184 "workos-account-bootstrap",
183185 storedSessionId ?? "none",
184186 workosAuthenticated ? "signed-in" : "signed-out",
187+ profileName ?? "no-profile-name",
185188 ],
186189 enabled:
187190 workosEnabled
@@ -192,6 +195,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 4 unmodified lines
192195 staleTime: Number.POSITIVE_INFINITY,
193196 queryFn: async () => bootstrapCurrentWorkosAccount({
194197 sessionId: storedSessionId ?? undefined,
198+ profileName: profileName && profileName.length > 0 ? profileName : undefined,
195199 }),
196200 });
197201
executor/packages/core/src/openapi-prepare.ts+81−14
@@ -9,6 +9,7 @@ import type { PreparedOpenApiSpec } from "./tool/source-types"; · 8 unmodified lines
99 import { toPlainObject } from "./utils";
1010
1111 const unknownRecordSchema = z.record(z.unknown());
12+const OPENAPI_JSON_FETCH_TIMEOUT_MS = 45_000;
1213
1314 const openApiDocumentSchema = z.object({
1415 openapi: z.string().optional(),
@@ -55,6 +56,66 @@ function toOpenApiTsInput(spec: Record<string, unknown>): Parameters<typeof open · 40 unmodified lines
5556 return parsed.data as Parameters<typeof openapiTS>[0] & Record<string, unknown>;
5657 }
5758
59+function isHttpUrl(value: string): boolean {
60+ try {
61+ const parsed = new URL(value);
62+ return parsed.protocol === "http:" || parsed.protocol === "https:";
63+ } catch {
64+ return false;
65+ }
66+}
67+
68+function looksLikeJsonSpecUrl(value: string): boolean {
69+ if (!isHttpUrl(value)) {
70+ return false;
71+ }
72+
73+ return /\.json($|[?#])/i.test(value);
74+}
75+
76+async function fetchJsonOpenApiSpec(url: string, sourceName: string): Promise<Record<string, unknown>> {
77+ const controller = new AbortController();
78+ const timeout = setTimeout(() => {
79+ controller.abort();
80+ }, OPENAPI_JSON_FETCH_TIMEOUT_MS);
81+
82+ try {
83+ const response = await fetch(url, {
84+ signal: controller.signal,
85+ redirect: "follow",
86+ });
87+
88+ if (!response.ok) {
89+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
90+ }
91+
92+ const body = await response.text();
93+ if (!body.trim()) {
94+ throw new Error("empty response body");
95+ }
96+
97+ let parsed: unknown;
98+ try {
99+ parsed = JSON.parse(body);
100+ } catch (error) {
101+ const msg = error instanceof Error ? error.message : String(error);
102+ throw new Error(`invalid JSON payload: ${msg}`);
103+ }
104+
105+ const asRecord = toRecordResult(parsed, `Fetched OpenAPI source '${sourceName}'`);
106+ if (asRecord.isErr()) {
107+ throw asRecord.error;
108+ }
109+
110+ return asRecord.value;
111+ } catch (error) {
112+ const msg = error instanceof Error ? error.message : String(error);
113+ throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);
114+ } finally {
115+ clearTimeout(timeout);
116+ }
117+}
118+
58119 function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null {
59120 let strippedCount = 0;
60121
@@ -157,6 +218,7 @@ async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string · 96 unmodified lines
157218 export interface PrepareOpenApiSpecOptions {
158219 includeDts?: boolean;
159220 profile?: "full" | "inventory";
221+ resolveSchemaRefs?: boolean;
160222 }
161223
162224 export async function prepareOpenApiSpec(
@@ -168,26 +230,31 @@ export async function prepareOpenApiSpec( · 5 unmodified lines
168230 const includeDts = options.includeDts ?? true;
169231 const profile = options.profile ?? (includeDts ? "full" : "inventory");
170232 const shouldGenerateDts = includeDts && profile === "full";
171− const shouldBundle = profile === "full";
233+ const shouldBundle = profile === "full" && includeDts;
234+ const resolveSchemaRefs = options.resolveSchemaRefs ?? (profile === "full");
172235
173236 const warnings: string[] = [];
174237
175238 let parsed: Record<string, unknown>;
176239 if (typeof spec === "string") {
177− let parsedValue: unknown;
178− try {
179− parsedValue = await parser.parse(spec);
180− } catch (error) {
181− const msg = error instanceof Error ? error.message : String(error);
182− throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);
183− }
240+ if (looksLikeJsonSpecUrl(spec)) {
241+ parsed = await fetchJsonOpenApiSpec(spec, sourceName);
242+ } else {
243+ let parsedValue: unknown;
244+ try {
245+ parsedValue = await parser.parse(spec);
246+ } catch (error) {
247+ const msg = error instanceof Error ? error.message : String(error);
248+ throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);
249+ }
184250
185− const parsedRecord = toRecordResult(parsedValue, `Parsed OpenAPI source '${sourceName}'`);
186− if (parsedRecord.isErr()) {
187− throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`);
188− }
251+ const parsedRecord = toRecordResult(parsedValue, `Parsed OpenAPI source '${sourceName}'`);
252+ if (parsedRecord.isErr()) {
253+ throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`);
254+ }
189255
190− parsed = parsedRecord.value;
256+ parsed = parsedRecord.value;
257+ }
191258 } else {
192259 parsed = spec;
193260 }
@@ -234,7 +301,7 @@ export async function prepareOpenApiSpec( · 40 unmodified lines
234301 includeSchemas: profile === "full",
235302 includeTypeHints: true,
236303 includeParameterSchemas: true,
237− resolveSchemaRefs: profile === "full",
304+ resolveSchemaRefs,
238305 },
239306 );
240307
executor/packages/core/src/openapi/real-specs.test.ts+111−0
@@ -659,6 +659,74 @@ describe("real-world OpenAPI specs", () => { · 658 unmodified lines
659659 300_000,
660660 );
661661
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+
662730 test(
663731 "OpenAPI inventory mode still yields usable schemas",
664732 async () => {
@@ -685,6 +753,49 @@ describe("real-world OpenAPI specs", () => { · 20 unmodified lines
685753 300_000,
686754 );
687755
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+
688799 test(
689800 "openai: create batch hints stay non-lossy in inventory mode",
690801 async () => {
executor/packages/database/convex/auth.ts+1−0
@@ -45,6 +45,7 @@ export const bootstrapCurrentWorkosAccount = customMutation({ · 44 unmodified lines
4545 method: "POST",
4646 args: {
4747 sessionId: v.optional(v.string()),
48+ profileName: v.optional(v.string()),
4849 },
4950 handler: async (ctx, args) => {
5051 return await bootstrapCurrentWorkosAccountImpl(ctx, args);
executor/packages/database/convex/executorNode.ts+0−2
@@ -113,7 +113,6 @@ export const listToolsWithWarnings = workspaceAction({ · 112 unmodified lines
113113 sourceName: v.optional(v.string()),
114114 cursor: v.optional(v.string()),
115115 limit: v.optional(v.number()),
116− buildId: v.optional(v.string()),
117116 fetchAll: v.optional(v.boolean()),
118117 rebuildInventory: v.optional(v.boolean()),
119118 },
@@ -156,7 +155,6 @@ export const listToolsWithWarnings = workspaceAction({ · 36 unmodified lines
156155 sourceName: args.sourceName,
157156 cursor: args.cursor,
158157 limit: args.limit,
159− buildId: args.buildId,
160158 fetchAll: args.fetchAll,
161159 });
162160
executor/packages/database/convex/runtimeNode.ts+1−0
@@ -44,6 +44,7 @@ export const prepareOpenApiSpec = internalAction({ · 43 unmodified lines
4444 const prepared = await prepareOpenApiSpecInNode(args.specUrl, args.sourceName, {
4545 includeDts: args.includeDts,
4646 profile: "full",
47+ resolveSchemaRefs: args.includeDts === true,
4748 });
4849
4950 const json = JSON.stringify(prepared);
executor/packages/database/convex/schema.ts+14−25
@@ -422,17 +422,13 @@ export default defineSchema({ · 421 unmodified lines
422422 .index("by_spec_url_version", ["specUrl", "version"]),
423423
424424 // Workspace tool registry state.
425− // Stores the currently "ready" build id for search + invocation.
425+ // Stores active incremental materialization state for search + invocation.
426426 workspaceToolRegistryState: defineTable({
427427 workspaceId: v.id("workspaces"),
428428 signature: v.optional(v.string()),
429− readyBuildId: v.optional(v.string()),
430− buildingBuildId: v.optional(v.string()),
431− buildingSignature: v.optional(v.string()),
432− buildingStartedAt: v.optional(v.number()),
433− lastBuildCompletedAt: v.optional(v.number()),
434− lastBuildFailedAt: v.optional(v.number()),
435− lastBuildError: v.optional(v.string()),
429+ lastRefreshCompletedAt: v.optional(v.number()),
430+ lastRefreshFailedAt: v.optional(v.number()),
431+ lastRefreshError: v.optional(v.string()),
436432 typesStorageId: v.optional(v.id("_storage")),
437433 warnings: v.optional(v.array(v.string())),
438434 toolCount: v.optional(v.number()),
@@ -440,11 +436,7 @@ export default defineSchema({ · 1 unmodified line
440436 sourceName: v.string(),
441437 toolCount: v.number(),
442438 }))),
443− sourceVersions: v.optional(v.array(v.object({
444− sourceId: v.string(),
445− sourceName: v.string(),
446− updatedAt: v.number(),
447− }))),
439+ sourceStates: v.optional(v.array(jsonObjectValidator)),
448440 sourceQuality: v.optional(v.array(v.object({
449441 sourceKey: v.string(),
450442 toolCount: v.number(),
@@ -479,7 +471,6 @@ export default defineSchema({ · 28 unmodified lines
479471 // NOTE: We avoid storing raw JSON Schemas here because Convex forbids `$`-prefixed keys.
480472 workspaceToolRegistry: defineTable({
481473 workspaceId: v.id("workspaces"),
482− buildId: v.string(),
483474 path: v.string(),
484475 preferredPath: v.string(),
485476 namespace: v.string(),
@@ -500,37 +491,35 @@ export default defineSchema({ · 14 unmodified lines
500491 })),
501492 createdAt: v.number(),
502493 })
503− .index("by_workspace_build_path", ["workspaceId", "buildId", "path"])
504− .index("by_workspace_build_normalized", ["workspaceId", "buildId", "normalizedPath"])
505− .index("by_workspace_build_source", ["workspaceId", "buildId", "source"])
506− .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"])
507− .index("by_workspace_build", ["workspaceId", "buildId"])
494+ .index("by_workspace_path", ["workspaceId", "path"])
495+ .index("by_workspace_normalized", ["workspaceId", "normalizedPath"])
496+ .index("by_workspace_source", ["workspaceId", "source"])
497+ .index("by_workspace_namespace", ["workspaceId", "namespace"])
498+ .index("by_workspace", ["workspaceId"])
508499 .searchIndex("search_text", {
509500 searchField: "searchText",
510− filterFields: ["workspaceId", "buildId"],
501+ filterFields: ["workspaceId"],
511502 }),
512503
513504 // Heavy per-tool payloads kept separate from searchable metadata.
514505 workspaceToolRegistryPayloads: defineTable({
515506 workspaceId: v.id("workspaces"),
516− buildId: v.string(),
517507 path: v.string(),
518508 serializedToolJson: v.string(),
519509 createdAt: v.number(),
520510 })
521− .index("by_workspace_build_path", ["workspaceId", "buildId", "path"])
522− .index("by_workspace_build", ["workspaceId", "buildId"]),
511+ .index("by_workspace_path", ["workspaceId", "path"])
512+ .index("by_workspace", ["workspaceId"]),
523513
524514 // Precomputed namespace summaries for fast catalog.namespaces.
525515 workspaceToolNamespaces: defineTable({
526516 workspaceId: v.id("workspaces"),
527− buildId: v.string(),
528517 namespace: v.string(),
529518 toolCount: v.number(),
530519 samplePaths: v.array(v.string()),
531520 createdAt: v.number(),
532521 })
533− .index("by_workspace_build", ["workspaceId", "buildId"]),
522+ .index("by_workspace", ["workspaceId"]),
534523
535524 // Durable and ephemeral storage instances used by filesystem/kv/sql tools.
536525 // 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 @@
1+import { describe, expect, test } from "bun:test";
2+import { convexTest } from "convex-test";
3+import { internal } from "./_generated/api";
4+import schema from "./schema";
5+import { registerRateLimiterComponent } from "./testHelpers";
6+
7+function setup() {
8+ const t = convexTest(schema, {
9+ "./database.ts": () => import("./database"),
10+ "./toolRegistry.ts": () => import("./toolRegistry"),
11+ "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
12+ "./_generated/api.js": () => import("./_generated/api.js"),
13+ });
14+
15+ registerRateLimiterComponent(t);
16+ return t;
17+}
18+
19+function makeRegistryTool(path: string, source = "openapi:bulk") {
20+ return {
21+ path,
22+ preferredPath: path,
23+ namespace: path.split(".")[0] ?? "bulk",
24+ normalizedPath: path.toLowerCase(),
25+ aliases: [],
26+ description: `Tool ${path}`,
27+ approval: "auto" as const,
28+ source,
29+ searchText: `${path} ${source}`,
30+ displayInput: "{}",
31+ displayOutput: "{ ok: boolean }",
32+ requiredInputKeys: [],
33+ previewInputKeys: [],
34+ serializedToolJson: JSON.stringify({
35+ path,
36+ description: `Tool ${path}`,
37+ approval: "auto",
38+ source,
39+ }),
40+ };
41+}
42+
43+describe("toolRegistry cleanup", () => {
44+ test("putToolsBatch keeps a single row per workspace/path", async () => {
45+ const t = setup();
46+ const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
47+
48+ await t.mutation(internal.toolRegistry.putToolsBatch, {
49+ workspaceId: session.workspaceId,
50+ tools: [makeRegistryTool("bulk.same_path")],
51+ });
52+
53+ await t.mutation(internal.toolRegistry.putToolsBatch, {
54+ workspaceId: session.workspaceId,
55+ tools: [makeRegistryTool("bulk.same_path")],
56+ });
57+
58+ const page = await t.query(internal.toolRegistry.listToolsPage, {
59+ workspaceId: session.workspaceId,
60+ limit: 50,
61+ });
62+
63+ expect(page.items.filter((item) => item.path === "bulk.same_path").length).toBe(1);
64+ });
65+
66+ test("deleteToolsBySource action pages through large source inventories", async () => {
67+ const t = setup();
68+ const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
69+
70+ const total = 1600;
71+ const batchSize = 100;
72+ for (let i = 0; i < total; i += batchSize) {
73+ const tools = Array.from({ length: Math.min(batchSize, total - i) }, (_, offset) =>
74+ makeRegistryTool(`bulk.tool_${i + offset}`, "openapi:bulk"),
75+ );
76+
77+ await t.mutation(internal.toolRegistry.putToolsBatch, {
78+ workspaceId: session.workspaceId,
79+ tools,
80+ });
81+ }
82+
83+ const removed = await t.action(internal.toolRegistry.deleteToolsBySource, {
84+ workspaceId: session.workspaceId,
85+ source: "openapi:bulk",
86+ });
87+
88+ expect(removed.removed).toBe(total);
89+
90+ const remaining = await t.query(internal.toolRegistry.listToolsBySourcePage, {
91+ workspaceId: session.workspaceId,
92+ source: "openapi:bulk",
93+ limit: 50,
94+ });
95+
96+ expect(remaining.items.length).toBe(0);
97+ expect(remaining.continueCursor).toBeNull();
98+ }, 120_000);
99+});
executor/packages/database/convex/toolRegistry.ts+221−317
@@ -1,6 +1,6 @@
11 import { v } from "convex/values";
2−import { internal } from "./_generated/api";
32 import { internalAction, internalMutation, internalQuery } from "./_generated/server";
3+import { internal } from "./_generated/api";
44 import { vv } from "./typedV";
55
66 export const getState = internalQuery({
@@ -16,21 +16,20 @@ export const getState = internalQuery({ · 9 unmodified lines
1616 if (!entry) return null;
1717
1818 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+ : [];
1922
2023 return {
2124 signature: entry.signature,
22− readyBuildId: entry.readyBuildId,
23− buildingBuildId: entry.buildingBuildId,
24− buildingSignature: entry.buildingSignature,
25− buildingStartedAt: entry.buildingStartedAt,
26− lastBuildCompletedAt: entry.lastBuildCompletedAt,
27− lastBuildFailedAt: entry.lastBuildFailedAt,
28− lastBuildError: entry.lastBuildError,
25+ lastRefreshCompletedAt: entry.lastRefreshCompletedAt,
26+ lastRefreshFailedAt: entry.lastRefreshFailedAt,
27+ lastRefreshError: entry.lastRefreshError,
2928 typesStorageId: entry.typesStorageId,
3029 warnings: entry.warnings ?? [],
3130 toolCount: entry.toolCount,
3231 sourceToolCounts: entry.sourceToolCounts ?? [],
33− sourceVersions: entry.sourceVersions ?? [],
32+ sourceStates,
3433 sourceQuality: entry.sourceQuality ?? [],
3534 sourceAuthProfiles: entry.sourceAuthProfiles ?? [],
3635 openApiRefHintTables: Array.isArray(stateEntry.openApiRefHintTables) ? stateEntry.openApiRefHintTables : [],
@@ -39,58 +38,9 @@ export const getState = internalQuery({ · 2 unmodified lines
3938 },
4039 });
4140
42−export const beginBuild = internalMutation({
43− args: {
44− workspaceId: vv.id("workspaces"),
45− signature: v.string(),
46− buildId: v.string(),
47− },
48− handler: async (ctx, args) => {
49− const now = Date.now();
50− const existing = await ctx.db
51− .query("workspaceToolRegistryState")
52− .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
53− .unique();
54−
55− if (existing) {
56− await ctx.db.patch(existing._id, {
57− buildingBuildId: args.buildId,
58− buildingSignature: args.signature,
59− buildingStartedAt: now,
60− lastBuildError: undefined,
61− updatedAt: now,
62− });
63− return;
64− }
65−
66− await ctx.db.insert("workspaceToolRegistryState", {
67− workspaceId: args.workspaceId,
68− signature: args.signature,
69− readyBuildId: undefined,
70− buildingBuildId: args.buildId,
71− buildingSignature: args.signature,
72− buildingStartedAt: now,
73− lastBuildCompletedAt: undefined,
74− lastBuildFailedAt: undefined,
75− lastBuildError: undefined,
76− typesStorageId: undefined,
77− warnings: [],
78− toolCount: 0,
79− sourceToolCounts: [],
80− sourceVersions: [],
81− sourceQuality: [],
82− sourceAuthProfiles: [],
83− openApiRefHintTables: [],
84− createdAt: now,
85− updatedAt: now,
86− } as never);
87− },
88−});
89−
9041 export const putToolsBatch = internalMutation({
9142 args: {
9243 workspaceId: vv.id("workspaces"),
93− buildId: v.string(),
9444 tools: v.array(
9545 v.object({
9646 path: v.string(),
@@ -120,9 +70,43 @@ export const putToolsBatch = internalMutation({ · 23 unmodified lines
12070 handler: async (ctx, args) => {
12171 const now = Date.now();
12272 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+
123108 await ctx.db.insert("workspaceToolRegistry", {
124109 workspaceId: args.workspaceId,
125− buildId: args.buildId,
126110 path: tool.path,
127111 preferredPath: tool.preferredPath,
128112 namespace: tool.namespace,
@@ -142,7 +126,6 @@ export const putToolsBatch = internalMutation({ · 13 unmodified lines
142126
143127 await ctx.db.insert("workspaceToolRegistryPayloads", {
144128 workspaceId: args.workspaceId,
145− buildId: args.buildId,
146129 path: tool.path,
147130 serializedToolJson: tool.serializedToolJson,
148131 createdAt: now,
@@ -154,7 +137,6 @@ export const putToolsBatch = internalMutation({ · 5 unmodified lines
154137 export const putNamespacesBatch = internalMutation({
155138 args: {
156139 workspaceId: vv.id("workspaces"),
157− buildId: v.string(),
158140 namespaces: v.array(
159141 v.object({
160142 namespace: v.string(),
@@ -168,7 +150,6 @@ export const putNamespacesBatch = internalMutation({ · 7 unmodified lines
168150 for (const ns of args.namespaces) {
169151 await ctx.db.insert("workspaceToolNamespaces", {
170152 workspaceId: args.workspaceId,
171− buildId: args.buildId,
172153 namespace: ns.namespace,
173154 toolCount: ns.toolCount,
174155 samplePaths: ns.samplePaths,
@@ -178,66 +159,9 @@ export const putNamespacesBatch = internalMutation({ · 3 unmodified lines
178159 },
179160 });
180161
181−export const finishBuild = internalMutation({
182− args: {
183− workspaceId: vv.id("workspaces"),
184− buildId: v.string(),
185− signature: v.string(),
186− },
187− handler: async (ctx, args) => {
188− const now = Date.now();
189− const state = await ctx.db
190− .query("workspaceToolRegistryState")
191− .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
192− .unique();
193−
194− if (!state) {
195− await ctx.db.insert("workspaceToolRegistryState", {
196− workspaceId: args.workspaceId,
197− signature: args.signature,
198− readyBuildId: args.buildId,
199− buildingBuildId: undefined,
200− buildingSignature: undefined,
201− buildingStartedAt: undefined,
202− lastBuildCompletedAt: now,
203− lastBuildFailedAt: undefined,
204− lastBuildError: undefined,
205− typesStorageId: undefined,
206− warnings: [],
207− toolCount: 0,
208− sourceToolCounts: [],
209− sourceVersions: [],
210− sourceQuality: [],
211− sourceAuthProfiles: [],
212− openApiRefHintTables: [],
213− createdAt: now,
214− updatedAt: now,
215− } as never);
216− return;
217− }
218−
219− if (state.buildingBuildId !== args.buildId) {
220− // Another build started; ignore finishing this one.
221− return;
222− }
223−
224− await ctx.db.patch(state._id, {
225− readyBuildId: args.buildId,
226− signature: args.signature,
227− buildingBuildId: undefined,
228− buildingSignature: undefined,
229− buildingStartedAt: undefined,
230− lastBuildCompletedAt: now,
231− lastBuildError: undefined,
232− updatedAt: now,
233− });
234− },
235−});
236−
237−export const updateBuildMetadata = internalMutation({
162+export const updateRegistryMetadata = internalMutation({
238163 args: {
239164 workspaceId: vv.id("workspaces"),
240− buildId: v.string(),
241165 typesStorageId: v.optional(v.id("_storage")),
242166 warnings: v.array(v.string()),
243167 toolCount: v.number(),
@@ -245,11 +169,6 @@ export const updateBuildMetadata = internalMutation({ · 1 unmodified line
245169 sourceName: v.string(),
246170 toolCount: v.number(),
247171 })),
248− sourceVersions: v.array(v.object({
249− sourceId: v.string(),
250− sourceName: v.string(),
251− updatedAt: v.number(),
252− })),
253172 sourceQuality: v.array(v.object({
254173 sourceKey: v.string(),
255174 toolCount: v.number(),
@@ -282,9 +201,23 @@ export const updateBuildMetadata = internalMutation({ · 26 unmodified lines
282201 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
283202 .unique();
284203 if (!state) {
285− return;
286− }
287− if (state.readyBuildId !== args.buildId) {
204+ await ctx.db.insert("workspaceToolRegistryState", {
205+ workspaceId: args.workspaceId,
206+ signature: undefined,
207+ lastRefreshCompletedAt: Date.now(),
208+ lastRefreshFailedAt: undefined,
209+ lastRefreshError: undefined,
210+ typesStorageId: args.typesStorageId,
211+ warnings: args.warnings,
212+ toolCount: args.toolCount,
213+ sourceToolCounts: args.sourceToolCounts,
214+ sourceStates: [],
215+ sourceQuality: args.sourceQuality,
216+ sourceAuthProfiles: args.sourceAuthProfiles,
217+ openApiRefHintTables: args.openApiRefHintTables,
218+ createdAt: Date.now(),
219+ updatedAt: Date.now(),
220+ } as never);
288221 return;
289222 }
290223
@@ -293,251 +226,234 @@ export const updateBuildMetadata = internalMutation({ · 2 unmodified lines
293226 warnings: args.warnings,
294227 toolCount: args.toolCount,
295228 sourceToolCounts: args.sourceToolCounts,
296− sourceVersions: args.sourceVersions,
297229 sourceQuality: args.sourceQuality,
298230 sourceAuthProfiles: args.sourceAuthProfiles,
299231 openApiRefHintTables: args.openApiRefHintTables,
232+ lastRefreshCompletedAt: Date.now(),
233+ lastRefreshError: undefined,
300234 updatedAt: Date.now(),
301235 } as never);
302236 },
303237 });
304238
305−export const failBuild = internalMutation({
239+const sourceStateValidator = v.any();
240+
241+export const setSourceStates = internalMutation({
306242 args: {
307243 workspaceId: vv.id("workspaces"),
308− buildId: v.string(),
309− error: v.optional(v.string()),
244+ sourceStates: v.array(sourceStateValidator),
245+ signature: v.optional(v.string()),
310246 },
311247 handler: async (ctx, args) => {
248+ const now = Date.now();
312249 const state = await ctx.db
313250 .query("workspaceToolRegistryState")
314251 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
315252 .unique();
253+
316254 if (!state) {
317− return;
318− }
319− if (state.buildingBuildId !== args.buildId) {
255+ await ctx.db.insert("workspaceToolRegistryState", {
256+ workspaceId: args.workspaceId,
257+ signature: args.signature,
258+ lastRefreshCompletedAt: undefined,
259+ lastRefreshFailedAt: undefined,
260+ lastRefreshError: undefined,
261+ typesStorageId: undefined,
262+ warnings: [],
263+ toolCount: 0,
264+ sourceToolCounts: [],
265+ sourceStates: args.sourceStates,
266+ sourceQuality: [],
267+ sourceAuthProfiles: [],
268+ openApiRefHintTables: [],
269+ createdAt: now,
270+ updatedAt: now,
271+ } as never);
320272 return;
321273 }
322274
323275 await ctx.db.patch(state._id, {
324− buildingBuildId: undefined,
325− buildingSignature: undefined,
326− buildingStartedAt: undefined,
327− lastBuildFailedAt: Date.now(),
328− lastBuildError: args.error,
329− updatedAt: Date.now(),
276+ ...(args.signature ? { signature: args.signature } : {}),
277+ sourceStates: args.sourceStates,
278+ updatedAt: now,
330279 });
331280 },
332281 });
333282
334−const PRUNE_DELETE_PAGE_SIZE = 10;
335−const PRUNE_NAMESPACE_SCAN_PAGE_SIZE = 250;
336−
337−export const scanNamespaceBuildsForPrune = internalQuery({
283+export const setRefreshError = internalMutation({
338284 args: {
339285 workspaceId: vv.id("workspaces"),
340− cursor: v.optional(v.string()),
286+ error: v.string(),
341287 },
342288 handler: async (ctx, args) => {
343− const page = await ctx.db
344− .query("workspaceToolNamespaces")
345− .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId))
346− .paginate({ numItems: PRUNE_NAMESPACE_SCAN_PAGE_SIZE, cursor: args.cursor ?? null });
289+ const now = Date.now();
290+ const state = await ctx.db
291+ .query("workspaceToolRegistryState")
292+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
293+ .unique();
347294
348− return {
349− items: page.page.map((entry) => ({
350− buildId: entry.buildId,
351− createdAt: entry.createdAt,
352− })),
353− continueCursor: page.isDone ? null : page.continueCursor,
354− };
295+ if (!state) {
296+ await ctx.db.insert("workspaceToolRegistryState", {
297+ workspaceId: args.workspaceId,
298+ signature: undefined,
299+ lastRefreshCompletedAt: undefined,
300+ lastRefreshFailedAt: now,
301+ lastRefreshError: args.error,
302+ typesStorageId: undefined,
303+ warnings: [],
304+ toolCount: 0,
305+ sourceToolCounts: [],
306+ sourceStates: [],
307+ sourceQuality: [],
308+ sourceAuthProfiles: [],
309+ openApiRefHintTables: [],
310+ createdAt: now,
311+ updatedAt: now,
312+ } as never);
313+ return;
314+ }
315+
316+ await ctx.db.patch(state._id, {
317+ lastRefreshFailedAt: now,
318+ lastRefreshError: args.error,
319+ updatedAt: now,
320+ });
355321 },
356322 });
357323
358−export const deleteToolRegistryToolsPage = internalMutation({
324+export const deleteToolsBySourcePage = internalMutation({
359325 args: {
360326 workspaceId: vv.id("workspaces"),
361− buildId: v.string(),
362− cursor: v.optional(v.string()),
327+ source: v.string(),
363328 },
364329 handler: async (ctx, args) => {
365− const page = await ctx.db
330+ const source = args.source.trim();
331+ if (!source) {
332+ return { removed: 0 };
333+ }
334+
335+ const PAGE_SIZE = 100;
336+ let removed = 0;
337+
338+ const entries = await ctx.db
366339 .query("workspaceToolRegistry")
367− .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId))
368− .paginate({ numItems: PRUNE_DELETE_PAGE_SIZE, cursor: args.cursor ?? null });
340+ .withIndex("by_workspace_source", (q) =>
341+ q.eq("workspaceId", args.workspaceId).eq("source", source),
342+ )
343+ .take(PAGE_SIZE);
369344
370− for (const entry of page.page) {
345+ for (const entry of entries) {
371346 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+ }
372366 }
373367
374− return { continueCursor: page.isDone ? null : page.continueCursor };
368+ return {
369+ removed,
370+ hasMore: entries.length >= PAGE_SIZE,
371+ };
375372 },
376373 });
377374
378−export const deleteToolRegistryPayloadsPage = internalMutation({
375+export const deleteToolsBySource = internalAction({
379376 args: {
380377 workspaceId: vv.id("workspaces"),
381− buildId: v.string(),
382− cursor: v.optional(v.string()),
378+ source: v.string(),
383379 },
384380 handler: async (ctx, args) => {
385− const page = await ctx.db
386− .query("workspaceToolRegistryPayloads")
387− .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId))
388− .paginate({ numItems: PRUNE_DELETE_PAGE_SIZE, cursor: args.cursor ?? null });
381+ let removed = 0;
389382
390− for (const entry of page.page) {
391− await ctx.db.delete(entry._id);
383+ while (true) {
384+ const page: { removed: number; hasMore: boolean } = await ctx.runMutation(
385+ internal.toolRegistry.deleteToolsBySourcePage,
386+ {
387+ workspaceId: args.workspaceId,
388+ source: args.source,
389+ },
390+ );
391+
392+ removed += page.removed;
393+ if (!page.hasMore || page.removed === 0) {
394+ break;
395+ }
392396 }
393397
394− return { continueCursor: page.isDone ? null : page.continueCursor };
398+ return { removed };
395399 },
396400 });
397401
398−export const deleteToolRegistryNamespacesPage = internalMutation({
402+export const listSerializedToolsPage = internalQuery({
399403 args: {
400404 workspaceId: vv.id("workspaces"),
401− buildId: v.string(),
402405 cursor: v.optional(v.string()),
406+ limit: v.number(),
403407 },
404408 handler: async (ctx, args) => {
409+ const limit = Math.max(1, Math.min(250, Math.floor(args.limit)));
405410 const page = await ctx.db
406− .query("workspaceToolNamespaces")
407− .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId))
408− .paginate({ numItems: PRUNE_DELETE_PAGE_SIZE, cursor: args.cursor ?? null });
409−
410− for (const entry of page.page) {
411− await ctx.db.delete(entry._id);
412− }
411+ .query("workspaceToolRegistryPayloads")
412+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
413+ .paginate({
414+ numItems: limit,
415+ cursor: args.cursor ?? null,
416+ });
413417
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+ };
415425 },
416426 });
417427
418−export const pruneBuilds = internalAction({
428+export const deleteToolRegistryNamespacesPage = internalMutation({
419429 args: {
420430 workspaceId: vv.id("workspaces"),
421− maxRetainedBuilds: v.optional(v.number()),
431+ cursor: v.optional(v.string()),
422432 },
423433 handler: async (ctx, args) => {
424− const maxRetainedBuilds = Math.max(1, Math.min(10, Math.floor(args.maxRetainedBuilds ?? 2)));
425− const state = await ctx.runQuery(internal.toolRegistry.getState, {
426− workspaceId: args.workspaceId,
427− });
428−
429− const protectedBuildIds = new Set<string>();
430− if (state?.readyBuildId) protectedBuildIds.add(state.readyBuildId);
431− if (state?.buildingBuildId) protectedBuildIds.add(state.buildingBuildId);
432−
433− const latestCreatedAtByBuild = new Map<string, number>();
434− let namespaceCursor: string | undefined = undefined;
435−
436− while (true) {
437− const namespacePage: {
438− continueCursor: string | null;
439− items: Array<{ buildId: string; createdAt: number }>;
440− } = await ctx.runQuery(internal.toolRegistry.scanNamespaceBuildsForPrune, {
441− workspaceId: args.workspaceId,
442− cursor: namespaceCursor,
443− });
444−
445− for (const item of namespacePage.items) {
446− const current = latestCreatedAtByBuild.get(item.buildId) ?? 0;
447− if (item.createdAt > current) {
448− latestCreatedAtByBuild.set(item.buildId, item.createdAt);
449− }
450− }
451−
452− if (namespacePage.continueCursor === null) {
453− break;
454− }
455−
456− namespaceCursor = namespacePage.continueCursor;
457− }
458−
459− const buildIdsByRecency = [...latestCreatedAtByBuild.entries()]
460− .sort((a, b) => b[1] - a[1])
461− .map(([buildId]) => buildId);
434+ const page = await ctx.db
435+ .query("workspaceToolNamespaces")
436+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
437+ .paginate({ numItems: 100, cursor: args.cursor ?? null });
462438
463− const retained = new Set<string>(buildIdsByRecency.slice(0, maxRetainedBuilds));
464− for (const protectedBuildId of protectedBuildIds) {
465− retained.add(protectedBuildId);
439+ for (const entry of page.page) {
440+ await ctx.db.delete(entry._id);
466441 }
467442
468− for (const buildId of buildIdsByRecency) {
469− if (retained.has(buildId)) {
470− continue;
471− }
472−
473− let cursor: string | undefined = undefined;
474− while (true) {
475− const page: { continueCursor: string | null } = await ctx.runMutation(
476− internal.toolRegistry.deleteToolRegistryToolsPage,
477− {
478− workspaceId: args.workspaceId,
479− buildId,
480− cursor,
481− },
482− );
483−
484− if (page.continueCursor === null) {
485− break;
486− }
487−
488− cursor = page.continueCursor;
489− }
490−
491− let payloadCursor: string | undefined = undefined;
492− while (true) {
493− const payloadPage: { continueCursor: string | null } = await ctx.runMutation(
494− internal.toolRegistry.deleteToolRegistryPayloadsPage,
495− {
496− workspaceId: args.workspaceId,
497− buildId,
498− cursor: payloadCursor,
499− },
500− );
501−
502− if (payloadPage.continueCursor === null) {
503− break;
504− }
505−
506− payloadCursor = payloadPage.continueCursor;
507− }
508−
509− let namespacePageCursor: string | undefined = undefined;
510− while (true) {
511− const namespaceDeletePage: { continueCursor: string | null } = await ctx.runMutation(
512− internal.toolRegistry.deleteToolRegistryNamespacesPage,
513− {
514− workspaceId: args.workspaceId,
515− buildId,
516− cursor: namespacePageCursor,
517− },
518− );
519−
520− if (namespaceDeletePage.continueCursor === null) {
521− break;
522− }
523−
524− namespacePageCursor = namespaceDeletePage.continueCursor;
525− }
526− }
443+ return { continueCursor: page.isDone ? null : page.continueCursor };
527444 },
528445 });
529446
530447 export const getToolByPath = internalQuery({
531448 args: {
532449 workspaceId: vv.id("workspaces"),
533− buildId: v.string(),
534450 path: v.string(),
535451 },
536452 handler: async (ctx, args) => {
537453 const entry = await ctx.db
538454 .query("workspaceToolRegistry")
539− .withIndex("by_workspace_build_path", (q) =>
540− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", args.path),
455+ .withIndex("by_workspace_path", (q) =>
456+ q.eq("workspaceId", args.workspaceId).eq("path", args.path),
541457 )
542458 .first();
543459
@@ -545,8 +461,8 @@ export const getToolByPath = internalQuery({ · 1 unmodified line
545461
546462 const payload = await ctx.db
547463 .query("workspaceToolRegistryPayloads")
548− .withIndex("by_workspace_build_path", (q) =>
549− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", entry.path),
464+ .withIndex("by_workspace_path", (q) =>
465+ q.eq("workspaceId", args.workspaceId).eq("path", entry.path),
550466 )
551467 .first();
552468
@@ -571,7 +487,6 @@ export const getToolByPath = internalQuery({ · 18 unmodified lines
571487 export const getSerializedToolsByPaths = internalQuery({
572488 args: {
573489 workspaceId: vv.id("workspaces"),
574− buildId: v.string(),
575490 paths: v.array(v.string()),
576491 },
577492 handler: async (ctx, args) => {
@@ -584,8 +499,8 @@ export const getSerializedToolsByPaths = internalQuery({ · 6 unmodified lines
584499 const payloads = await Promise.all(paths.map(async (path) => {
585500 const payload = await ctx.db
586501 .query("workspaceToolRegistryPayloads")
587− .withIndex("by_workspace_build_path", (q) =>
588− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", path),
502+ .withIndex("by_workspace_path", (q) =>
503+ q.eq("workspaceId", args.workspaceId).eq("path", path),
589504 )
590505 .first();
591506
@@ -603,7 +518,6 @@ export const getSerializedToolsByPaths = internalQuery({ · 11 unmodified lines
603518 export const listToolsByNamespace = internalQuery({
604519 args: {
605520 workspaceId: vv.id("workspaces"),
606− buildId: v.string(),
607521 namespace: v.string(),
608522 limit: v.number(),
609523 },
@@ -614,8 +528,8 @@ export const listToolsByNamespace = internalQuery({ · 4 unmodified lines
614528
615529 const entries = await ctx.db
616530 .query("workspaceToolRegistry")
617− .withIndex("by_workspace_build_namespace", (q) =>
618− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("namespace", namespace),
531+ .withIndex("by_workspace_namespace", (q) =>
532+ q.eq("workspaceId", args.workspaceId).eq("namespace", namespace),
619533 )
620534 .take(limit);
621535
@@ -638,7 +552,6 @@ export const listToolsByNamespace = internalQuery({ · 16 unmodified lines
638552 export const listToolsPage = internalQuery({
639553 args: {
640554 workspaceId: vv.id("workspaces"),
641− buildId: v.string(),
642555 cursor: v.optional(v.string()),
643556 limit: v.number(),
644557 },
@@ -646,9 +559,7 @@ export const listToolsPage = internalQuery({ · 1 unmodified line
646559 const limit = Math.max(1, Math.min(250, Math.floor(args.limit)));
647560 const page = await ctx.db
648561 .query("workspaceToolRegistry")
649− .withIndex("by_workspace_build", (q) =>
650− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId),
651− )
562+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
652563 .paginate({
653564 numItems: limit,
654565 cursor: args.cursor ?? null,
@@ -676,7 +587,6 @@ export const listToolsPage = internalQuery({ · 21 unmodified lines
676587 export const listToolsBySourcePage = internalQuery({
677588 args: {
678589 workspaceId: vv.id("workspaces"),
679− buildId: v.string(),
680590 source: v.string(),
681591 cursor: v.optional(v.string()),
682592 limit: v.number(),
@@ -709,8 +619,8 @@ export const listToolsBySourcePage = internalQuery({ · 26 unmodified lines
709619 const limit = Math.max(1, Math.min(250, Math.floor(args.limit)));
710620 const page = await ctx.db
711621 .query("workspaceToolRegistry")
712− .withIndex("by_workspace_build_source", (q) =>
713− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("source", source),
622+ .withIndex("by_workspace_source", (q) =>
623+ q.eq("workspaceId", args.workspaceId).eq("source", source),
714624 )
715625 .paginate({
716626 numItems: limit,
@@ -739,7 +649,6 @@ export const listToolsBySourcePage = internalQuery({ · 22 unmodified lines
739649 export const getToolsByNormalizedPath = internalQuery({
740650 args: {
741651 workspaceId: vv.id("workspaces"),
742− buildId: v.string(),
743652 normalizedPath: v.string(),
744653 limit: v.number(),
745654 },
@@ -750,16 +659,16 @@ export const getToolsByNormalizedPath = internalQuery({ · 4 unmodified lines
750659
751660 const entries = await ctx.db
752661 .query("workspaceToolRegistry")
753− .withIndex("by_workspace_build_normalized", (q) =>
754− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("normalizedPath", normalized),
662+ .withIndex("by_workspace_normalized", (q) =>
663+ q.eq("workspaceId", args.workspaceId).eq("normalizedPath", normalized),
755664 )
756665 .take(limit);
757666
758667 const payloads = await Promise.all(entries.map(async (entry) => {
759668 const payload = await ctx.db
760669 .query("workspaceToolRegistryPayloads")
761− .withIndex("by_workspace_build_path", (q) =>
762− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", entry.path),
670+ .withIndex("by_workspace_path", (q) =>
671+ q.eq("workspaceId", args.workspaceId).eq("path", entry.path),
763672 )
764673 .first();
765674 if (!payload) return null;
@@ -783,7 +692,6 @@ export const getToolsByNormalizedPath = internalQuery({ · 17 unmodified lines
783692 export const searchTools = internalQuery({
784693 args: {
785694 workspaceId: vv.id("workspaces"),
786− buildId: v.string(),
787695 query: v.string(),
788696 limit: v.number(),
789697 },
@@ -796,8 +704,7 @@ export const searchTools = internalQuery({ · 6 unmodified lines
796704 .query("workspaceToolRegistry")
797705 .withSearchIndex("search_text", (q) =>
798706 q.search("searchText", term)
799− .eq("workspaceId", args.workspaceId)
800− .eq("buildId", args.buildId),
707+ .eq("workspaceId", args.workspaceId),
801708 )
802709 .take(limit);
803710
@@ -820,16 +727,13 @@ export const searchTools = internalQuery({ · 16 unmodified lines
820727 export const listNamespaces = internalQuery({
821728 args: {
822729 workspaceId: vv.id("workspaces"),
823− buildId: v.string(),
824730 limit: v.number(),
825731 },
826732 handler: async (ctx, args) => {
827733 const limit = Math.max(1, Math.min(200, Math.floor(args.limit)));
828734 const entries = await ctx.db
829735 .query("workspaceToolNamespaces")
830− .withIndex("by_workspace_build", (q) =>
831− q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId),
832− )
736+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
833737 .take(limit);
834738
835739 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
108108 const remaining = maxDeletes - deleted;
109109 const docs = await ctx.db
110110 .query(table)
111− .withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId))
111+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
112112 .take(remaining);
113113 if (docs.length === 0) {
114114 return false;
@@ -149,9 +149,9 @@ async function deleteWorkspaceDataBatch( · 34 unmodified lines
149149 ctx.db.query("toolSources").withIndex("by_workspace_updated", (q) => q.eq("workspaceId", workspaceId)).first(),
150150 ctx.db.query("anonymousSessions").withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId)).first(),
151151 ctx.db.query("storageInstances").withIndex("by_workspace_updated", (q) => q.eq("workspaceId", workspaceId)).first(),
152− ctx.db.query("workspaceToolRegistry").withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId)).first(),
153− ctx.db.query("workspaceToolRegistryPayloads").withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId)).first(),
154− ctx.db.query("workspaceToolNamespaces").withIndex("by_workspace_build", (q) => q.eq("workspaceId", workspaceId)).first(),
152+ ctx.db.query("workspaceToolRegistry").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(),
153+ ctx.db.query("workspaceToolRegistryPayloads").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(),
154+ ctx.db.query("workspaceToolNamespaces").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(),
155155 ctx.db.query("workspaceToolRegistryState").withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)).first(),
156156 ]);
157157
executor/packages/database/src/auth/bootstrap.ts+66−18
@@ -7,6 +7,50 @@ import { claimAnonymousSessionToWorkosAccount } from "./account_links"; · 6 unmodified lines
77 import { getOrCreatePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace";
88 import type { AccountId } from "./types";
99
10+function readNonEmptyTrimmed(value: string | undefined): string | undefined {
11+ const normalized = value?.trim();
12+ return normalized && normalized.length > 0 ? normalized : undefined;
13+}
14+
15+function isGeneratedWorkosLabel(value: string, workosUserId: string): boolean {
16+ const normalizedValue = value.trim();
17+ if (!normalizedValue) {
18+ return false;
19+ }
20+
21+ const fallbackSuffix = workosUserId.trim().slice(-6);
22+ if (fallbackSuffix && normalizedValue.toLowerCase() === `user ${fallbackSuffix}`.toLowerCase()) {
23+ return true;
24+ }
25+
26+ return /^user\s+[a-z0-9]{6,}$/i.test(normalizedValue);
27+}
28+
29+function withProfileNameHint(
30+ identityProfile: ReturnType<typeof resolveIdentityProfile>,
31+ workosUserId: string,
32+ profileNameHint: string | undefined,
33+): ReturnType<typeof resolveIdentityProfile> {
34+ const profileName = readNonEmptyTrimmed(profileNameHint);
35+ if (!profileName) {
36+ return identityProfile;
37+ }
38+
39+ if (!isGeneratedWorkosLabel(identityProfile.fullName, workosUserId)) {
40+ return identityProfile;
41+ }
42+
43+ const [hintFirstName, ...hintRest] = profileName.split(/\s+/);
44+ const hintLastName = hintRest.length > 0 ? hintRest.join(" ") : undefined;
45+
46+ return {
47+ ...identityProfile,
48+ fullName: profileName,
49+ firstName: identityProfile.firstName ?? hintFirstName,
50+ lastName: identityProfile.lastName ?? hintLastName,
51+ };
52+}
53+
1054 async function seedHintedOrganizationMembership(
1155 ctx: MutationCtx,
1256 args: {
@@ -56,7 +100,7 @@ async function hasActiveWorkspaceAccess(ctx: MutationCtx, args: { accountId: Acc · 43 unmodified lines
56100
57101 export async function bootstrapCurrentWorkosAccountImpl(
58102 ctx: MutationCtx,
59− args?: { sessionId?: string },
103+ args?: { sessionId?: string; profileName?: string },
60104 ) {
61105 const identity = await ctx.auth.getUserIdentity();
62106 if (!identity) return null;
@@ -64,18 +108,22 @@ export async function bootstrapCurrentWorkosAccountImpl( · 1 unmodified line
64108 const now = Date.now();
65109 const subject = identity.subject;
66110 const authKitProfile = await getAuthKitUserProfile(ctx, subject);
67− const identityProfile = resolveIdentityProfile({
68− identity: { ...identity, subject },
69− authKitProfile,
70− });
111+ const resolvedIdentityProfile = withProfileNameHint(
112+ resolveIdentityProfile({
113+ identity: { ...identity, subject },
114+ authKitProfile,
115+ }),
116+ subject,
117+ args?.profileName,
118+ );
71119
72120 const account = await upsertWorkosAccount(ctx, {
73121 workosUserId: subject,
74− email: identityProfile.email,
75− fullName: identityProfile.fullName,
76− firstName: identityProfile.firstName,
77− lastName: identityProfile.lastName,
78− avatarUrl: identityProfile.avatarUrl,
122+ email: resolvedIdentityProfile.email,
123+ fullName: resolvedIdentityProfile.fullName,
124+ firstName: resolvedIdentityProfile.firstName,
125+ lastName: resolvedIdentityProfile.lastName,
126+ avatarUrl: resolvedIdentityProfile.avatarUrl,
79127 now,
80128 includeLastLoginAt: true,
81129 });
@@ -88,17 +136,17 @@ export async function bootstrapCurrentWorkosAccountImpl( · 6 unmodified lines
88136 });
89137
90138 await refreshGeneratedPersonalWorkspaceNames(ctx, account._id, {
91− email: identityProfile.email,
92− firstName: identityProfile.firstName,
93− fullName: identityProfile.fullName,
139+ email: resolvedIdentityProfile.email,
140+ firstName: resolvedIdentityProfile.firstName,
141+ fullName: resolvedIdentityProfile.fullName,
94142 workosUserId: subject,
95143 now,
96144 });
97145
98146 await seedHintedOrganizationMembership(ctx, {
99147 accountId: account._id,
100− hintedWorkosOrgId: identityProfile.hintedWorkosOrgId,
101− email: identityProfile.email,
148+ hintedWorkosOrgId: resolvedIdentityProfile.hintedWorkosOrgId,
149+ email: resolvedIdentityProfile.email,
102150 now,
103151 });
104152
@@ -108,9 +156,9 @@ export async function bootstrapCurrentWorkosAccountImpl( · 3 unmodified lines
108156
109157 if (!hasWorkspaceMembership) {
110158 await getOrCreatePersonalWorkspace(ctx, account._id, {
111− email: identityProfile.email,
112− firstName: identityProfile.firstName,
113− fullName: identityProfile.fullName,
159+ email: resolvedIdentityProfile.email,
160+ firstName: resolvedIdentityProfile.firstName,
161+ fullName: resolvedIdentityProfile.fullName,
114162 workosUserId: subject,
115163 now,
116164 });
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
66 import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
77 import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";
88 import { getDecisionForContext, getToolDecision } from "./policy";
9−import { getReadyRegistryBuildIdResult } from "./tool_registry_state";
9+import { getRegistryReadyResult } from "./tool_registry_state";
1010 import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
1111 import { baseTools } from "./base_tools";
1212
@@ -43,7 +43,7 @@ function getGraphqlQueryFromInput(input: unknown): string { · 30 unmodified lines
4343
4444 async function searchRegistryEntries(
4545 ctx: ActionCtx,
46− args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
46+ args: { workspaceId: TaskRecord["workspaceId"]; query: string; limit: number },
4747 ): Promise<RegistrySearchEntry[]> {
4848 const entries = await ctx.runQuery(internal.toolRegistry.searchTools, args);
4949 const parsed = z.array(registrySearchEntrySchema).safeParse(entries);
@@ -52,7 +52,7 @@ async function searchRegistryEntries( · 2 unmodified lines
5252
5353 async function getRegistryToolByPath(
5454 ctx: ActionCtx,
55− args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; path: string },
55+ args: { workspaceId: TaskRecord["workspaceId"]; path: string },
5656 ): Promise<RegistrySerializedToolEntry | null> {
5757 const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, args);
5858 const parsed = registrySerializedToolEntrySchema.safeParse(entry);
@@ -63,7 +63,6 @@ async function getRegistryToolsByNormalizedPath( · 4 unmodified lines
6363 ctx: ActionCtx,
6464 args: {
6565 workspaceId: TaskRecord["workspaceId"];
66− buildId: string;
6766 normalizedPath: string;
6867 limit: number;
6968 },
@@ -139,13 +138,11 @@ export function getGraphqlDecision( · 69 unmodified lines
139138 async function suggestFromRegistry(
140139 ctx: ActionCtx,
141140 workspaceId: TaskRecord["workspaceId"],
142− buildId: string,
143141 toolPath: string,
144142 ): Promise<string[]> {
145143 const term = toolPath.split(".").filter(Boolean).join(" ");
146144 const hits = await searchRegistryEntries(ctx, {
147145 workspaceId,
148− buildId,
149146 query: term,
150147 limit: 3,
151148 });
@@ -185,20 +182,15 @@ export async function resolveToolForCall( · 33 unmodified lines
185182 return Result.ok({ kind: "builtin", tool: builtin, resolvedToolPath: toolPath });
186183 }
187184
188− const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
185+ const readyResult = await getRegistryReadyResult(ctx, {
189186 workspaceId: task.workspaceId,
190− accountId: task.accountId,
191− clientId: task.clientId,
192187 });
193− if (buildIdResult.isErr()) {
194− return Result.err(buildIdResult.error);
188+ if (readyResult.isErr()) {
189+ return Result.err(readyResult.error);
195190 }
196− const buildId = buildIdResult.value;
197−
198191 let resolvedToolPath = toolPath;
199192 let entry = await getRegistryToolByPath(ctx, {
200193 workspaceId: task.workspaceId,
201− buildId,
202194 path: toolPath,
203195 });
204196
@@ -206,7 +198,6 @@ export async function resolveToolForCall( · 1 unmodified line
206198 const normalized = normalizeToolPathForLookup(toolPath);
207199 const hits = await getRegistryToolsByNormalizedPath(ctx, {
208200 workspaceId: task.workspaceId,
209− buildId,
210201 normalizedPath: normalized,
211202 limit: 5,
212203 });
@@ -224,7 +215,7 @@ export async function resolveToolForCall( · 11 unmodified lines
224215 }
225216
226217 if (!entry) {
227− const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath);
218+ const suggestions = await suggestFromRegistry(ctx, task.workspaceId, toolPath);
228219 return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions)));
229220 }
230221
executor/packages/database/src/runtime/tool_invocation.ts+8−20
@@ -27,7 +27,7 @@ import { publishTaskEvent } from "./events"; · 26 unmodified lines
2727 import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
2828 import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials";
2929 import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
30−import { getReadyRegistryBuildIdResult } from "./tool_registry_state";
30+import { getRegistryReadyResult } from "./tool_registry_state";
3131 import {
3232 catalogNamespaceSchema,
3333 catalogNamespacesInputSchema,
@@ -214,7 +214,7 @@ async function listWorkspaceToolPolicies( · 180 unmodified lines
214214
215215 async function listRegistryNamespaces(
216216 ctx: ActionCtx,
217− args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; limit: number },
217+ args: { workspaceId: TaskRecord["workspaceId"]; limit: number },
218218 ): Promise<CatalogNamespace[]> {
219219 const namespaces = await ctx.runQuery(internal.toolRegistry.listNamespaces, args);
220220 const parsed = z.array(catalogNamespaceSchema).safeParse(namespaces);
@@ -272,7 +272,6 @@ async function searchRegistryTools( · 51 unmodified lines
272272 ctx: ActionCtx,
273273 args: {
274274 workspaceId: TaskRecord["workspaceId"];
275− buildId: string;
276275 query: string;
277276 limit: number;
278277 includeSchemas?: boolean;
@@ -280,7 +279,6 @@ async function searchRegistryTools( · 1 unmodified line
280279 ): Promise<RegistryToolEntry[]> {
281280 const entries = await ctx.runQuery(internal.toolRegistry.searchTools, {
282281 workspaceId: args.workspaceId,
283− buildId: args.buildId,
284282 query: args.query,
285283 limit: args.limit,
286284 });
@@ -290,7 +288,6 @@ async function searchRegistryTools( · 3 unmodified lines
290288
291289 const payloads = await ctx.runQuery(internal.toolRegistry.getSerializedToolsByPaths, {
292290 workspaceId: args.workspaceId,
293− buildId: args.buildId,
294291 paths: parsed.data.map((entry) => entry.path),
295292 });
296293 const parsedPayloads = z.array(registryToolPayloadEntrySchema).safeParse(payloads);
@@ -310,7 +307,6 @@ async function listRegistryToolsByNamespace( · 13 unmodified lines
310307 ctx: ActionCtx,
311308 args: {
312309 workspaceId: TaskRecord["workspaceId"];
313− buildId: string;
314310 namespace: string;
315311 limit: number;
316312 includeSchemas?: boolean;
@@ -318,7 +314,6 @@ async function listRegistryToolsByNamespace( · 1 unmodified line
318314 ): Promise<RegistryToolEntry[]> {
319315 const entries = await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, {
320316 workspaceId: args.workspaceId,
321− buildId: args.buildId,
322317 namespace: args.namespace,
323318 limit: args.limit,
324319 });
@@ -328,7 +323,6 @@ async function listRegistryToolsByNamespace( · 3 unmodified lines
328323
329324 const payloads = await ctx.runQuery(internal.toolRegistry.getSerializedToolsByPaths, {
330325 workspaceId: args.workspaceId,
331− buildId: args.buildId,
332326 paths: parsed.data.map((entry) => entry.path),
333327 });
334328 const parsedPayloads = z.array(registryToolPayloadEntrySchema).safeParse(payloads);
@@ -539,13 +533,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 204 unmodified lines
539533 return output;
540534 }
541535
542− const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
536+ const readyResult = await getRegistryReadyResult(ctx, {
543537 workspaceId: task.workspaceId,
544− accountId: task.accountId,
545− clientId: task.clientId,
546538 });
547− const buildId = buildIdResult.isErr() ? undefined : buildIdResult.value;
548− const state = buildId
539+ const registryReady = readyResult.isOk();
540+ const state = registryReady
549541 ? await ctx.runQuery(internal.toolRegistry.getState, {
550542 workspaceId: task.workspaceId,
551543 }) as unknown as { openApiRefHintTables?: unknown } | null
@@ -579,10 +571,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 27 unmodified lines
579571 const parsedInput = catalogNamespacesInputSchema.safeParse(payload);
580572 const limitInput = parsedInput.success ? parsedInput.data.limit : undefined;
581573 const limit = Math.max(1, Math.min(200, Number(limitInput ?? 200)));
582− const registryNamespaces = buildId
574+ const registryNamespaces = registryReady
583575 ? await listRegistryNamespaces(ctx, {
584576 workspaceId: task.workspaceId,
585− buildId,
586577 limit: 200,
587578 })
588579 : [];
@@ -633,12 +624,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 44 unmodified lines
633624 const limit = Math.max(1, Math.min(200, Number(limitInput ?? 50)));
634625 const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false;
635626
636− const raw = !buildId
627+ const raw = !registryReady
637628 ? []
638629 : query
639630 ? await searchRegistryTools(ctx, {
640631 workspaceId: task.workspaceId,
641− buildId,
642632 query,
643633 limit,
644634 includeSchemas,
@@ -646,7 +636,6 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 1 unmodified line
646636 : namespace
647637 ? await listRegistryToolsByNamespace(ctx, {
648638 workspaceId: task.workspaceId,
649− buildId,
650639 namespace,
651640 limit,
652641 includeSchemas,
@@ -739,10 +728,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 86 unmodified lines
739728 const limit = Math.max(1, Math.min(50, Number(limitInput ?? 8)));
740729 const compact = parsedInput.success ? (parsedInput.data.compact ?? true) : true;
741730 const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false;
742− const hits = buildId
731+ const hits = registryReady
743732 ? await searchRegistryTools(ctx, {
744733 workspaceId: task.workspaceId,
745− buildId,
746734 query,
747735 limit: Math.max(limit * 2, limit),
748736 includeSchemas,
executor/packages/database/src/runtime/tool_registry_state.ts+39−30
@@ -17,8 +17,17 @@ export function registrySignatureForWorkspace( · 16 unmodified lines
1717
1818 const registryStateSchema = z.object({
1919 signature: z.string().optional(),
20− readyBuildId: z.string().optional(),
21− buildingBuildId: z.string().optional(),
20+ sourceStates: z.array(z.object({
21+ sourceId: z.string(),
22+ signature: z.string(),
23+ state: z.union([
24+ z.literal("queued"),
25+ z.literal("loading"),
26+ z.literal("indexing"),
27+ z.literal("ready"),
28+ z.literal("failed"),
29+ ]),
30+ })).optional(),
2231 });
2332
2433 type RegistryState = z.infer<typeof registryStateSchema> | null;
@@ -70,7 +79,7 @@ function toToolSourceStateList(value: unknown): ToolSourceState[] { · 45 unmodified lines
7079 async function readRegistryState(
7180 ctx: Pick<ActionCtx, "runQuery">,
7281 workspaceId: Id<"workspaces">,
73−): Promise<{ buildId?: string; isReady: boolean }> {
82+): Promise<{ isReady: boolean }> {
7483 const [rawState, rawSources] = await Promise.all([
7584 ctx.runQuery(internal.toolRegistry.getState, { workspaceId }),
7685 ctx.runQuery(internal.database.listToolSources, { workspaceId }),
@@ -79,44 +88,44 @@ async function readRegistryState( · 2 unmodified lines
7988 const sources = toToolSourceStateList(rawSources);
8089
8190 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+ });
83111
84112 return {
85− buildId,
86− isReady: Boolean(buildId && !state?.buildingBuildId && state?.signature === expectedSignature),
113+ isReady: Boolean(state?.signature === expectedSignature && !hasPendingOrMismatchedSource),
87114 };
88115 }
89116
90−export async function getReadyRegistryBuildIdResult(
91− ctx: Pick<ActionCtx, "runQuery" | "runAction">,
117+export async function getRegistryReadyResult(
118+ ctx: Pick<ActionCtx, "runQuery">,
92119 args: {
93120 workspaceId: Id<"workspaces">;
94− accountId?: Id<"accounts">;
95− clientId?: string;
96121 },
97−): Promise<Result<string, Error>> {
122+): Promise<Result<void, Error>> {
98123 const initial = await readRegistryState(ctx, args.workspaceId);
99− if (initial.isReady && initial.buildId) {
100− return Result.ok(initial.buildId);
124+ if (initial.isReady) {
125+ return Result.ok(undefined);
101126 }
102127
103128 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."),
105130 );
106131 }
107−
108−export async function getReadyRegistryBuildId(
109− ctx: Pick<ActionCtx, "runQuery" | "runAction">,
110− args: {
111− workspaceId: Id<"workspaces">;
112− accountId?: Id<"accounts">;
113− clientId?: string;
114− },
115−): Promise<string> {
116− const buildIdResult = await getReadyRegistryBuildIdResult(ctx, args);
117− if (buildIdResult.isErr()) {
118− throw buildIdResult.error;
119− }
120−
121− return buildIdResult.value;
122−}
executor/packages/database/src/runtime/tool_source_loading.ts+31−6
@@ -28,8 +28,10 @@ import { · 27 unmodified lines
2828 } from "./tool_source_artifact";
2929
3030 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
31+const OPENAPI_PREPARE_MAX_ATTEMPTS = 3;
32+const OPENAPI_PREPARE_RETRY_BASE_DELAY_MS = 1_500;
3133
32−const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v8";
34+const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v9";
3335
3436 const openApiAuthModeSchema = z.enum(["account", "workspace", "organization"]);
3537
@@ -329,11 +331,34 @@ async function loadCachedOpenApiSpec( · 293 unmodified lines
329331 console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`);
330332 }
331333
332− const preparedResponse = await ctx.runAction(internal.runtimeNode.prepareOpenApiSpec, {
333− specUrl,
334− sourceName,
335− includeDts,
336− });
334+ let preparedResponse: unknown = undefined;
335+ let lastPrepareError: string | undefined;
336+ for (let attempt = 1; attempt <= OPENAPI_PREPARE_MAX_ATTEMPTS; attempt += 1) {
337+ try {
338+ preparedResponse = await ctx.runAction(internal.runtimeNode.prepareOpenApiSpec, {
339+ specUrl,
340+ sourceName,
341+ includeDts,
342+ });
343+ lastPrepareError = undefined;
344+ break;
345+ } catch (error) {
346+ const message = error instanceof Error ? error.message : String(error);
347+ lastPrepareError = message;
348+ if (attempt >= OPENAPI_PREPARE_MAX_ATTEMPTS) {
349+ break;
350+ }
351+
352+ const delayMs = OPENAPI_PREPARE_RETRY_BASE_DELAY_MS * attempt;
353+ await new Promise((resolve) => setTimeout(resolve, delayMs));
354+ }
355+ }
356+
357+ if (lastPrepareError) {
358+ throw new Error(
359+ `Failed to prepare OpenAPI source '${sourceName}' after ${OPENAPI_PREPARE_MAX_ATTEMPTS} attempts: ${lastPrepareError}`,
360+ );
361+ }
337362
338363 let preparedPayload: unknown;
339364 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
3737 async function listCurrentSourceToolMap(
3838 ctx: ActionCtx,
3939 workspaceId: Id<"workspaces">,
40− buildId: string,
4140 sourceLabel: string,
4241 ): Promise<Map<string, string>> {
4342 const tools = new Map<string, string>();
@@ -49,7 +48,6 @@ async function listCurrentSourceToolMap( · 5 unmodified lines
4948 items: Array<{ path: string }>;
5049 } = await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, {
5150 workspaceId,
52− buildId,
5351 source: sourceLabel,
5452 cursor,
5553 limit: 500,
@@ -59,7 +57,6 @@ async function listCurrentSourceToolMap( · 3 unmodified lines
5957 internal.toolRegistry.getSerializedToolsByPaths,
6058 {
6159 workspaceId,
62− buildId,
6360 paths: page.items.map((item) => item.path),
6461 },
6562 );
@@ -134,12 +131,11 @@ export async function previewOpenApiSourceUpgradeForContext( · 68 unmodified lines
134131 proposedByPath.set(tool.path, JSON.stringify(tool));
135132 }
136133
137− const state = await ctx.runQuery(internal.toolRegistry.getState, {
138− workspaceId: context.workspaceId,
139− });
140− const currentByPath = state?.readyBuildId
141− ? await listCurrentSourceToolMap(ctx, context.workspaceId, state.readyBuildId, `openapi:${existing.name}`)
142− : new Map<string, string>();
134+ const currentByPath = await listCurrentSourceToolMap(
135+ ctx,
136+ context.workspaceId,
137+ `openapi:${existing.name}`,
138+ );
143139
144140 const added: string[] = [];
145141 const removed: string[] = [];
executor/packages/database/src/runtime/workspace_tools.ts+750−466
@@ -12,10 +12,9 @@ import { · 11 unmodified lines
1212 } from "../../../core/src/type-hints";
1313 import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils";
1414 import {
15− materializeCompiledToolSource,
1615 type CompiledToolSourceArtifact,
1716 } from "./tool_source_artifact";
18−import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";
17+import { parseSerializedTool, rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";
1918 import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types";
2019 import type {
2120 ToolPolicyRecord,
@@ -75,35 +74,17 @@ async function listWorkspaceToolPolicies( · 53 unmodified lines
7574 return policies;
7675 }
7776
78−interface WorkspaceToolsResult {
79− tools: Map<string, ToolDefinition>;
80− warnings: string[];
81− typesStorageId?: Id<"_storage">;
82− timedOutSources: string[];
83−}
84−
85−export type ToolInventoryState = "initializing" | "ready" | "rebuilding" | "stale" | "failed";
77+export type ToolInventoryState = "initializing" | "ready" | "rebuilding" | "failed";
8678
8779 export interface ToolInventoryStatus {
8880 state: ToolInventoryState;
89− readyBuildId?: string;
90− buildingBuildId?: string;
9181 readyToolCount: number;
9282 loadingSourceNames: string[];
9383 sourceToolCounts: Record<string, number>;
94− lastBuildStartedAt?: number;
95− lastBuildCompletedAt?: number;
96− lastBuildFailedAt?: number;
9784 error?: string;
9885 updatedAt?: number;
9986 }
10087
101−interface GetWorkspaceToolsOptions {
102− sourceTimeoutMs?: number;
103− accountId?: Id<"accounts">;
104− includeDts?: boolean;
105−}
106−
10788 interface WorkspaceToolInventory {
10889 tools: ToolDescriptor[];
10990 warnings: string[];
@@ -119,10 +100,10 @@ export type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | · 9 unmodified lines
119100
120101 const MAX_TOOLS_IN_ACTION_RESULT = 8_000;
121102 const MAX_TOOL_DETAILS_LOOKUP_PATHS = 100;
122−const REGISTRY_BUILD_STALE_MS = 2 * 60_000;
123103 const MAX_REF_HINTS_PER_SOURCE = 300;
124104 const MAX_REF_HINT_HINT_CHARS = 240;
125105 const MAX_REF_HINT_METADATA_BYTES = 220_000;
106+const SOURCE_LOAD_HEARTBEAT_MS = 5_000;
126107
127108 function truncateToolsForActionResult(
128109 tools: ToolDescriptor[],
@@ -355,20 +336,20 @@ function toDescriptorFromRegistryEntry( · 226 unmodified lines
355336 source: entry.source,
356337 ...(includeDetails
357338 ? {
358− typing: {
359− requiredInputKeys: entry.requiredInputKeys,
360− previewInputKeys: entry.previewInputKeys,
361− ...(inputSchemaJson ? { inputSchemaJson } : {}),
362− ...(outputSchemaJson ? { outputSchemaJson } : {}),
363− ...(refHintResolution.refHintKeys.length > 0 ? { refHintKeys: refHintResolution.refHintKeys } : {}),
364− ...(Object.keys(refHintResolution.refHints).length > 0 ? { refHints: refHintResolution.refHints } : {}),
365− typedRef: entry.typedRef,
366− },
367− display: {
368− input: resolvedDisplayInput,
369− output: resolvedDisplayOutput,
370− },
371− }
339+ typing: {
340+ requiredInputKeys: entry.requiredInputKeys,
341+ previewInputKeys: entry.previewInputKeys,
342+ ...(inputSchemaJson ? { inputSchemaJson } : {}),
343+ ...(outputSchemaJson ? { outputSchemaJson } : {}),
344+ ...(refHintResolution.refHintKeys.length > 0 ? { refHintKeys: refHintResolution.refHintKeys } : {}),
345+ ...(Object.keys(refHintResolution.refHints).length > 0 ? { refHints: refHintResolution.refHints } : {}),
346+ typedRef: entry.typedRef,
347+ },
348+ display: {
349+ input: resolvedDisplayInput,
350+ output: resolvedDisplayOutput,
351+ },
352+ }
372353 : {}),
373354 };
374355 }
@@ -626,299 +607,625 @@ function getPathAliases(path: string): string[] { · 251 unmodified lines
626607 return [...aliases].slice(0, 4);
627608 }
628609
610+type RegistryWriteEntry = {
611+ path: string;
612+ preferredPath: string;
613+ namespace: string;
614+ normalizedPath: string;
615+ aliases: string[];
616+ description: string;
617+ approval: "auto" | "required";
618+ source?: string;
619+ searchText: string;
620+ displayInput?: string;
621+ displayOutput?: string;
622+ requiredInputKeys?: string[];
623+ previewInputKeys?: string[];
624+ typedRef?: {
625+ kind: "openapi_operation";
626+ sourceKey: string;
627+ operationId: string;
628+ };
629+ serializedToolJson: string;
630+};
631+
632+type MaterializedSourceStateRecord = {
633+ sourceId: string;
634+ sourceName: string;
635+ sourceKey: string;
636+ signature: string;
637+ state: "queued" | "loading" | "indexing" | "ready" | "failed";
638+ toolCount: number;
639+ processedTools?: number;
640+ message?: string;
641+ error?: string;
642+ startedAt?: number;
643+ completedAt?: number;
644+ updatedAt: number;
645+ sourceQuality?: OpenApiSourceQuality;
646+ openApiRefHints?: Array<{ key: string; hint: string }>;
647+};
648+
649+function sourceStateSignature(workspaceId: Id<"workspaces">, source: ToolSourceRecord): string {
650+ return registrySignatureForWorkspace(workspaceId, [{
651+ id: source.id,
652+ updatedAt: source.updatedAt,
653+ enabled: source.enabled,
654+ }]);
655+}
629656
630−async function buildWorkspaceToolRegistry(
631− ctx: ActionCtx,
632− args: {
633− workspaceId: Id<"workspaces">;
634− registrySignature: string;
635− serializedTools: SerializedTool[];
636− },
637−): Promise<{ buildId: string }> {
638− const buildId = `toolreg_${crypto.randomUUID()}`;
639− await ctx.runMutation(internal.toolRegistry.beginBuild, {
640− workspaceId: args.workspaceId,
641− signature: args.registrySignature,
642− buildId,
643− });
657+function sourceStateSourceKey(source: Pick<ToolSourceRecord, "type" | "name">): string {
658+ return `${source.type}:${source.name}`;
659+}
644660
645− try {
646− const entries = args.serializedTools.map((st) => {
647− if (st.path === "discover" || st.path.startsWith("catalog.")) {
648− return null;
661+function sourceLoadingMessage(source: ExternalToolSourceConfig, elapsedSeconds: number): string {
662+ if (source.type === "openapi") {
663+ let hostSuffix = "";
664+ if (typeof source.spec === "string") {
665+ try {
666+ const host = new URL(source.spec).host;
667+ if (host) {
668+ hostSuffix = ` from ${host}`;
669+ }
670+ } catch {
671+ hostSuffix = "";
649672 }
650− const preferredPath = preferredToolPath(st.path);
651− const aliases = getPathAliases(st.path);
652− const namespace = (preferredPath.split(".")[0] ?? "default").toLowerCase();
653− const normalizedPath = normalizeToolPathForLookup(st.path);
654− const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase();
655−
656− const inputSchema = toJsonSchema(st.typing?.inputSchema);
657− const outputSchema = toJsonSchema(st.typing?.outputSchema);
658− const parsedTyping = toolHintSchema.safeParse(st.typing);
659− const typing = parsedTyping.success ? parsedTyping.data : {};
660−
661− const requiredInputKeys = typing.requiredInputKeys ?? extractTopLevelRequiredKeys(inputSchema);
662− const previewInputKeys = typing.previewInputKeys ?? buildPreviewKeys(inputSchema);
663− const inputHint = typing.inputHint?.trim();
664− const outputHint = typing.outputHint?.trim();
665− const hasInputSchema = Object.keys(inputSchema).length > 0;
666− const hasOutputSchema = Object.keys(outputSchema).length > 0;
667− const useInputHint = Boolean(inputHint && (!isLossyTypeHint(inputHint) || !hasInputSchema));
668− const useOutputHint = Boolean(outputHint && (!isLossyTypeHint(outputHint) || !hasOutputSchema));
673+ }
669674
670− const displayInput = useInputHint && inputHint
671− ? displayArgTypeHint(inputHint)
672− : compactArgTypeHintFromSchema(inputSchema);
675+ if (elapsedSeconds < 10) {
676+ return `Downloading OpenAPI document${hostSuffix}`;
677+ }
678+ if (elapsedSeconds < 40) {
679+ return `Parsing and resolving $ref links${hostSuffix}`;
680+ }
681+ return `Resolving a large OpenAPI document${hostSuffix} (can take 1-2m)`;
682+ }
673683
674− const displayOutput = useOutputHint && outputHint
675− ? displayReturnTypeHint(outputHint)
676− : compactReturnTypeHintFromSchema(outputSchema);
684+ if (source.type === "graphql") {
685+ return elapsedSeconds < 12
686+ ? "Loading GraphQL schema"
687+ : "Building GraphQL operations";
688+ }
677689
678− const typedRef = st.typing?.typedRef && st.typing.typedRef.kind === "openapi_operation"
679− ? {
680− kind: "openapi_operation" as const,
681− sourceKey: st.typing.typedRef.sourceKey,
682− operationId: st.typing.typedRef.operationId,
683− }
684− : undefined;
690+ return elapsedSeconds < 12
691+ ? "Connecting to MCP source"
692+ : "Discovering MCP tools";
693+}
685694
686− return {
687− path: st.path,
688− preferredPath,
689− namespace,
690− normalizedPath,
691− aliases,
692− description: st.description,
693− approval: st.approval,
694− source: st.source,
695− searchText,
696− displayInput,
697− displayOutput,
698− requiredInputKeys,
699− previewInputKeys,
700− typedRef,
701− serializedToolJson: JSON.stringify(st),
702− };
703− });
695+function sleep(ms: number): Promise<void> {
696+ return new Promise((resolve) => {
697+ setTimeout(resolve, ms);
698+ });
699+}
704700
705− const filteredEntries = entries.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
701+function registryWriteEntriesFromSerializedTools(serializedTools: SerializedTool[]): RegistryWriteEntry[] {
702+ const entries: RegistryWriteEntry[] = [];
706703
707− const namespaceMap = new Map<string, { toolCount: number; samplePaths: string[] }>();
708− for (const entry of filteredEntries) {
709− const current = namespaceMap.get(entry.namespace) ?? { toolCount: 0, samplePaths: [] };
710− current.toolCount += 1;
711− if (current.samplePaths.length < 6) {
712− current.samplePaths.push(entry.preferredPath);
704+ for (const st of serializedTools) {
705+ if (st.path === "discover" || st.path.startsWith("catalog.")) {
706+ continue;
707+ }
708+ const preferredPath = preferredToolPath(st.path);
709+ const aliases = getPathAliases(st.path);
710+ const namespace = (preferredPath.split(".")[0] ?? "default").toLowerCase();
711+ const normalizedPath = normalizeToolPathForLookup(st.path);
712+ const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase();
713+
714+ const inputSchema = toJsonSchema(st.typing?.inputSchema);
715+ const outputSchema = toJsonSchema(st.typing?.outputSchema);
716+ const parsedTyping = toolHintSchema.safeParse(st.typing);
717+ const typing = parsedTyping.success ? parsedTyping.data : {};
718+
719+ const requiredInputKeys = typing.requiredInputKeys ?? extractTopLevelRequiredKeys(inputSchema);
720+ const previewInputKeys = typing.previewInputKeys ?? buildPreviewKeys(inputSchema);
721+ const inputHint = typing.inputHint?.trim();
722+ const outputHint = typing.outputHint?.trim();
723+ const hasInputSchema = Object.keys(inputSchema).length > 0;
724+ const hasOutputSchema = Object.keys(outputSchema).length > 0;
725+ const useInputHint = Boolean(inputHint && (!isLossyTypeHint(inputHint) || !hasInputSchema));
726+ const useOutputHint = Boolean(outputHint && (!isLossyTypeHint(outputHint) || !hasOutputSchema));
727+
728+ const displayInput = useInputHint && inputHint
729+ ? displayArgTypeHint(inputHint)
730+ : compactArgTypeHintFromSchema(inputSchema);
731+
732+ const displayOutput = useOutputHint && outputHint
733+ ? displayReturnTypeHint(outputHint)
734+ : compactReturnTypeHintFromSchema(outputSchema);
735+
736+ const typedRef = st.typing?.typedRef && st.typing.typedRef.kind === "openapi_operation"
737+ ? {
738+ kind: "openapi_operation" as const,
739+ sourceKey: st.typing.typedRef.sourceKey,
740+ operationId: st.typing.typedRef.operationId,
713741 }
714− namespaceMap.set(entry.namespace, current);
742+ : undefined;
743+
744+ entries.push({
745+ path: st.path,
746+ preferredPath,
747+ namespace,
748+ normalizedPath,
749+ aliases,
750+ description: st.description,
751+ approval: st.approval,
752+ source: st.source,
753+ searchText,
754+ displayInput,
755+ displayOutput,
756+ requiredInputKeys,
757+ previewInputKeys,
758+ typedRef,
759+ serializedToolJson: JSON.stringify(st),
760+ });
761+ }
762+
763+ return entries;
764+}
765+
766+
767+function toSourceStateRows(
768+ items: Iterable<MaterializedSourceStateRecord>,
769+): MaterializedSourceStateRecord[] {
770+ return [...items]
771+ .sort((left, right) => left.sourceName.localeCompare(right.sourceName))
772+ .map((item) => ({ ...item }));
773+}
774+
775+async function persistSourceStates(
776+ ctx: ActionCtx,
777+ workspaceId: Id<"workspaces">,
778+ sourceStates: Iterable<MaterializedSourceStateRecord>,
779+ signature?: string,
780+): Promise<void> {
781+ await ctx.runMutation(internal.toolRegistry.setSourceStates, {
782+ workspaceId,
783+ sourceStates: toSourceStateRows(sourceStates),
784+ signature,
785+ });
786+}
787+
788+async function listAllRegistryEntries(
789+ ctx: ActionCtx,
790+ workspaceId: Id<"workspaces">,
791+): Promise<RegistryToolEntry[]> {
792+ const entries: RegistryToolEntry[] = [];
793+ let cursor: string | undefined;
794+
795+ while (true) {
796+ const page: {
797+ continueCursor: string | null;
798+ items: RegistryToolEntry[];
799+ } = await ctx.runQuery(internal.toolRegistry.listToolsPage, {
800+ workspaceId,
801+ cursor,
802+ limit: 250,
803+ });
804+
805+ entries.push(...page.items);
806+ if (page.continueCursor === null) {
807+ break;
715808 }
809+ cursor = page.continueCursor;
810+ }
716811
717− const namespaces = [...namespaceMap.entries()]
718− .map(([namespace, meta]) => ({
719− namespace,
720− toolCount: meta.toolCount,
721− samplePaths: [...meta.samplePaths].sort((a, b) => a.localeCompare(b)).slice(0, 3),
722− }))
723− .sort((a, b) => a.namespace.localeCompare(b.namespace));
812+ return entries;
813+}
724814
725− const TOOL_BATCH = 100;
726− for (let i = 0; i < filteredEntries.length; i += TOOL_BATCH) {
727− await ctx.runMutation(internal.toolRegistry.putToolsBatch, {
728− workspaceId: args.workspaceId,
729− buildId,
730− tools: filteredEntries.slice(i, i + TOOL_BATCH),
731− });
815+async function listAllSerializedTools(
816+ ctx: ActionCtx,
817+ workspaceId: Id<"workspaces">,
818+): Promise<SerializedTool[]> {
819+ const serializedTools: SerializedTool[] = [];
820+ let cursor: string | undefined;
821+
822+ while (true) {
823+ const page: {
824+ continueCursor: string | null;
825+ items: Array<{ path: string; serializedToolJson: string }>;
826+ } = await ctx.runQuery(internal.toolRegistry.listSerializedToolsPage, {
827+ workspaceId,
828+ cursor,
829+ limit: 250,
830+ });
831+
832+ for (const item of page.items) {
833+ try {
834+ const parsed = JSON.parse(item.serializedToolJson);
835+ const parsedTool = parseSerializedTool(parsed);
836+ if (parsedTool.isOk()) {
837+ serializedTools.push(parsedTool.value);
838+ }
839+ } catch {
840+ // Ignore malformed payloads; they are surfaced as warnings elsewhere.
841+ }
732842 }
733843
734− const NS_BATCH = 100;
735− for (let i = 0; i < namespaces.length; i += NS_BATCH) {
736− await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, {
737− workspaceId: args.workspaceId,
738− buildId,
739− namespaces: namespaces.slice(i, i + NS_BATCH),
740− });
844+ if (page.continueCursor === null) {
845+ break;
741846 }
847+ cursor = page.continueCursor;
848+ }
742849
743− await ctx.runMutation(internal.toolRegistry.finishBuild, {
744− workspaceId: args.workspaceId,
745− buildId,
746− signature: args.registrySignature,
747− });
850+ return serializedTools;
851+}
748852
749− await ctx.runAction(internal.toolRegistry.pruneBuilds, {
750− workspaceId: args.workspaceId,
751− maxRetainedBuilds: 2,
752− });
853+async function rebuildNamespaceSummaries(
854+ ctx: ActionCtx,
855+ workspaceId: Id<"workspaces">,
856+ entries: RegistryToolEntry[],
857+): Promise<void> {
858+ let cursor: string | undefined;
859+ while (true) {
860+ const page: { continueCursor: string | null } = await ctx.runMutation(
861+ internal.toolRegistry.deleteToolRegistryNamespacesPage,
862+ {
863+ workspaceId,
864+ cursor,
865+ },
866+ );
867+ if (page.continueCursor === null) {
868+ break;
869+ }
870+ cursor = page.continueCursor;
871+ }
753872
754− return { buildId };
755− } catch (error) {
756− await ctx.runMutation(internal.toolRegistry.failBuild, {
757− workspaceId: args.workspaceId,
758− buildId,
873+ const namespaceMap = new Map<string, { toolCount: number; samplePaths: string[] }>();
874+ for (const entry of entries) {
875+ const namespace = (entry.preferredPath.split(".")[0] ?? "default").toLowerCase();
876+ const current = namespaceMap.get(namespace) ?? { toolCount: 0, samplePaths: [] };
877+ current.toolCount += 1;
878+ if (current.samplePaths.length < 6) {
879+ current.samplePaths.push(entry.preferredPath);
880+ }
881+ namespaceMap.set(namespace, current);
882+ }
883+
884+ const namespaces = [...namespaceMap.entries()]
885+ .map(([namespace, meta]) => ({
886+ namespace,
887+ toolCount: meta.toolCount,
888+ samplePaths: [...meta.samplePaths].sort((a, b) => a.localeCompare(b)).slice(0, 3),
889+ }))
890+ .sort((a, b) => a.namespace.localeCompare(b.namespace));
891+
892+ const NS_BATCH = 100;
893+ for (let i = 0; i < namespaces.length; i += NS_BATCH) {
894+ await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, {
895+ workspaceId,
896+ namespaces: namespaces.slice(i, i + NS_BATCH),
759897 });
760− throw error;
761898 }
762899 }
763900
764−// No implicit "ensure"/backfill on reads: the registry is built on writes.
765−
766−export async function getWorkspaceTools(
901+async function refreshWorkspaceToolRegistryIncremental(
767902 ctx: ActionCtx,
768− workspaceId: Id<"workspaces">,
769− options: GetWorkspaceToolsOptions = {},
770−): Promise<WorkspaceToolsResult> {
771− const includeDts = options.includeDts ?? false;
772− const sourceTimeoutMs = options.sourceTimeoutMs;
773− const accountId = options.accountId;
903+ context: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string },
904+): Promise<void> {
905+ const workspaceId = context.workspaceId;
774906 const sources = (await listWorkspaceToolSources(ctx, workspaceId))
775907 .filter((source) => source.enabled);
776− const registrySignature = registrySignatureForWorkspace(workspaceId, sources);
777908
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+
779931 const warnings: string[] = [];
932+
780933 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+
781956 const normalizedResult = normalizeExternalToolSource(source);
782957 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());
784975 continue;
785976 }
786− configs.push(normalizedResult.value);
787− }
788977
789− const loadedSources = await Promise.all(configs.map(async (config) => {
790− if (!sourceTimeoutMs || sourceTimeoutMs <= 0) {
791− return {
792− ...(await loadSourceArtifact(ctx, config, { includeDts, workspaceId, accountId })),
793− timedOut: false,
794− sourceName: config.name,
795− };
796− }
978+ const initialLoadMessage = sourceLoadingMessage(normalizedResult.value, 0);
979+ sourceStatesById.set(source.id, {
980+ sourceId: source.id,
981+ sourceName: source.name,
982+ sourceKey,
983+ signature,
984+ state: "loading",
985+ toolCount: previous?.toolCount ?? 0,
986+ processedTools: 0,
987+ message: initialLoadMessage,
988+ startedAt,
989+ updatedAt: Date.now(),
990+ });
991+ await persistSourceStates(ctx, workspaceId, sourceStatesById.values());
992+
993+ let keepHeartbeatRunning = true;
994+ const heartbeat = (async () => {
995+ while (keepHeartbeatRunning) {
996+ await sleep(SOURCE_LOAD_HEARTBEAT_MS);
997+ if (!keepHeartbeatRunning) {
998+ break;
999+ }
7971000
798− let timer: ReturnType<typeof setTimeout> | undefined;
799− const timeoutResult = new Promise<{
800− artifact?: CompiledToolSourceArtifact;
801− warnings: string[];
802− timedOut: boolean;
803− sourceName: string;
804− openApiDts?: string;
805− openApiSourceKey?: string;
806− }>((resolve) => {
807− timer = setTimeout(() => {
808− resolve({
809− artifact: undefined,
810− warnings: [`Source '${config.name}' is still loading; showing partial results.`],
811− timedOut: true,
812− sourceName: config.name,
813− openApiDts: undefined,
814− openApiSourceKey: config.type === "openapi" ? (config.sourceKey ?? `openapi:${config.name}`) : undefined,
1001+ const elapsedMs = Date.now() - startedAt;
1002+ const elapsedSeconds = Math.max(1, Math.floor(elapsedMs / 1000));
1003+ const loadingMessage = `${sourceLoadingMessage(normalizedResult.value, elapsedSeconds)} (${elapsedSeconds}s)`;
1004+ sourceStatesById.set(source.id, {
1005+ sourceId: source.id,
1006+ sourceName: source.name,
1007+ sourceKey,
1008+ signature,
1009+ state: "loading",
1010+ toolCount: previous?.toolCount ?? 0,
1011+ processedTools: 0,
1012+ message: loadingMessage,
1013+ startedAt,
1014+ updatedAt: Date.now(),
8151015 });
816− }, sourceTimeoutMs);
817− });
1016+ await persistSourceStates(ctx, workspaceId, sourceStatesById.values());
1017+ }
1018+ })();
1019+
1020+ let loaded:
1021+ | {
1022+ artifact?: CompiledToolSourceArtifact;
1023+ warnings: string[];
1024+ openApiDts?: string;
1025+ openApiSourceKey?: string;
1026+ }
1027+ | undefined;
8181028
819− const loadResult = loadSourceArtifact(ctx, config, { includeDts, workspaceId, accountId })
820− .then((result) => ({ ...result, timedOut: false, sourceName: config.name }));
1029+ try {
1030+ loaded = await loadSourceArtifact(ctx, normalizedResult.value, {
1031+ includeDts: false,
1032+ workspaceId,
1033+ accountId: context.accountId,
1034+ });
1035+ } catch (error) {
1036+ keepHeartbeatRunning = false;
1037+ await heartbeat;
8211038
822− const result = await Promise.race([loadResult, timeoutResult]);
823− if (timer && !result.timedOut) {
824− clearTimeout(timer);
1039+ const message = error instanceof Error ? error.message : String(error);
1040+ const failedAt = Date.now();
1041+ sourceStatesById.set(source.id, {
1042+ sourceId: source.id,
1043+ sourceName: source.name,
1044+ sourceKey,
1045+ signature,
1046+ state: "failed",
1047+ toolCount: previous?.toolCount ?? 0,
1048+ processedTools: 0,
1049+ message: "Failed",
1050+ error: message,
1051+ startedAt,
1052+ updatedAt: failedAt,
1053+ });
1054+ warnings.push(`Source '${source.name}': ${message}`);
1055+ await persistSourceStates(ctx, workspaceId, sourceStatesById.values());
1056+ continue;
8251057 }
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);
8381058
839− let typesStorageId: Id<"_storage"> | undefined;
840− try {
841− if (hasTimedOutSource) {
842− return {
843− tools: merged,
844− warnings,
845− typesStorageId,
846− timedOutSources,
847− };
1059+ keepHeartbeatRunning = false;
1060+ await heartbeat;
1061+
1062+ if (!loaded.artifact) {
1063+ const message = loaded.warnings[0] ?? `No tools generated for source '${source.name}'`;
1064+ const failedAt = Date.now();
1065+ sourceStatesById.set(source.id, {
1066+ sourceId: source.id,
1067+ sourceName: source.name,
1068+ sourceKey,
1069+ signature,
1070+ state: "failed",
1071+ toolCount: previous?.toolCount ?? 0,
1072+ processedTools: 0,
1073+ message: "Failed",
1074+ error: message,
1075+ startedAt,
1076+ updatedAt: failedAt,
1077+ });
1078+ warnings.push(`Source '${source.name}': ${message}`);
1079+ await persistSourceStates(ctx, workspaceId, sourceStatesById.values());
1080+ continue;
8481081 }
1082+ warnings.push(...loaded.warnings);
8491083
850− const allTools = [...merged.values()];
1084+ const serializedTools = loaded.artifact.tools;
1085+ const entries = registryWriteEntriesFromSerializedTools(serializedTools);
8511086
852− // Build a per-tool registry for fast discover + invocation.
853− const { buildId } = await buildWorkspaceToolRegistry(ctx, {
854− workspaceId,
855− registrySignature,
856− serializedTools: externalArtifacts.flatMap((artifact) => artifact.tools),
1087+ sourceStatesById.set(source.id, {
1088+ sourceId: source.id,
1089+ sourceName: source.name,
1090+ sourceKey,
1091+ signature,
1092+ state: "indexing",
1093+ toolCount: entries.length,
1094+ processedTools: 0,
1095+ message: `Indexing 0/${entries.length} tools`,
1096+ startedAt,
1097+ updatedAt: Date.now(),
8571098 });
1099+ await persistSourceStates(ctx, workspaceId, sourceStatesById.values());
8581100
859− // Build and store a workspace-wide Monaco type bundle.
860− const openApiDtsBySource: Record<string, string> = {};
861− for (const loaded of loadedSources) {
862− if (loaded.openApiDts && loaded.openApiDts.trim().length > 0) {
863− const sourceKey = loaded.openApiSourceKey ?? `openapi:${loaded.sourceName}`;
864− openApiDtsBySource[sourceKey] = loaded.openApiDts;
865− }
1101+ if (previous?.sourceKey && previous.sourceKey !== sourceKey) {
1102+ await ctx.runAction(internal.toolRegistry.deleteToolsBySource, {
1103+ workspaceId,
1104+ source: previous.sourceKey,
1105+ });
8661106 }
867− const typeBundle = buildWorkspaceTypeBundle({
868− tools: allTools,
869− openApiDtsBySource,
1107+
1108+ await ctx.runAction(internal.toolRegistry.deleteToolsBySource, {
1109+ workspaceId,
1110+ source: sourceKey,
8701111 });
871− const typesBlob = new Blob([typeBundle], { type: "text/plain" });
872− typesStorageId = await ctx.storage.store(typesBlob);
8731112
874− const sourceToolCountsByName = new Map<string, number>();
875− for (const source of sources) {
876− sourceToolCountsByName.set(source.name, 0);
877− }
878− for (const tool of allTools) {
879− const sourceName = toSourceName(tool.source);
880− if (!sourceName) continue;
881− sourceToolCountsByName.set(sourceName, (sourceToolCountsByName.get(sourceName) ?? 0) + 1);
882− }
1113+ const TOOL_BATCH = 100;
1114+ let processedTools = 0;
1115+ for (let i = 0; i < entries.length; i += TOOL_BATCH) {
1116+ await ctx.runMutation(internal.toolRegistry.putToolsBatch, {
1117+ workspaceId,
1118+ tools: entries.slice(i, i + TOOL_BATCH),
1119+ });
8831120
884− await ctx.runMutation(internal.toolRegistry.updateBuildMetadata, {
885− workspaceId,
886− buildId,
887− typesStorageId,
888− warnings,
889− toolCount: allTools.length,
890− sourceToolCounts: [...sourceToolCountsByName.entries()].map(([sourceName, toolCount]) => ({
891− sourceName,
892− toolCount,
893− })),
894− sourceVersions: sources.map((source) => ({
1121+ processedTools = Math.min(entries.length, processedTools + TOOL_BATCH);
1122+ sourceStatesById.set(source.id, {
8951123 sourceId: source.id,
8961124 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]) => ({
9031125 sourceKey,
904− type: profile.type,
905− mode: profile.mode,
906− header: profile.header,
907− inferred: profile.inferred,
908− })),
909− openApiRefHintTables: buildBoundedOpenApiRefHintTables(externalArtifacts),
910− } as never);
911− } catch (error) {
912− const msg = error instanceof Error ? error.message : String(error);
913− console.warn(`[executor] workspace tool registry metadata write failed for '${workspaceId}': ${msg}`);
1126+ signature,
1127+ state: "indexing",
1128+ toolCount: entries.length,
1129+ processedTools,
1130+ message: `Indexing ${processedTools}/${entries.length} tools`,
1131+ startedAt,
1132+ updatedAt: Date.now(),
1133+ });
1134+ await persistSourceStates(ctx, workspaceId, sourceStatesById.values());
1135+ }
1136+
1137+ const sourceQuality = source.type === "openapi"
1138+ ? computeOpenApiSourceQualityFromSerializedTools(serializedTools)[sourceKey]
1139+ : undefined;
1140+ const openApiRefHints = buildBoundedOpenApiRefHintTables([loaded.artifact])[0]?.refs;
1141+ const completedAt = Date.now();
1142+ sourceStatesById.set(source.id, {
1143+ sourceId: source.id,
1144+ sourceName: source.name,
1145+ sourceKey,
1146+ signature,
1147+ state: "ready",
1148+ toolCount: entries.length,
1149+ processedTools: entries.length,
1150+ message: "Ready",
1151+ startedAt,
1152+ completedAt,
1153+ updatedAt: completedAt,
1154+ ...(sourceQuality ? { sourceQuality } : {}),
1155+ ...(openApiRefHints ? { openApiRefHints } : {}),
1156+ });
1157+ await persistSourceStates(ctx, workspaceId, sourceStatesById.values());
9141158 }
9151159
916− return {
917− tools: merged,
918− warnings,
1160+ const allRegistryEntries = await listAllRegistryEntries(ctx, workspaceId);
1161+ await rebuildNamespaceSummaries(ctx, workspaceId, allRegistryEntries);
1162+
1163+ const serializedTools = await listAllSerializedTools(ctx, workspaceId);
1164+ const hydratedTools = rehydrateTools(serializedTools, baseTools);
1165+ const merged = mergeTools(hydratedTools);
1166+ const typeBundle = buildWorkspaceTypeBundle({
1167+ tools: [...merged.values()],
1168+ openApiDtsBySource: {},
1169+ });
1170+
1171+ let typesStorageId: Id<"_storage"> | undefined;
1172+ try {
1173+ typesStorageId = await ctx.storage.store(new Blob([typeBundle], { type: "text/plain" }));
1174+ } catch {
1175+ typesStorageId = undefined;
1176+ }
1177+
1178+ const sourceToolCountsByName = new Map<string, number>();
1179+ for (const source of sources) {
1180+ sourceToolCountsByName.set(source.name, 0);
1181+ }
1182+ for (const entry of allRegistryEntries) {
1183+ const sourceName = toSourceName(entry.source);
1184+ if (!sourceName) {
1185+ continue;
1186+ }
1187+ sourceToolCountsByName.set(sourceName, (sourceToolCountsByName.get(sourceName) ?? 0) + 1);
1188+ }
1189+
1190+ const sourceStates = toSourceStateRows(sourceStatesById.values());
1191+ const sourceQuality = sourceStates
1192+ .filter((state): state is MaterializedSourceStateRecord & { sourceQuality: OpenApiSourceQuality } => Boolean(state.sourceQuality))
1193+ .map((state) => state.sourceQuality);
1194+ const openApiRefHintTables = sourceStates
1195+ .filter((state): state is MaterializedSourceStateRecord & { openApiRefHints: Array<{ key: string; hint: string }> } => (
1196+ Array.isArray(state.openApiRefHints) && state.openApiRefHints.length > 0
1197+ ))
1198+ .map((state) => ({
1199+ sourceKey: state.sourceKey,
1200+ refs: state.openApiRefHints,
1201+ }));
1202+
1203+ await ctx.runMutation(internal.toolRegistry.updateRegistryMetadata, {
1204+ workspaceId,
9191205 typesStorageId,
920− timedOutSources,
921− };
1206+ warnings,
1207+ toolCount: baseTools.size + allRegistryEntries.length,
1208+ sourceToolCounts: [...sourceToolCountsByName.entries()].map(([sourceName, toolCount]) => ({
1209+ sourceName,
1210+ toolCount,
1211+ })),
1212+ sourceQuality,
1213+ sourceAuthProfiles: Object.entries(computeSourceAuthProfilesFromSources(sources)).map(([sourceKey, profile]) => ({
1214+ sourceKey,
1215+ type: profile.type,
1216+ mode: profile.mode,
1217+ header: profile.header,
1218+ inferred: profile.inferred,
1219+ })),
1220+ openApiRefHintTables,
1221+ } as never);
1222+
1223+ await persistSourceStates(
1224+ ctx,
1225+ workspaceId,
1226+ sourceStatesById.values(),
1227+ registrySignatureForWorkspace(workspaceId, sources),
1228+ );
9221229 }
9231230
9241231 interface WorkspaceRegistryReadResult {
@@ -934,12 +1241,15 @@ interface WorkspaceRegistryReadResult { · 9 unmodified lines
9341241 openApiRefHintLookup: Record<string, Record<string, string>>;
9351242 }
9361243
937−export type ToolSourceGenerationState = "ready" | "loading" | "refreshing" | "failed";
1244+export type ToolSourceGenerationState = "queued" | "loading" | "indexing" | "ready" | "failed";
9381245
9391246 export interface ToolSourceGenerationStatus {
9401247 state: ToolSourceGenerationState;
9411248 toolCount: number;
942− changed: boolean;
1249+ processedTools?: number;
1250+ message?: string;
1251+ error?: string;
1252+ updatedAt?: number;
9431253 }
9441254
9451255 export interface WorkspaceInventoryProgress {
@@ -986,37 +1296,29 @@ function toSourceAuthProfileRecord( · 40 unmodified lines
9861296 return result;
9871297 }
9881298
989−function changedSourceNames(
990− currentSources: ToolSourceRecord[],
991− previousVersions: Array<{ sourceId: string; sourceName: string; updatedAt: number }>,
992−): string[] {
993− const previousById = new Map(previousVersions.map((source) => [source.sourceId, source]));
994− const changed = new Set<string>();
995−
996− for (const source of currentSources) {
997− const previous = previousById.get(source.id);
998− if (!previous || previous.updatedAt !== source.updatedAt || previous.sourceName !== source.name) {
999− changed.add(source.name);
1000− }
1001− }
1002−
1003− return [...changed].sort((a, b) => a.localeCompare(b));
1004−}
1005−
10061299 type WorkspaceRegistryStateRecord = {
10071300 signature?: string;
1008− readyBuildId?: string;
1009− buildingBuildId?: string;
1010− buildingSignature?: string;
1011− buildingStartedAt?: number;
1012− lastBuildCompletedAt?: number;
1013− lastBuildFailedAt?: number;
1014− lastBuildError?: string;
1301+ lastRefreshError?: string;
10151302 typesStorageId?: Id<"_storage">;
10161303 warnings?: string[];
10171304 toolCount?: number;
10181305 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+ }>;
10201322 sourceQuality?: OpenApiSourceQuality[];
10211323 sourceAuthProfiles?: Array<{
10221324 sourceKey: string;
@@ -1032,86 +1334,129 @@ type WorkspaceRegistryStateRecord = { · 9 unmodified lines
10321334 updatedAt?: number;
10331335 } | null;
10341336
1337+type WorkspaceSourceStateRow = NonNullable<Exclude<WorkspaceRegistryStateRecord, null>["sourceStates"]>[number];
1338+
1339+function toSourceStateRecord(
1340+ items: Exclude<WorkspaceRegistryStateRecord, null>["sourceStates"] | undefined,
1341+): WorkspaceSourceStateRow[] {
1342+ const result: WorkspaceSourceStateRow[] = [];
1343+ for (const item of items ?? []) {
1344+ if (!item || typeof item !== "object") {
1345+ continue;
1346+ }
1347+ const state = (item as { state?: unknown }).state;
1348+ if (
1349+ state !== "queued"
1350+ && state !== "loading"
1351+ && state !== "indexing"
1352+ && state !== "ready"
1353+ && state !== "failed"
1354+ ) {
1355+ continue;
1356+ }
1357+ const sourceId = (item as { sourceId?: unknown }).sourceId;
1358+ const sourceName = (item as { sourceName?: unknown }).sourceName;
1359+ const sourceKey = (item as { sourceKey?: unknown }).sourceKey;
1360+ const signature = (item as { signature?: unknown }).signature;
1361+ const toolCount = (item as { toolCount?: unknown }).toolCount;
1362+ const updatedAt = (item as { updatedAt?: unknown }).updatedAt;
1363+ if (
1364+ typeof sourceId !== "string"
1365+ || typeof sourceName !== "string"
1366+ || typeof sourceKey !== "string"
1367+ || typeof signature !== "string"
1368+ || typeof toolCount !== "number"
1369+ || typeof updatedAt !== "number"
1370+ ) {
1371+ continue;
1372+ }
1373+
1374+ result.push(item as WorkspaceSourceStateRow);
1375+ }
1376+
1377+ return result;
1378+}
1379+
10351380 function computeWorkspaceInventoryProgress(
10361381 workspaceId: Id<"workspaces">,
10371382 sources: ToolSourceRecord[],
10381383 registryState: WorkspaceRegistryStateRecord,
10391384 ): WorkspaceInventoryProgress {
10401385 const expectedRegistrySignature = registrySignatureForWorkspace(workspaceId, sources);
1041− const readyBuildId = registryState?.readyBuildId;
1042− const building = Boolean(registryState?.buildingBuildId);
1043− const isFresh = Boolean(registryState?.signature && registryState.signature === expectedRegistrySignature && !building);
1386+ const isFresh = Boolean(registryState?.signature && registryState.signature === expectedRegistrySignature);
10441387 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+ }
10461433
10471434 let state: ToolInventoryState;
1048− if (building && readyBuildId) {
1435+ if (loadingSourceNames.length > 0 && (registryState?.toolCount ?? 0) > 0) {
10491436 state = "rebuilding";
1050− } else if (building && !readyBuildId) {
1437+ } else if (loadingSourceNames.length > 0) {
10511438 state = "initializing";
1052− } else if (!readyBuildId && registryState?.lastBuildError) {
1439+ } else if (failedSourceError || registryState?.lastRefreshError) {
10531440 state = "failed";
1054− } else if (!readyBuildId) {
1055− state = "initializing";
10561441 } else if (isFresh) {
10571442 state = "ready";
1058− } else if (registryState?.lastBuildError) {
1059− state = "failed";
10601443 } else {
1061− state = "stale";
1444+ state = "ready";
10621445 }
10631446
10641447 const warnings = [...(registryState?.warnings ?? [])];
1065− const allSourceNames = sources.map((source) => source.name);
1066− const loadingSourceNames = state === "initializing"
1067− ? [...allSourceNames]
1068− : (state === "rebuilding" || state === "stale" || state === "failed")
1069− ? changedSources
1070− : [];
1071−
1072− if (state === "initializing" && allSourceNames.length > 0) {
1073− warnings.push("Tool inventory is still loading; showing partial results.");
1074− }
1075−
1076− if (state === "stale" || state === "rebuilding") {
1077− warnings.push("Tool sources changed; showing previous results while refreshing.");
1078− }
1079− if (state === "failed" && registryState?.lastBuildError) {
1080− warnings.push(`Tool inventory build failed: ${registryState.lastBuildError}`);
1081− }
1082− for (const sourceName of loadingSourceNames) {
1083− warnings.push(`Source '${sourceName}' is still loading; showing partial results.`);
1084− }
1085−
1086− const loadingSourceSet = new Set(loadingSourceNames);
1087− const changedSourceSet = new Set(changedSources);
1088− const sourceStates: Record<string, ToolSourceGenerationStatus> = {};
1089− for (const source of sources) {
1090− const changed = changedSourceSet.has(source.name);
1091− const loading = loadingSourceSet.has(source.name);
1092−
1093− let sourceState: ToolSourceGenerationState;
1094− if (state === "failed" && loading) {
1095− sourceState = "failed";
1096− } else if (state === "stale" && loading) {
1097− sourceState = "refreshing";
1098− } else if (loading) {
1099− sourceState = "loading";
1100− } else {
1101− sourceState = "ready";
1448+ for (const [sourceName, sourceStatus] of Object.entries(sourceStates)) {
1449+ if (sourceStatus.state === "failed" && sourceStatus.error) {
1450+ warnings.push(`Source '${sourceName}' failed: ${sourceStatus.error}`);
1451+ continue;
11021452 }
1103−
1104− sourceStates[source.name] = {
1105− state: sourceState,
1106− toolCount: sourceCounts[source.name] ?? 0,
1107− changed,
1108− };
1453+ }
1454+ if (state === "failed" && registryState?.lastRefreshError) {
1455+ warnings.push(`Tool inventory refresh failed: ${registryState.lastRefreshError}`);
11091456 }
11101457
11111458 const inventoryStatus: ToolInventoryStatus = {
11121459 state,
1113− readyBuildId,
1114− buildingBuildId: registryState?.buildingBuildId,
11151460 readyToolCount: registryState?.toolCount ?? 0,
11161461 loadingSourceNames,
11171462 sourceToolCounts: {
@@ -1119,22 +1464,19 @@ function computeWorkspaceInventoryProgress( · 1 unmodified line
11191464 // Ensure system tools are always visible even for pre-existing registry states.
11201465 system: sourceCounts["system"] ?? baseTools.size,
11211466 },
1122− lastBuildStartedAt: registryState?.buildingStartedAt,
1123− lastBuildCompletedAt: registryState?.lastBuildCompletedAt,
1124− lastBuildFailedAt: registryState?.lastBuildFailedAt,
1125− ...(registryState?.lastBuildError ? { error: registryState.lastBuildError } : {}),
1467+ ...(failedSourceError || registryState?.lastRefreshError
1468+ ? { error: failedSourceError ?? registryState?.lastRefreshError }
1469+ : {}),
11261470 updatedAt: registryState?.updatedAt,
11271471 };
11281472
11291473 const sourceTokens = Object.entries(sourceStates)
11301474 .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 ?? ""}`)
11321476 .join(",");
11331477
11341478 const reactiveKey = [
11351479 inventoryStatus.state,
1136− inventoryStatus.readyBuildId ?? "",
1137− inventoryStatus.buildingBuildId ?? "",
11381480 String(inventoryStatus.updatedAt ?? 0),
11391481 sourceTokens,
11401482 ].join("|");
@@ -1162,14 +1504,14 @@ export async function getWorkspaceInventoryProgressForContext( · 21 unmodified lines
11621504
11631505 const progress = computeWorkspaceInventoryProgress(
11641506 workspaceId,
1165− sources.filter((source) => source.enabled),
1507+ sources.filter((source: ToolSourceRecord) => source.enabled),
11661508 registryState,
11671509 );
11681510
11691511 // Append a lightweight policy fingerprint so the reactive key changes
11701512 // whenever any policy is created, updated, or deleted.
11711513 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}`)
11731515 .sort()
11741516 .join(",");
11751517
@@ -1188,26 +1530,30 @@ async function getWorkspaceToolsFromRegistry( · 12 unmodified lines
11881530 sourceName?: string;
11891531 cursor?: string;
11901532 limit?: number;
1191− buildId?: string;
11921533 fetchAll?: boolean;
11931534 } = {},
11941535 ): Promise<WorkspaceRegistryReadResult> {
11951536 const sources = (await listWorkspaceToolSources(ctx, workspaceId))
1196− .filter((source) => source.enabled);
1537+ .filter((source: ToolSourceRecord) => source.enabled);
11971538
11981539 const registryState = await ctx.runQuery(internal.toolRegistry.getState, {
11991540 workspaceId,
12001541 });
12011542
12021543 const progress = computeWorkspaceInventoryProgress(workspaceId, sources, registryState);
1203− const readyBuildId = progress.inventoryStatus.readyBuildId;
1204− const activeBuildId = options.buildId ?? readyBuildId;
12051544 const sourceCounts = progress.inventoryStatus.sourceToolCounts;
12061545 const sourceQuality = toSourceQualityRecord(registryState?.sourceQuality ?? []);
12071546 const sourceAuthProfiles = toSourceAuthProfileRecord(registryState?.sourceAuthProfiles ?? []);
12081547 const openApiRefHintLookup = toOpenApiRefHintLookup(registryState?.openApiRefHintTables ?? []);
12091548 const warnings = [...progress.warnings];
12101549 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);
12111557 const allSourceNames = sources.map((source) => source.name);
12121558 const relevantSourceNames = scopedSourceName
12131559 ? allSourceNames.filter((name) => name === scopedSourceName)
@@ -1227,21 +1573,6 @@ async function getWorkspaceToolsFromRegistry( · 13 unmodified lines
12271573 ? (sourceCounts[scopedSourceName] ?? 0)
12281574 : inventoryStatus.readyToolCount;
12291575
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−
12451576 const registryTools: RegistryToolEntry[] = [];
12461577 const requestedPaths = [...new Set((options.toolPaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0))];
12471578 let nextCursor: string | null | undefined;
@@ -1250,7 +1581,6 @@ async function getWorkspaceToolsFromRegistry( · 2 unmodified lines
12501581 const entries = await Promise.all(requestedPaths.map(async (path) => {
12511582 const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {
12521583 workspaceId,
1253− buildId: activeBuildId,
12541584 path,
12551585 });
12561586 if (!entry) return null;
@@ -1277,20 +1607,18 @@ async function getWorkspaceToolsFromRegistry( · 20 unmodified lines
12771607 const page: {
12781608 continueCursor: string | null;
12791609 items: RegistryToolEntry[];
1280− } = options.source
1281− ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, {
1282− workspaceId,
1283− buildId: activeBuildId,
1284− source: options.source,
1285− cursor,
1286− limit: 100,
1287− })
1288− : await ctx.runQuery(internal.toolRegistry.listToolsPage, {
1289− workspaceId,
1290− buildId: activeBuildId,
1291− cursor,
1292− limit: 100,
1293− });
1610+ } = sourceKeyFilter
1611+ ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, {
1612+ workspaceId,
1613+ source: sourceKeyFilter,
1614+ cursor,
1615+ limit: 100,
1616+ })
1617+ : await ctx.runQuery(internal.toolRegistry.listToolsPage, {
1618+ workspaceId,
1619+ cursor,
1620+ limit: 100,
1621+ });
12941622 for (const entry of page.items) {
12951623 registryTools.push(entry);
12961624 }
@@ -1304,20 +1632,18 @@ async function getWorkspaceToolsFromRegistry( · 7 unmodified lines
13041632 const page: {
13051633 continueCursor: string | null;
13061634 items: RegistryToolEntry[];
1307− } = options.source
1308− ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, {
1309− workspaceId,
1310− buildId: activeBuildId,
1311− source: options.source,
1312− cursor: options.cursor,
1313− limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))),
1314− })
1315− : await ctx.runQuery(internal.toolRegistry.listToolsPage, {
1316− workspaceId,
1317− buildId: activeBuildId,
1318− cursor: options.cursor,
1319− limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))),
1320− });
1635+ } = sourceKeyFilter
1636+ ? await ctx.runQuery(internal.toolRegistry.listToolsBySourcePage, {
1637+ workspaceId,
1638+ source: sourceKeyFilter,
1639+ cursor: options.cursor,
1640+ limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))),
1641+ })
1642+ : await ctx.runQuery(internal.toolRegistry.listToolsPage, {
1643+ workspaceId,
1644+ cursor: options.cursor,
1645+ limit: Math.max(1, Math.min(250, Math.floor(options.limit ?? 100))),
1646+ });
13211647 registryTools.push(...page.items);
13221648 nextCursor = page.continueCursor;
13231649 }
@@ -1348,7 +1674,6 @@ async function loadWorkspaceToolInventoryForContext( · 24 unmodified lines
13481674 sourceName?: string;
13491675 cursor?: string;
13501676 limit?: number;
1351− buildId?: string;
13521677 fetchAll?: boolean;
13531678 } = {},
13541679 ): Promise<WorkspaceToolInventory> {
@@ -1366,7 +1691,6 @@ async function loadWorkspaceToolInventoryForContext( · 11 unmodified lines
13661691 sourceName: options.sourceName,
13671692 cursor: options.cursor,
13681693 limit: options.limit,
1369− buildId: options.buildId,
13701694 fetchAll: options.fetchAll,
13711695 }),
13721696 listWorkspaceToolPolicies(ctx, context.workspaceId, context.accountId),
@@ -1385,8 +1709,8 @@ async function loadWorkspaceToolInventoryForContext( · 12 unmodified lines
13851709 const includeBaseTools = options.source || options.sourceName
13861710 ? false
13871711 : boundedRequestedPaths.length > 0
1388− ? true
1389− : Boolean(options.fetchAll || !options.cursor);
1712+ ? true
1713+ : Boolean(options.fetchAll || !options.cursor);
13901714 const baseDescriptors = includeBaseTools
13911715 ? listVisibleToolDescriptors(baseTools, context, policies, {
13921716 includeDetails,
@@ -1443,7 +1767,6 @@ export async function listToolsForContext( · 50 unmodified lines
14431767 sourceName?: string;
14441768 cursor?: string;
14451769 limit?: number;
1446− buildId?: string;
14471770 } = {},
14481771 ): Promise<ToolDescriptor[]> {
14491772 const inventory = await loadWorkspaceToolInventoryForContext(ctx, context, {
@@ -1494,17 +1817,11 @@ export async function listToolDetailsForContext( · 44 unmodified lines
14941817 }
14951818 }
14961819
1497− const readyBuildId = registryState?.readyBuildId;
1498− if (!readyBuildId) {
1499− return result;
1500− }
1501−
15021820 const openApiRefHintLookup = toOpenApiRefHintLookup(registryState?.openApiRefHintTables ?? []);
15031821 const registryPaths = boundedRequestedPaths.filter((path) => !baseTools.has(path));
15041822 const entries = await Promise.all(registryPaths.map((path) =>
15051823 ctx.runQuery(internal.toolRegistry.getToolByPath, {
15061824 workspaceId: context.workspaceId,
1507− buildId: readyBuildId,
15081825 path,
15091826 })
15101827 ));
@@ -1550,49 +1867,17 @@ export async function rebuildWorkspaceToolInventoryForContext( · 39 unmodified lines
15501867 ctx: ActionCtx,
15511868 context: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string },
15521869 ): Promise<{ rebuilt: boolean }> {
1553− const [sources, state] = await Promise.all([
1554− listWorkspaceToolSources(ctx, context.workspaceId),
1555− ctx.runQuery(internal.toolRegistry.getState, {
1556− workspaceId: context.workspaceId,
1557− }),
1558− ]);
1559−
1560− const registrySignature = registrySignatureForWorkspace(
1561− context.workspaceId,
1562− sources.filter((source) => source.enabled),
1563− );
1564−
1565− const alreadyBuildingTarget = Boolean(
1566− state?.buildingBuildId
1567− && state?.buildingSignature === registrySignature,
1568− );
1569− const buildingStartedAt = typeof state?.buildingStartedAt === "number"
1570− ? state.buildingStartedAt
1571− : undefined;
1572− const staleBuildingTarget = Boolean(
1573− alreadyBuildingTarget
1574− && state?.buildingBuildId
1575− && buildingStartedAt
1576− && Date.now() - buildingStartedAt > REGISTRY_BUILD_STALE_MS,
1577− );
1578−
1579− if (alreadyBuildingTarget && !staleBuildingTarget) {
1580− return { rebuilt: false };
1581− }
1582−
1583− if (staleBuildingTarget && state?.buildingBuildId) {
1584− await ctx.runMutation(internal.toolRegistry.failBuild, {
1870+ try {
1871+ await refreshWorkspaceToolRegistryIncremental(ctx, context);
1872+ } catch (error) {
1873+ const message = error instanceof Error ? error.message : String(error);
1874+ await ctx.runMutation(internal.toolRegistry.setRefreshError, {
15851875 workspaceId: context.workspaceId,
1586− buildId: state.buildingBuildId,
1587− error: "Tool inventory build timed out; retrying",
1876+ error: message,
15881877 });
1878+ throw error;
15891879 }
15901880
1591− await getWorkspaceTools(ctx, context.workspaceId, {
1592− accountId: context.accountId,
1593− includeDts: false,
1594− });
1595−
15961881 return { rebuilt: true };
15971882 }
15981883
@@ -1607,7 +1892,6 @@ export async function listToolsWithWarningsForContext( · 8 unmodified lines
16071892 sourceName?: string;
16081893 cursor?: string;
16091894 limit?: number;
1610− buildId?: string;
16111895 fetchAll?: boolean;
16121896 } = {},
16131897 ): Promise<{
executor/scripts/dev/security/probe.token-access.ts+0−146
@@ -1,146 +0,0 @@
1−#!/usr/bin/env bun
2−
3−import { ConvexHttpClient } from "convex/browser";
4−import { api } from "../../../packages/database/convex/_generated/api";
5−
6−type RuntimeId = "local-bun" | "cloudflare-worker-loader";
7−
8−function requireConvexUrl(): string {
9− const value = process.env.CONVEX_URL ?? process.env.CONVEX_SITE_URL;
10− if (!value) {
11− throw new Error("Missing CONVEX_URL or CONVEX_SITE_URL");
12− }
13−
14− return value;
15−}
16−
17−function buildProbeCode(): string {
18− return [
19− "const out = {};",
20− "const summarize = (value) => {",
21− " if (value === undefined) return { type: 'undefined' };",
22− " if (value === null) return { type: 'null' };",
23− " const t = typeof value;",
24− " if (t === 'string') return { type: 'string', length: value.length, prefix: value.slice(0, 8) };",
25− " if (t === 'number' || t === 'boolean' || t === 'bigint') return { type: t, value };",
26− " if (t === 'function') return { type: 'function', name: value.name || '<anonymous>' };",
27− " if (t === 'object') {",
28− " try { return { type: 'object', keys: Object.keys(value).slice(0, 30) }; } catch { return { type: 'object' }; }",
29− " }",
30− " return { type: t };",
31− "};",
32− "const tokenKeys = ['CLOUDFLARE_SANDBOX_AUTH_TOKEN', 'EXECUTOR_INTERNAL_TOKEN', 'AUTH_TOKEN', 'CLOUDFLARE_SANDBOX_RUN_URL', 'CONVEX_URL'];",
33− "let g;",
34− "try { g = Function('return globalThis')(); } catch (error) { out.globalThisError = String(error); }",
35− "out.globalThis = summarize(g);",
36− "out.globalKeys = g ? Object.getOwnPropertyNames(g).slice(0, 80) : [];",
37− "let processRef;",
38− "try { processRef = Function(\"return typeof process !== 'undefined' ? process : undefined\")(); } catch (error) { out.processEvalError = String(error); }",
39− "out.process = summarize(processRef);",
40− "out.processEnv = summarize(processRef?.env);",
41− "const processEnvProbe = {};",
42− "for (const key of tokenKeys) {",
43− " let value;",
44− " try { value = processRef?.env?.[key]; } catch (error) { value = `<error:${String(error)}>`; }",
45− " processEnvProbe[key] = summarize(value);",
46− "}",
47− "out.processEnvProbe = processEnvProbe;",
48− "let bunRef;",
49− "try { bunRef = Function(\"return typeof Bun !== 'undefined' ? Bun : undefined\")(); } catch (error) { out.bunEvalError = String(error); }",
50− "out.Bun = summarize(bunRef);",
51− "let denoRef;",
52− "try { denoRef = Function(\"return typeof Deno !== 'undefined' ? Deno : undefined\")(); } catch (error) { out.denoEvalError = String(error); }",
53− "out.Deno = summarize(denoRef);",
54− "try {",
55− " const src = tools.discover?.toString?.() ?? '<missing>';",
56− " out.discoverToString = src.slice(0, 220);",
57− "} catch (error) {",
58− " out.discoverToStringError = String(error);",
59− "}",
60− "return out;",
61− ].join("\n");
62−}
63−
64−function extractSuspiciousKeys(result: unknown): string[] {
65− const rec = (result && typeof result === "object") ? result as Record<string, unknown> : {};
66− const probe = (rec.processEnvProbe && typeof rec.processEnvProbe === "object")
67− ? rec.processEnvProbe as Record<string, unknown>
68− : {};
69−
70− const leaked: string[] = [];
71− for (const [key, value] of Object.entries(probe)) {
72− const summary = (value && typeof value === "object") ? value as Record<string, unknown> : {};
73− const type = summary.type;
74− const length = summary.length;
75− if (type === "string" && typeof length === "number" && length > 0) {
76− leaked.push(key);
77− }
78− }
79−
80− return leaked;
81−}
82−
83−async function runForRuntime(
84− client: ConvexHttpClient,
85− workspaceId: string,
86− sessionId: string,
87− runtimeId: RuntimeId,
88− code: string,
89−) {
90− const response = await client.action(api.executor.createTask, {
91− workspaceId: workspaceId as never,
92− sessionId,
93− runtimeId,
94− waitForResult: true,
95− timeoutMs: 120_000,
96− code,
97− metadata: {
98− purpose: "security-token-probe",
99− runtimeId,
100− },
101− }) as {
102− task: { id: string; status: string; error?: string };
103− result?: unknown;
104− };
105−
106− return response;
107−}
108−
109−async function main() {
110− const client = new ConvexHttpClient(requireConvexUrl(), {
111− skipConvexDeploymentUrlCheck: true,
112− });
113−
114− const sessionId = `mcp_token_probe_${crypto.randomUUID().slice(0, 8)}`;
115− const bootstrap = await client.mutation(api.workspace.bootstrapAnonymousSession, { sessionId }) as {
116− sessionId: string;
117− };
118− const created = await client.mutation(api.organizations.create, {
119− sessionId: bootstrap.sessionId,
120− name: `Token Probe Org ${crypto.randomUUID().slice(0, 8)}`,
121− }) as {
122− workspace: { id: string };
123− };
124− const workspaceId = created.workspace.id;
125− const code = buildProbeCode();
126−
127− for (const runtimeId of ["local-bun", "cloudflare-worker-loader"] as const) {
128− try {
129− const response = await runForRuntime(client, workspaceId, sessionId, runtimeId, code);
130− const leaked = extractSuspiciousKeys(response.result);
131−
132− console.log(`\n=== ${runtimeId} ===`);
133− console.log(`taskStatus=${response.task.status}`);
134− if (response.task.error) {
135− console.log(`taskError=${response.task.error}`);
136− }
137− console.log(`leakedKeys=${leaked.length > 0 ? leaked.join(",") : "none"}`);
138− console.log(JSON.stringify(response.result ?? null, null, 2));
139− } catch (error) {
140− console.log(`\n=== ${runtimeId} FAILED ===`);
141− console.log(error instanceof Error ? error.message : String(error));
142− }
143− }
144−}
145−
146−await main();
executor/scripts/dev/storage/smoke.execute-flow.ts+0−390
@@ -1,390 +0,0 @@
1−#!/usr/bin/env bun
2−
3−import assert from "node:assert/strict";
4−import { ConvexHttpClient } from "convex/browser";
5−import { $ } from "bun";
6−import { api } from "../../../packages/database/convex/_generated/api";
7−
8−type RuntimeId = "local-bun" | "cloudflare-worker-loader";
9−const EXECUTOR_ROOT = new URL("../../../", import.meta.url).pathname;
10−const DATABASE_ROOT = new URL("../../../packages/database/", import.meta.url).pathname;
11−
12−function parseArgs(argv: string[]): {
13− runtimeId?: RuntimeId;
14− timeoutMs: number;
15− deployWorker: boolean;
16− deployConvex: boolean;
17−} {
18− let runtimeId: RuntimeId | undefined;
19− let timeoutMs = 120_000;
20− let deployWorker = false;
21− let deployConvex = false;
22−
23− for (let index = 0; index < argv.length; index += 1) {
24− const arg = argv[index];
25− if (arg === "--runtime") {
26− const value = argv[index + 1];
27− if (value !== "local-bun" && value !== "cloudflare-worker-loader") {
28− throw new Error("--runtime must be one of: local-bun, cloudflare-worker-loader");
29− }
30− runtimeId = value;
31− index += 1;
32− continue;
33− }
34−
35− if (arg === "--timeout-ms") {
36− const value = Number.parseInt(argv[index + 1] ?? "", 10);
37− if (!Number.isFinite(value) || value <= 0) {
38− throw new Error("--timeout-ms must be a positive integer");
39− }
40− timeoutMs = value;
41− index += 1;
42− continue;
43− }
44−
45− if (arg === "--deploy-worker") {
46− deployWorker = true;
47− continue;
48− }
49−
50− if (arg === "--deploy-convex") {
51− deployConvex = true;
52− continue;
53− }
54−
55− throw new Error(`Unknown argument: ${arg}`);
56− }
57−
58− return { runtimeId, timeoutMs, deployWorker, deployConvex };
59−}
60−
61−function requireEnv(name: string): string {
62− const value = process.env[name]?.trim();
63− if (!value) {
64− throw new Error(`Missing required environment variable: ${name}`);
65− }
66− return value;
67−}
68−
69−function makeClient(convexUrl: string): ConvexHttpClient {
70− return new ConvexHttpClient(convexUrl, {
71− skipConvexDeploymentUrlCheck: true,
72− });
73−}
74−
75−function sleep(ms: number): Promise<void> {
76− return new Promise((resolve) => setTimeout(resolve, ms));
77−}
78−
79−function parseWorkersDevUrl(output: string): string | null {
80− const match = output.match(/https:\/\/[\w.-]+\.workers\.dev/);
81− return match ? match[0] : null;
82−}
83−
84−function isHostedConvexUrl(url: string): boolean {
85− try {
86− const hostname = new URL(url).hostname.trim().toLowerCase();
87− return hostname.endsWith(".convex.cloud") || hostname.endsWith(".convex.site");
88− } catch {
89− return false;
90− }
91−}
92−
93−async function deployCloudflareWorkerAndGetUrl(): Promise<string> {
94− console.log("[storage:smoke:execute-flow] deploying runner-sandbox-host...");
95− const output = await $`bun run --cwd packages/runner-sandbox-host deploy`
96− .cwd(EXECUTOR_ROOT)
97− .env(process.env)
98− .text();
99− console.log(output.trim());
100−
101− const url = parseWorkersDevUrl(output);
102− if (!url) {
103− throw new Error("Could not parse workers.dev URL from runner-sandbox-host deploy output");
104− }
105−
106− return url;
107−}
108−
109−async function setCloudflareWorkerAuthSecret(token: string): Promise<void> {
110− console.log("[storage:smoke:execute-flow] updating runner-sandbox-host AUTH_TOKEN secret...");
111− await $`printf "%s" ${token} | bun run --cwd packages/runner-sandbox-host wrangler secret put AUTH_TOKEN`
112− .cwd(EXECUTOR_ROOT)
113− .env(process.env)
114− .quiet(false);
115−}
116−
117−async function maybeDeployConvex(enabled: boolean): Promise<void> {
118− if (!enabled) {
119− return;
120− }
121−
122− console.log("[storage:smoke:execute-flow] deploying Convex functions...");
123− await $`bunx --package convex convex dev --once --typecheck disable`
124− .cwd(DATABASE_ROOT)
125− .env(process.env)
126− .quiet(false);
127−}
128−
129−async function setConvexEnvVar(key: string, value: string): Promise<void> {
130− await $`bunx --package convex convex env set ${key} ${value}`
131− .cwd(DATABASE_ROOT)
132− .env(process.env)
133− .quiet(false);
134−}
135−
136−async function waitForSourceReady(args: {
137− client: ConvexHttpClient;
138− workspaceId: string;
139− sessionId: string;
140− sourceName: string;
141− timeoutMs: number;
142−}): Promise<void> {
143− const started = Date.now();
144−
145− while (Date.now() - started < args.timeoutMs) {
146− const progress = await args.client.query(api.workspace.getToolInventoryProgress, {
147− workspaceId: args.workspaceId as never,
148− sessionId: args.sessionId,
149− }) as {
150− inventoryStatus?: { state?: string; error?: string };
151− sourceStates?: Record<string, { state?: string; toolCount?: number }>;
152− warnings?: string[];
153− };
154−
155− const source = progress.sourceStates?.[args.sourceName];
156− const inventoryState = progress.inventoryStatus?.state ?? "unknown";
157− const sourceState = source?.state ?? "unknown";
158− const sourceCount = source?.toolCount ?? 0;
159−
160− if (inventoryState === "failed") {
161− throw new Error(
162− `Tool inventory failed for source '${args.sourceName}': ${progress.inventoryStatus?.error ?? "unknown error"}`,
163− );
164− }
165−
166− if (sourceState === "ready" && sourceCount > 0) {
167− return;
168− }
169−
170− await sleep(1_000);
171− }
172−
173− throw new Error(`Timed out waiting for source '${args.sourceName}' to become ready`);
174−}
175−
176−async function run(): Promise<void> {
177− const options = parseArgs(process.argv.slice(2));
178− const convexUrl = requireEnv("CONVEX_URL");
179− const client = makeClient(convexUrl);
180−
181− const requestedRuntime: RuntimeId = options.runtimeId
182− ?? (process.env.CLOUDFLARE_SANDBOX_RUN_URL?.trim() ? "cloudflare-worker-loader" : "local-bun");
183−
184− if (requestedRuntime === "local-bun" && isHostedConvexUrl(convexUrl)) {
185− throw new Error(
186− "local-bun storage smoke is self-host only. Hosted Convex deployments must use AGENT_STORAGE_PROVIDER=agentfs-cloudflare and the cloudflare-worker-loader runtime.",
187− );
188− }
189−
190− await maybeDeployConvex(options.deployConvex);
191−
192− if (requestedRuntime === "cloudflare-worker-loader") {
193− let sandboxRunUrl = process.env.CLOUDFLARE_SANDBOX_RUN_URL?.trim();
194− let sandboxAuthToken = process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN?.trim();
195−
196− if (!sandboxAuthToken || options.deployWorker) {
197− sandboxAuthToken = `smoke_${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`;
198− await setCloudflareWorkerAuthSecret(sandboxAuthToken);
199− }
200−
201− if (!sandboxRunUrl || options.deployWorker) {
202− const workerUrl = await deployCloudflareWorkerAndGetUrl();
203− sandboxRunUrl = `${workerUrl}/v1/runs`;
204− }
205−
206− if (!sandboxRunUrl || !sandboxAuthToken) {
207− throw new Error("Unable to resolve Cloudflare sandbox URL/token");
208− }
209−
210− process.env.CLOUDFLARE_SANDBOX_RUN_URL = sandboxRunUrl;
211− process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = sandboxAuthToken;
212− process.env.AGENT_STORAGE_PROVIDER = "agentfs-cloudflare";
213−
214− console.log("[storage:smoke:execute-flow] syncing Convex dev env vars for Cloudflare runtime/storage...");
215− await setConvexEnvVar("CLOUDFLARE_SANDBOX_RUN_URL", sandboxRunUrl);
216− await setConvexEnvVar("CLOUDFLARE_SANDBOX_AUTH_TOKEN", sandboxAuthToken);
217− await setConvexEnvVar("AGENT_STORAGE_PROVIDER", "agentfs-cloudflare");
218− } else {
219− process.env.AGENT_STORAGE_PROVIDER = "agentfs-local";
220− console.log("[storage:smoke:execute-flow] syncing Convex dev env vars for local storage backend...");
221− await setConvexEnvVar("AGENT_STORAGE_PROVIDER", "agentfs-local");
222− }
223−
224− const runId = crypto.randomUUID().slice(0, 8);
225− const sessionId = `mcp_storage_e2e_${runId}`;
226− const organizationName = `Storage Smoke Org ${runId}`;
227− const sourceName = `storage-smoke-source-${runId}`;
228−
229− const bootstrap = await client.mutation(api.workspace.bootstrapAnonymousSession, {
230− sessionId,
231− }) as {
232− sessionId: string;
233− accountId: string;
234− workspaceId: string;
235− };
236−
237− assert.ok(bootstrap.sessionId, "bootstrap must return a sessionId");
238−
239− const createdOrg = await client.mutation(api.organizations.create, {
240− sessionId: bootstrap.sessionId,
241− name: organizationName,
242− }) as {
243− organization: { id: string; name: string };
244− workspace: { id: string; name: string };
245− };
246−
247− const workspaceId = createdOrg.workspace.id;
248− assert.ok(workspaceId, "organization.create must return a workspace id");
249−
250− const openapiSpec = {
251− openapi: "3.1.0",
252− info: {
253− title: `Storage Smoke API ${runId}`,
254− version: "1.0.0",
255− },
256− servers: [
257− { url: "https://example.com" },
258− ],
259− paths: {
260− "/ping": {
261− get: {
262− operationId: "smoke_ping",
263− summary: "Ping endpoint",
264− responses: {
265− "200": {
266− description: "ok",
267− content: {
268− "application/json": {
269− schema: {
270− type: "object",
271− properties: {
272− ok: { type: "boolean" },
273− },
274− },
275− },
276− },
277− },
278− },
279− },
280− },
281− },
282− };
283−
284− const source = await client.action(api.workspace.upsertToolSource, {
285− workspaceId: workspaceId as never,
286− sessionId: bootstrap.sessionId,
287− name: sourceName,
288− type: "openapi",
289− scopeType: "workspace",
290− config: {
291− spec: openapiSpec,
292− },
293− }) as { id: string; name: string };
294−
295− assert.equal(source.name, sourceName, "upsertToolSource should return created source");
296−
297− await client.mutation(api.workspace.regenerateToolInventory, {
298− workspaceId: workspaceId as never,
299− sessionId: bootstrap.sessionId,
300− });
301−
302− await waitForSourceReady({
303− client,
304− workspaceId,
305− sessionId: bootstrap.sessionId,
306− sourceName,
307− timeoutMs: options.timeoutMs,
308− });
309−
310− const sourceNameLiteral = JSON.stringify(sourceName);
311− const taskCode = [
312− `const sourceName = ${sourceNameLiteral};`,
313− "const storage = await tools.storage.open({ scopeType: 'scratch', purpose: 'execute-flow-smoke' });",
314− "const instanceId = storage.instance.id;",
315− "await tools.fs.mkdir({ instanceId, path: '/workspace' });",
316− "await tools.fs.write({ instanceId, path: '/workspace/hello.txt', content: 'hello storage execute flow' });",
317− "const file = await tools.fs.read({ instanceId, path: '/workspace/hello.txt' });",
318− "await tools.kv.set({ instanceId, key: 'discord.userId', value: 'uuid-smoke' });",
319− "const kv = await tools.kv.get({ instanceId, key: 'discord.userId' });",
320− "await tools.sqlite.query({ instanceId, mode: 'write', sql: 'CREATE TABLE IF NOT EXISTS smoke (k TEXT PRIMARY KEY, v TEXT)' });",
321− "await tools.sqlite.query({ instanceId, mode: 'write', sql: 'INSERT INTO smoke (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', params: ['discord', 'uuid-smoke'] });",
322− "const sql = await tools.sqlite.query({ instanceId, mode: 'read', sql: 'SELECT v FROM smoke WHERE k = ?', params: ['discord'], maxRows: 1 });",
323− "return {",
324− " instanceId,",
325− " provider: storage.instance.provider,",
326− " fileContent: file.content,",
327− " kvValue: kv.value,",
328− " sqlValue: Array.isArray(sql.rows) && sql.rows.length > 0 ? sql.rows[0].v : null,",
329− "};",
330− ].join("\n");
331−
332− const taskOutcome = await client.action(api.executor.createTask, {
333− workspaceId: workspaceId as never,
334− sessionId: bootstrap.sessionId,
335− runtimeId: requestedRuntime,
336− waitForResult: true,
337− timeoutMs: Math.max(90_000, options.timeoutMs),
338− code: taskCode,
339− metadata: {
340− purpose: "storage-execute-flow-smoke",
341− runId,
342− },
343− }) as {
344− task: { id: string; status: string; error?: string };
345− result?: {
346− instanceId?: string;
347− provider?: string;
348− fileContent?: string;
349− kvValue?: string;
350− sqlValue?: string | null;
351− };
352− };
353−
354− if (taskOutcome.task.status !== "completed") {
355− throw new Error(`Task did not complete successfully: ${taskOutcome.task.status} ${taskOutcome.task.error ?? ""}`);
356− }
357−
358− const result = taskOutcome.result ?? {};
359− assert.equal(result.fileContent, "hello storage execute flow");
360− assert.equal(result.kvValue, "uuid-smoke");
361− assert.equal(result.sqlValue, "uuid-smoke");
362− assert.ok(result.instanceId, "task result must include storage instanceId");
363−
364− const storageInstances = await client.query(api.workspace.listStorageInstances, {
365− workspaceId: workspaceId as never,
366− sessionId: bootstrap.sessionId,
367− scopeType: "scratch",
368− }) as Array<{ id: string; provider: string; status: string }>;
369−
370− const createdInstance = storageInstances.find((item) => item.id === result.instanceId);
371− assert.ok(createdInstance, "workspace.listStorageInstances should include created scratch instance");
372−
373− if (result.instanceId) {
374− await client.mutation(api.workspace.deleteStorageInstance, {
375− workspaceId: workspaceId as never,
376− sessionId: bootstrap.sessionId,
377− instanceId: result.instanceId,
378− });
379− }
380−
381− console.log("[storage:smoke:execute-flow] PASS");
382− console.log(`runtime=${requestedRuntime}`);
383− console.log(`organization=${createdOrg.organization.id}`);
384− console.log(`workspace=${workspaceId}`);
385− console.log(`source=${source.id}`);
386− console.log(`instance=${result.instanceId}`);
387− console.log(`provider=${createdInstance?.provider ?? result.provider ?? "unknown"}`);
388−}
389−
390−await run();
executor/scripts/prod/deploy-prod.test.ts+0−23
@@ -1,23 +0,0 @@
1−import { describe, expect, test } from "bun:test";
2−import { parseArgs, resolveSteps } from "./deploy-prod";
3−
4−describe("deploy-prod args", () => {
5− test("defaults to plan mode with all steps", () => {
6− const options = parseArgs([]);
7− expect(options.apply).toBe(false);
8− expect(options.yes).toBe(false);
9− expect(options.force).toBe(false);
10− expect(resolveSteps(options)).toEqual(["cloudflare", "env", "convex", "doctor"]);
11− });
12−
13− test("supports only and skip filters", () => {
14− const options = parseArgs(["--apply", "--only", "cloudflare,doctor", "--skip", "doctor"]);
15− expect(options.apply).toBe(true);
16− expect(resolveSteps(options)).toEqual(["cloudflare"]);
17− });
18−
19− test("throws on unknown step", () => {
20− expect(() => parseArgs(["--only", "bogus"]))
21− .toThrow("Unknown step 'bogus'");
22− });
23−});
executor/scripts/prod/deploy-prod.ts+0−371
@@ -1,371 +0,0 @@
1−import path from "node:path";
2−import { createInterface } from "node:readline/promises";
3−import { parseConvexEnvList } from "./doctor-prod";
4−
5−type StepId = "cloudflare" | "env" | "convex" | "doctor";
6−
7−interface Options {
8− apply: boolean;
9− yes: boolean;
10− force: boolean;
11− only?: Set<StepId>;
12− skip: Set<StepId>;
13−}
14−
15−interface CommandResult {
16− exitCode: number;
17− stdout: string;
18− stderr: string;
19−}
20−
21−interface PreflightCheck {
22− id: string;
23− ok: boolean;
24− detail: string;
25− hint?: string;
26− blocking: boolean;
27−}
28−
29−interface DeployStep {
30− id: StepId;
31− name: string;
32− command: string[];
33− cwd?: string;
34−}
35−
36−const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", "..");
37−const RUNNER_HOST_DIR = path.join(EXECUTOR_ROOT, "packages", "runner-sandbox-host");
38−
39−const ALL_STEPS: StepId[] = ["cloudflare", "env", "convex", "doctor"];
40−const REQUIRED_ENV_SETUP_KEYS = [
41− "WORKOS_CLIENT_ID",
42− "WORKOS_API_KEY",
43− "WORKOS_WEBHOOK_SECRET",
44− "WORKOS_COOKIE_PASSWORD",
45− "STRIPE_SECRET_KEY",
46− "STRIPE_WEBHOOK_SECRET",
47− "STRIPE_PRICE_ID",
48− "MCP_AUTHORIZATION_SERVER",
49−] as const;
50−
51−function printHelp(): void {
52− console.log(`Guided production deploy for executor
53−
54−Usage:
55− bun run scripts/prod/deploy-prod.ts [options]
56−
57−Options:
58− --apply Execute steps (default is plan only)
59− --yes Skip confirmation prompts
60− --force Continue apply even if preflight has blocking failures
61− --only <steps> Run only these steps (comma-separated)
62− --skip <steps> Skip these steps (comma-separated)
63− -h, --help Show this help
64−
65−Steps:
66− cloudflare Configure sandbox host worker + runtime env wiring
67− env Sync WorkOS/Stripe/auth env to Convex prod
68− convex Deploy Convex functions to prod
69− doctor Verify prod setup and print remediation
70−
71−Examples:
72− bun run scripts/prod/deploy-prod.ts
73− bun run scripts/prod/deploy-prod.ts --apply
74− bun run scripts/prod/deploy-prod.ts --apply --only cloudflare,doctor
75− bun run scripts/prod/deploy-prod.ts --apply --skip convex --yes
76−`);
77−}
78−
79−function parseStepSet(raw: string): Set<StepId> {
80− const set = new Set<StepId>();
81− for (const token of raw.split(",")) {
82− const value = token.trim();
83− if (!value) continue;
84− if (!ALL_STEPS.includes(value as StepId)) {
85− throw new Error(`Unknown step '${value}'. Valid steps: ${ALL_STEPS.join(", ")}`);
86− }
87− set.add(value as StepId);
88− }
89− return set;
90−}
91−
92−export function parseArgs(argv: string[]): Options {
93− const options: Options = {
94− apply: false,
95− yes: false,
96− force: false,
97− skip: new Set<StepId>(),
98− };
99−
100− for (let i = 0; i < argv.length; i += 1) {
101− const arg = argv[i];
102− const next = argv[i + 1];
103−
104− if (arg === "--apply") {
105− options.apply = true;
106− continue;
107− }
108− if (arg === "--yes") {
109− options.yes = true;
110− continue;
111− }
112− if (arg === "--force") {
113− options.force = true;
114− continue;
115− }
116− if (arg === "--only") {
117− if (!next) throw new Error("Missing value for --only");
118− options.only = parseStepSet(next);
119− i += 1;
120− continue;
121− }
122− if (arg === "--skip") {
123− if (!next) throw new Error("Missing value for --skip");
124− options.skip = parseStepSet(next);
125− i += 1;
126− continue;
127− }
128− if (arg === "--help" || arg === "-h") {
129− printHelp();
130− process.exit(0);
131− }
132−
133− throw new Error(`Unknown option '${arg}'. Use --help for usage.`);
134− }
135−
136− return options;
137−}
138−
139−export function resolveSteps(options: Options): StepId[] {
140− const only = options.only ?? new Set<StepId>(ALL_STEPS);
141− return ALL_STEPS.filter((step) => only.has(step) && !options.skip.has(step));
142−}
143−
144−async function runCommand(command: string[], cwd = EXECUTOR_ROOT): Promise<CommandResult> {
145− const proc = Bun.spawn(command, {
146− cwd,
147− env: Bun.env,
148− stdin: "ignore",
149− stdout: "pipe",
150− stderr: "pipe",
151− });
152−
153− const [stdout, stderr, exitCode] = await Promise.all([
154− new Response(proc.stdout).text(),
155− new Response(proc.stderr).text(),
156− proc.exited,
157− ]);
158−
159− return {
160− exitCode,
161− stdout,
162− stderr,
163− };
164−}
165−
166−function isTruthy(value: string | undefined): boolean {
167− if (!value) return false;
168− const normalized = value.trim().toLowerCase();
169− return ["1", "true", "yes", "on"].includes(normalized);
170−}
171−
172−function buildSteps(selected: StepId[]): DeployStep[] {
173− const byId: Record<StepId, DeployStep> = {
174− cloudflare: {
175− id: "cloudflare",
176− name: "Cloudflare Runtime",
177− command: ["bun", "run", "scripts/prod/setup/prod-cloudflare.ts", "--deploy", "--no-doctor"],
178− cwd: EXECUTOR_ROOT,
179− },
180− env: {
181− id: "env",
182− name: "Convex Env Sync",
183− command: ["bun", "run", "scripts/prod/setup/prod-env.ts", "--from-env", "--strict"],
184− cwd: EXECUTOR_ROOT,
185− },
186− convex: {
187− id: "convex",
188− name: "Convex Deploy",
189− command: ["bunx", "convex", "deploy", "--prod"],
190− cwd: EXECUTOR_ROOT,
191− },
192− doctor: {
193− id: "doctor",
194− name: "Production Doctor",
195− command: ["bun", "run", "scripts/prod/doctor-prod.ts"],
196− cwd: EXECUTOR_ROOT,
197− },
198− };
199−
200− return selected.map((step) => byId[step]);
201−}
202−
203−async function getConvexProdEnv(): Promise<Map<string, string>> {
204− const result = await runCommand(["bunx", "convex", "env", "list", "--prod"]);
205− if (result.exitCode !== 0) {
206− return new Map<string, string>();
207− }
208− return parseConvexEnvList(result.stdout);
209−}
210−
211−function missingLocalEnvKeys(): string[] {
212− return REQUIRED_ENV_SETUP_KEYS.filter((key) => !Bun.env[key]?.trim());
213−}
214−
215−async function runPreflight(selected: StepId[]): Promise<PreflightCheck[]> {
216− const checks: PreflightCheck[] = [];
217− const usesCloudflare = selected.includes("cloudflare");
218− const usesConvex = selected.some((id) => id === "env" || id === "convex" || id === "doctor" || id === "cloudflare");
219−
220− if (usesCloudflare) {
221− const whoami = await runCommand(["bunx", "wrangler", "whoami"], RUNNER_HOST_DIR);
222− checks.push({
223− id: "wrangler-auth",
224− ok: whoami.exitCode === 0,
225− blocking: true,
226− detail: whoami.exitCode === 0 ? "Wrangler auth is available" : "Wrangler auth not available",
227− hint: whoami.exitCode === 0 ? undefined : "Run `bunx wrangler login` and retry.",
228− });
229− }
230−
231− if (usesConvex) {
232− const convexEnv = await runCommand(["bunx", "convex", "env", "list", "--prod"]);
233− checks.push({
234− id: "convex-auth",
235− ok: convexEnv.exitCode === 0,
236− blocking: true,
237− detail: convexEnv.exitCode === 0 ? "Convex prod env is accessible" : "Cannot access Convex prod env",
238− hint: convexEnv.exitCode === 0 ? undefined : "Run `bunx convex dev` or `bunx convex login` to authenticate.",
239− });
240− }
241−
242− if (selected.includes("env")) {
243− const missing = missingLocalEnvKeys();
244− checks.push({
245− id: "env-local-values",
246− ok: missing.length === 0,
247− blocking: true,
248− detail: missing.length === 0
249− ? "All required local env vars for setup:prod:env are present"
250− : `Missing local env vars: ${missing.join(", ")}`,
251− hint: missing.length === 0
252− ? undefined
253− : "Export required WORKOS_*, STRIPE_*, and MCP_AUTHORIZATION_SERVER vars before apply.",
254− });
255− }
256−
257− if (selected.includes("cloudflare")) {
258− const env = await getConvexProdEnv();
259− const hasConvexUrl = Boolean(Bun.env.CONVEX_URL?.trim() || env.get("CONVEX_URL")?.trim());
260− checks.push({
261− id: "convex-url",
262− ok: hasConvexUrl,
263− blocking: true,
264− detail: hasConvexUrl ? "CONVEX_URL is available" : "CONVEX_URL not found locally or in Convex prod env",
265− hint: hasConvexUrl
266− ? undefined
267− : "Set with `export CONVEX_URL=https://<deployment>.convex.cloud` or `bunx convex env set CONVEX_URL ... --prod`.",
268− });
269− }
270−
271− if (selected.includes("convex") && isTruthy(Bun.env.IAC_MANAGE_CONVEX_ENV) && !selected.includes("env")) {
272− checks.push({
273− id: "convex-env-order",
274− ok: true,
275− blocking: false,
276− detail: "Convex deploy selected without env sync",
277− hint: "If you changed env vars, run with `--only env,convex,doctor` for safer ordering.",
278− });
279− }
280−
281− return checks;
282−}
283−
284−function printPlan(steps: DeployStep[], checks: PreflightCheck[]): void {
285− console.log("Deploy plan");
286− for (const step of steps) {
287− console.log(` - ${step.id}: ${step.name}`);
288− console.log(` ${step.command.join(" ")}`);
289− }
290−
291− console.log("\nPreflight");
292− for (const check of checks) {
293− const label = check.ok ? "ok" : (check.blocking ? "fail" : "warn");
294− console.log(` [${label}] ${check.id}: ${check.detail}`);
295− if (check.hint) {
296− console.log(` hint: ${check.hint}`);
297− }
298− }
299−}
300−
301−async function confirmApply(): Promise<boolean> {
302− const rl = createInterface({
303− input: process.stdin,
304− output: process.stdout,
305− });
306− const input = (await rl.question("\nProceed with apply? [y/N] ")).trim().toLowerCase();
307− rl.close();
308− return input === "y" || input === "yes";
309−}
310−
311−async function executeSteps(steps: DeployStep[]): Promise<void> {
312− for (const step of steps) {
313− console.log(`\n==> ${step.name}`);
314− const result = await runCommand(step.command, step.cwd ?? EXECUTOR_ROOT);
315−
316− const stdout = result.stdout.trim();
317− const stderr = result.stderr.trim();
318− if (stdout) console.log(stdout);
319− if (stderr) console.error(stderr);
320−
321− if (result.exitCode !== 0) {
322− throw new Error(`Step '${step.id}' failed with exit code ${result.exitCode}`);
323− }
324− }
325−}
326−
327−async function main(): Promise<void> {
328− const options = parseArgs(process.argv.slice(2));
329− const selected = resolveSteps(options);
330− if (selected.length === 0) {
331− throw new Error("No steps selected. Remove --skip/--only filters.");
332− }
333−
334− const steps = buildSteps(selected);
335− const checks = await runPreflight(selected);
336− const blockingFailures = checks.filter((check) => !check.ok && check.blocking);
337−
338− console.log("Executor production deploy");
339− console.log(`Mode: ${options.apply ? "apply" : "plan"}`);
340− printPlan(steps, checks);
341−
342− if (!options.apply) {
343− console.log("\nPlan only. Re-run with --apply to execute.");
344− return;
345− }
346−
347− if (blockingFailures.length > 0 && !options.force) {
348− throw new Error("Blocking preflight failures detected. Fix them or use --force to override.");
349− }
350−
351− if (!options.yes) {
352− const approved = await confirmApply();
353− if (!approved) {
354− console.log("Aborted.");
355− return;
356− }
357− }
358−
359− await executeSteps(steps);
360− console.log("\nDeploy completed.");
361−}
362−
363−if (import.meta.main) {
364− try {
365− await main();
366− } catch (error) {
367− const message = error instanceof Error ? error.message : String(error);
368− console.error(`deploy:prod failed: ${message}`);
369− process.exit(1);
370− }
371−}
executor/scripts/prod/doctor-prod.test.ts+0−81
@@ -1,81 +0,0 @@
1−import { describe, expect, test } from "bun:test";
2−import { buildEnvChecks, parseConvexEnvList } from "./doctor-prod";
3−
4−describe("parseConvexEnvList", () => {
5− test("parses key value lines and ignores comments", () => {
6− const env = parseConvexEnvList([
7− "# comment",
8− "CONVEX_URL=https://example.convex.cloud",
9− "EXECUTOR_INTERNAL_TOKEN=abc=def",
10− "",
11− ].join("\n"));
12−
13− expect(env.get("CONVEX_URL")).toBe("https://example.convex.cloud");
14− expect(env.get("EXECUTOR_INTERNAL_TOKEN")).toBe("abc=def");
15− expect(env.has("# comment")).toBe(false);
16− });
17−});
18−
19−describe("buildEnvChecks", () => {
20− test("flags missing required production env vars", () => {
21− const checks = buildEnvChecks(new Map());
22− const byName = new Map(checks.map((check) => [check.name, check]));
23−
24− expect(byName.get("env:CLOUDFLARE_SANDBOX_RUN_URL")?.ok).toBe(false);
25− expect(byName.get("env:CLOUDFLARE_SANDBOX_AUTH_TOKEN")?.ok).toBe(false);
26− expect(byName.get("env:EXECUTOR_INTERNAL_TOKEN")?.ok).toBe(false);
27− expect(byName.get("env:CONVEX_URL")?.ok).toBe(false);
28− expect(byName.get("env:WORKOS_CLIENT_ID")?.ok).toBe(false);
29− expect(byName.get("env:WORKOS_API_KEY")?.ok).toBe(false);
30− expect(byName.get("env:WORKOS_WEBHOOK_SECRET")?.ok).toBe(false);
31− expect(byName.get("env:WORKOS_COOKIE_PASSWORD")?.ok).toBe(false);
32− expect(byName.get("env:STRIPE_SECRET_KEY")?.ok).toBe(false);
33− expect(byName.get("env:STRIPE_WEBHOOK_SECRET")?.ok).toBe(false);
34− expect(byName.get("env:STRIPE_PRICE_ID")?.ok).toBe(false);
35− expect(byName.get("env:auth server")?.ok).toBe(false);
36− });
37−
38− test("rejects CONVEX_URL pointing to convex.site", () => {
39− const env = new Map<string, string>([
40− ["CLOUDFLARE_SANDBOX_RUN_URL", "https://executor-sandbox-host.example.workers.dev/v1/runs"],
41− ["CLOUDFLARE_SANDBOX_AUTH_TOKEN", "abcdefghijklmnopqrstuvwxyz0123456789"],
42− ["EXECUTOR_INTERNAL_TOKEN", "abcdefghijklmnopqrstuvwxyz0123456789"],
43− ["CONVEX_URL", "https://perceptive-pigeon-577.convex.site"],
44− ["WORKOS_CLIENT_ID", "client_123"],
45− ["WORKOS_API_KEY", "sk_test_123"],
46− ["WORKOS_WEBHOOK_SECRET", "whsec_123"],
47− ["WORKOS_COOKIE_PASSWORD", "cookie_password_123"],
48− ["STRIPE_SECRET_KEY", "sk_live_123"],
49− ["STRIPE_WEBHOOK_SECRET", "whsec_live_123"],
50− ["STRIPE_PRICE_ID", "price_123"],
51− ["MCP_AUTHORIZATION_SERVER", "https://auth.example.com"],
52− ]);
53−
54− const checks = buildEnvChecks(env);
55− const hostTypeCheck = checks.find((check) => check.name === "convex URL host type");
56− expect(hostTypeCheck?.ok).toBe(false);
57− });
58−
59− test("accepts well-formed cloud configuration", () => {
60− const env = new Map<string, string>([
61− ["CLOUDFLARE_SANDBOX_RUN_URL", "https://executor-sandbox-host.example.workers.dev/v1/runs"],
62− ["CLOUDFLARE_SANDBOX_AUTH_TOKEN", "abcdefghijklmnopqrstuvwxyz0123456789"],
63− ["EXECUTOR_INTERNAL_TOKEN", "abcdefghijklmnopqrstuvwxyz0123456789"],
64− ["CONVEX_URL", "https://perceptive-pigeon-577.convex.cloud"],
65− ["CONVEX_SITE_URL", "https://perceptive-pigeon-577.convex.site"],
66− ["WORKOS_CLIENT_ID", "client_123"],
67− ["WORKOS_API_KEY", "sk_test_123"],
68− ["WORKOS_WEBHOOK_SECRET", "whsec_123"],
69− ["WORKOS_COOKIE_PASSWORD", "cookie_password_123"],
70− ["STRIPE_SECRET_KEY", "sk_live_123"],
71− ["STRIPE_WEBHOOK_SECRET", "whsec_live_123"],
72− ["STRIPE_PRICE_ID", "price_123"],
73− ["MCP_AUTHORIZATION_SERVER", "https://auth.example.com"],
74− ]);
75−
76− const checks = buildEnvChecks(env);
77− const failures = checks.filter((check) => !check.ok);
78− expect(failures).toEqual([]);
79− });
80−
81−});
executor/scripts/prod/doctor-prod.ts+0−382
@@ -1,382 +0,0 @@
1−import path from "node:path";
2−
3−export interface DoctorCheck {
4− name: string;
5− ok: boolean;
6− detail: string;
7−}
8−
9−export interface ProductionDoctorReport {
10− checks: DoctorCheck[];
11−}
12−
13−interface CommandResult {
14− exitCode: number;
15− stdout: string;
16− stderr: string;
17−}
18−
19−const REQUIRED_PROD_ENV_KEYS = [
20− "CLOUDFLARE_SANDBOX_RUN_URL",
21− "CLOUDFLARE_SANDBOX_AUTH_TOKEN",
22− "EXECUTOR_INTERNAL_TOKEN",
23− "CONVEX_URL",
24−] as const;
25−
26−const REQUIRED_APP_ENV_KEYS = [
27− "WORKOS_CLIENT_ID",
28− "WORKOS_API_KEY",
29− "WORKOS_WEBHOOK_SECRET",
30− "WORKOS_COOKIE_PASSWORD",
31−] as const;
32−
33−const REQUIRED_BILLING_ENV_KEYS = [
34− "STRIPE_SECRET_KEY",
35− "STRIPE_WEBHOOK_SECRET",
36− "STRIPE_PRICE_ID",
37−] as const;
38−
39−const OPTIONAL_BILLING_ENV_KEYS = [
40− "BILLING_SUCCESS_URL",
41− "BILLING_CANCEL_URL",
42− "BILLING_RETURN_URL",
43−] as const;
44−
45−const AUTH_SERVER_ENV_KEYS = [
46− "MCP_AUTHORIZATION_SERVER",
47− "MCP_AUTHORIZATION_SERVER_URL",
48− "WORKOS_AUTHKIT_ISSUER",
49− "WORKOS_AUTHKIT_DOMAIN",
50−] as const;
51−
52−const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", "..");
53−
54−function addCheck(report: ProductionDoctorReport, name: string, ok: boolean, detail: string): void {
55− report.checks.push({ name, ok, detail });
56−}
57−
58−async function runCommand(command: string[], cwd = EXECUTOR_ROOT): Promise<CommandResult> {
59− const proc = Bun.spawn(command, {
60− cwd,
61− env: Bun.env,
62− stdin: "ignore",
63− stdout: "pipe",
64− stderr: "pipe",
65− });
66−
67− const [stdout, stderr, exitCode] = await Promise.all([
68− new Response(proc.stdout).text(),
69− new Response(proc.stderr).text(),
70− proc.exited,
71− ]);
72−
73− return {
74− exitCode,
75− stdout,
76− stderr,
77− };
78−}
79−
80−async function runConvex(args: string[]): Promise<CommandResult> {
81− return await runCommand(["bunx", "convex", ...args]);
82−}
83−
84−export function parseConvexEnvList(raw: string): Map<string, string> {
85− const env = new Map<string, string>();
86−
87− for (const line of raw.split("\n")) {
88− const trimmed = line.trim();
89− if (!trimmed || trimmed.startsWith("#")) {
90− continue;
91− }
92−
93− const eqIndex = trimmed.indexOf("=");
94− if (eqIndex <= 0) {
95− continue;
96− }
97−
98− const key = trimmed.slice(0, eqIndex).trim();
99− const value = trimmed.slice(eqIndex + 1);
100− if (key.length > 0) {
101− env.set(key, value);
102− }
103− }
104−
105− return env;
106−}
107−
108−function parseUrl(input: string): URL | null {
109− try {
110− return new URL(input);
111− } catch {
112− return null;
113− }
114−}
115−
116−function deploymentPrefix(hostname: string, suffix: ".convex.cloud" | ".convex.site"): string | null {
117− if (!hostname.endsWith(suffix)) {
118− return null;
119− }
120− return hostname.slice(0, hostname.length - suffix.length);
121−}
122−
123−function addTokenStrengthCheck(report: ProductionDoctorReport, key: string, value: string): void {
124− const looksUsable = value.length >= 24;
125− addCheck(report, `${key} strength`, looksUsable, looksUsable ? "length looks reasonable" : "value seems too short");
126−}
127−
128−export function buildEnvChecks(env: Map<string, string>): DoctorCheck[] {
129− const checks: DoctorCheck[] = [];
130− const report: ProductionDoctorReport = { checks };
131−
132− for (const key of REQUIRED_PROD_ENV_KEYS) {
133− const present = Boolean(env.get(key)?.trim());
134− addCheck(report, `env:${key}`, present, present ? "set" : "missing");
135− }
136−
137− for (const key of REQUIRED_APP_ENV_KEYS) {
138− const present = Boolean(env.get(key)?.trim());
139− addCheck(report, `env:${key}`, present, present ? "set" : "missing");
140− }
141−
142− for (const key of REQUIRED_BILLING_ENV_KEYS) {
143− const present = Boolean(env.get(key)?.trim());
144− addCheck(
145− report,
146− `env:${key}`,
147− present,
148− present ? "set" : "missing (required while billing feature is enabled)",
149− );
150− }
151−
152− const authServerKey = AUTH_SERVER_ENV_KEYS.find((key) => Boolean(env.get(key)?.trim()));
153− addCheck(
154− report,
155− "env:auth server",
156− Boolean(authServerKey),
157− authServerKey
158− ? `using ${authServerKey}`
159− : `missing (set one of ${AUTH_SERVER_ENV_KEYS.join(", ")})`,
160− );
161−
162− const runUrlRaw = env.get("CLOUDFLARE_SANDBOX_RUN_URL")?.trim() ?? "";
163− if (runUrlRaw) {
164− const runUrl = parseUrl(runUrlRaw);
165− if (!runUrl) {
166− addCheck(report, "sandbox run URL format", false, `invalid URL: ${runUrlRaw}`);
167− } else {
168− addCheck(report, "sandbox run URL format", true, runUrlRaw);
169− addCheck(
170− report,
171− "sandbox run URL protocol",
172− runUrl.protocol === "https:",
173− runUrl.protocol === "https:" ? "https" : `expected https, got ${runUrl.protocol}`,
174− );
175− addCheck(
176− report,
177− "sandbox run URL path",
178− runUrl.pathname === "/v1/runs",
179− runUrl.pathname === "/v1/runs" ? "path is /v1/runs" : `expected /v1/runs, got ${runUrl.pathname}`,
180− );
181− }
182− }
183−
184− const convexUrlRaw = env.get("CONVEX_URL")?.trim() ?? "";
185− if (convexUrlRaw) {
186− const convexUrl = parseUrl(convexUrlRaw);
187− if (!convexUrl) {
188− addCheck(report, "convex URL format", false, `invalid URL: ${convexUrlRaw}`);
189− } else {
190− addCheck(report, "convex URL format", true, convexUrlRaw);
191− addCheck(
192− report,
193− "convex URL protocol",
194− convexUrl.protocol === "https:",
195− convexUrl.protocol === "https:" ? "https" : `expected https, got ${convexUrl.protocol}`,
196− );
197−
198− if (convexUrl.hostname.endsWith(".convex.site")) {
199− addCheck(report, "convex URL host type", false, "uses .convex.site; expected API host (.convex.cloud)");
200− } else if (convexUrl.hostname.endsWith(".convex.cloud")) {
201− addCheck(report, "convex URL host type", true, "uses .convex.cloud API host");
202− } else {
203− addCheck(report, "convex URL host type", true, `custom host: ${convexUrl.hostname}`);
204− }
205− }
206− }
207−
208− const convexSiteUrlRaw = env.get("CONVEX_SITE_URL")?.trim() ?? "";
209− if (convexSiteUrlRaw) {
210− const convexSiteUrl = parseUrl(convexSiteUrlRaw);
211− addCheck(
212− report,
213− "convex site URL format",
214− Boolean(convexSiteUrl),
215− convexSiteUrl ? convexSiteUrlRaw : `invalid URL: ${convexSiteUrlRaw}`,
216− );
217−
218− if (convexSiteUrl) {
219− addCheck(
220− report,
221− "convex site URL protocol",
222− convexSiteUrl.protocol === "https:",
223− convexSiteUrl.protocol === "https:" ? "https" : `expected https, got ${convexSiteUrl.protocol}`,
224− );
225− }
226− }
227−
228− if (convexUrlRaw && convexSiteUrlRaw) {
229− const convexUrl = parseUrl(convexUrlRaw);
230− const convexSiteUrl = parseUrl(convexSiteUrlRaw);
231− if (convexUrl && convexSiteUrl) {
232− const cloudPrefix = deploymentPrefix(convexUrl.hostname, ".convex.cloud");
233− const sitePrefix = deploymentPrefix(convexSiteUrl.hostname, ".convex.site");
234− if (cloudPrefix && sitePrefix) {
235− addCheck(
236− report,
237− "convex deployment pairing",
238− cloudPrefix === sitePrefix,
239− cloudPrefix === sitePrefix
240− ? `deployment ${cloudPrefix} matches`
241− : `CONVEX_URL(${cloudPrefix}) does not match CONVEX_SITE_URL(${sitePrefix})`,
242− );
243− }
244− }
245− }
246−
247− const sandboxAuthToken = env.get("CLOUDFLARE_SANDBOX_AUTH_TOKEN")?.trim();
248− if (sandboxAuthToken) {
249− addTokenStrengthCheck(report, "CLOUDFLARE_SANDBOX_AUTH_TOKEN", sandboxAuthToken);
250− }
251−
252− const internalSecret = env.get("EXECUTOR_INTERNAL_TOKEN")?.trim();
253− if (internalSecret) {
254− addTokenStrengthCheck(report, "EXECUTOR_INTERNAL_TOKEN", internalSecret);
255− }
256−
257− for (const key of OPTIONAL_BILLING_ENV_KEYS) {
258− const value = env.get(key)?.trim();
259− addCheck(
260− report,
261− `env(optional):${key}`,
262− true,
263− value ? "set" : "not set (billing flow falls back to localhost URL)",
264− );
265− }
266−
267− return checks;
268−}
269−
270−function guidanceForFailure(checkName: string): string[] {
271− if (
272− checkName === "env:CLOUDFLARE_SANDBOX_RUN_URL"
273− || checkName === "env:CLOUDFLARE_SANDBOX_AUTH_TOKEN"
274− || checkName === "env:EXECUTOR_INTERNAL_TOKEN"
275− ) {
276− return [
277− "Run `bun run setup:prod:cloudflare --deploy` (or `bun run setup:prod:all`) to deploy sandbox host and set required Cloudflare runtime env vars.",
278− ];
279− }
280−
281− if (checkName === "env:CONVEX_URL") {
282− return [
283− "Set Convex API URL: `bunx convex env set CONVEX_URL https://<deployment>.convex.cloud --prod`.",
284− ];
285− }
286−
287− if (checkName === "convex URL host type") {
288− return [
289− "Use `.convex.cloud` for `CONVEX_URL` (do not use `.convex.site` for callback RPC).",
290− ];
291− }
292−
293− if (checkName === "env:WORKOS_CLIENT_ID" || checkName === "env:WORKOS_API_KEY" || checkName === "env:WORKOS_WEBHOOK_SECRET") {
294− return [
295− "Set WorkOS auth env vars with `bun run setup:prod:env --from-env` (after exporting WORKOS_* vars), or run `bun run setup:prod:all`.",
296− ];
297− }
298−
299− if (checkName === "env:WORKOS_COOKIE_PASSWORD") {
300− return [
301− "Set a strong cookie password (`WORKOS_COOKIE_PASSWORD`) via `bunx convex env set WORKOS_COOKIE_PASSWORD <VALUE> --prod`.",
302− ];
303− }
304−
305− if (checkName === "env:auth server") {
306− return [
307− "Set one auth server env var: `MCP_AUTHORIZATION_SERVER` (preferred) via `bunx convex env set MCP_AUTHORIZATION_SERVER <URL> --prod`.",
308− ];
309− }
310−
311− if (
312− checkName === "env:STRIPE_SECRET_KEY"
313− || checkName === "env:STRIPE_WEBHOOK_SECRET"
314− || checkName === "env:STRIPE_PRICE_ID"
315− ) {
316− return [
317− "Set Stripe billing env vars with `bun run setup:prod:env --from-env` (after exporting STRIPE_* vars), or run `bun run setup:prod:all`.",
318− ];
319− }
320−
321− return ["Inspect current prod vars with `bunx convex env list --prod`."];
322−}
323−
324−export async function runProductionDoctor(): Promise<ProductionDoctorReport> {
325− const report: ProductionDoctorReport = { checks: [] };
326−
327− const envResult = await runConvex(["env", "list", "--prod"]);
328− if (envResult.exitCode !== 0) {
329− const detail = (envResult.stderr.trim() || envResult.stdout.trim() || "unknown failure").split("\n")[0] ?? "unknown failure";
330− addCheck(report, "convex env access", false, detail);
331− return report;
332− }
333−
334− addCheck(report, "convex env access", true, "able to read production Convex env");
335− const env = parseConvexEnvList(envResult.stdout);
336−
337− report.checks.push(...buildEnvChecks(env));
338−
339− return report;
340−}
341−
342−export function hasDoctorFailures(report: ProductionDoctorReport): boolean {
343− return report.checks.some((check) => !check.ok);
344−}
345−
346−async function main(): Promise<void> {
347− const report = await runProductionDoctor();
348− console.log("Production doctor");
349− for (const check of report.checks) {
350− const status = check.ok ? "ok" : "fail";
351− console.log(` [${status}] ${check.name}: ${check.detail}`);
352− }
353−
354− const failedChecks = report.checks.filter((check) => !check.ok);
355− if (failedChecks.length > 0) {
356− const guidance = new Set<string>();
357− for (const check of failedChecks) {
358− for (const line of guidanceForFailure(check.name)) {
359− guidance.add(line);
360− }
361− }
362−
363− console.log("\nHow to fix");
364− for (const line of guidance) {
365− console.log(` - ${line}`);
366− }
367− }
368−
369− if (hasDoctorFailures(report)) {
370− process.exit(1);
371− }
372−}
373−
374−if (import.meta.main) {
375− try {
376− await main();
377− } catch (error) {
378− const message = error instanceof Error ? error.message : String(error);
379− console.error(`doctor:prod failed: ${message}`);
380− process.exit(1);
381− }
382−}
executor/scripts/prod/setup/prod-cloudflare.ts+0−290
@@ -1,290 +0,0 @@
1−import crypto from "node:crypto";
2−import fs from "node:fs/promises";
3−import path from "node:path";
4−import { parseConvexEnvList } from "../doctor-prod";
5−
6−interface Options {
7− runUrl?: string;
8− convexUrl?: string;
9− authToken?: string;
10− internalToken?: string;
11− deploy: boolean;
12− runDoctor: boolean;
13−}
14−
15−interface CommandResult {
16− exitCode: number;
17− stdout: string;
18− stderr: string;
19−}
20−
21−const EXECUTOR_ROOT = path.resolve(import.meta.dir, "..", "..");
22−const SANDBOX_HOST_DIR = path.join(EXECUTOR_ROOT, "packages", "runner-sandbox-host");
23−
24−function printHelp(): void {
25− console.log(`Setup Cloudflare runtime for production
26−
27−Usage:
28− bun run scripts/prod/setup/prod-cloudflare.ts [options]
29−
30−Options:
31− --run-url <url> Explicit sandbox run URL (e.g. https://.../v1/runs)
32− --convex-url <url> Explicit Convex API URL (https://...convex.cloud)
33− --auth-token <token> Explicit sandbox auth token (otherwise generated)
34− --internal-token <token> Explicit internal callback token (otherwise generated)
35− --no-deploy Skip wrangler deploy step
36− --no-doctor Skip running doctor:prod at the end
37− -h, --help Show this help
38−`);
39−}
40−
41−function parseArgs(argv: string[]): Options {
42− const options: Options = {
43− deploy: true,
44− runDoctor: true,
45− };
46−
47− for (let i = 0; i < argv.length; i += 1) {
48− const arg = argv[i];
49− const next = argv[i + 1];
50− if (arg === "--run-url") {
51− if (!next) throw new Error("Missing value for --run-url");
52− options.runUrl = next;
53− i += 1;
54− continue;
55− }
56− if (arg === "--convex-url") {
57− if (!next) throw new Error("Missing value for --convex-url");
58− options.convexUrl = next;
59− i += 1;
60− continue;
61− }
62− if (arg === "--auth-token") {
63− if (!next) throw new Error("Missing value for --auth-token");
64− options.authToken = next;
65− i += 1;
66− continue;
67− }
68− if (arg === "--internal-token") {
69− if (!next) throw new Error("Missing value for --internal-token");
70− options.internalToken = next;
71− i += 1;
72− continue;
73− }
74− if (arg === "--no-deploy") {
75− options.deploy = false;
76− continue;
77− }
78− if (arg === "--no-doctor") {
79− options.runDoctor = false;
80− continue;
81− }
82− if (arg === "--help" || arg === "-h") {
83− printHelp();
84− process.exit(0);
85− }
86− throw new Error(`Unknown option: ${arg}`);
87− }
88−
89− return options;
90−}
91−
92−function randomToken(bytes = 32): string {
93− return crypto.randomBytes(bytes).toString("base64url");
94−}
95−
96−function redact(value: string): string {
97− if (value.length <= 8) return "********";
98− return `${value.slice(0, 4)}...${value.slice(-4)}`;
99−}
100−
101−async function runCommand(
102− command: string[],
103− opts: { cwd?: string; stdinData?: string } = {},
104−): Promise<CommandResult> {
105− const proc = Bun.spawn(command, {
106− cwd: opts.cwd ?? EXECUTOR_ROOT,
107− env: Bun.env,
108− stdin: opts.stdinData !== undefined ? "pipe" : "ignore",
109− stdout: "pipe",
110− stderr: "pipe",
111− });
112−
113− if (opts.stdinData !== undefined && proc.stdin) {
114− proc.stdin.write(`${opts.stdinData}\n`);
115− proc.stdin.end();
116− }
117−
118− const [stdout, stderr, exitCode] = await Promise.all([
119− new Response(proc.stdout).text(),
120− new Response(proc.stderr).text(),
121− proc.exited,
122− ]);
123−
124− return {
125− exitCode,
126− stdout,
127− stderr,
128− };
129−}
130−
131−async function runConvex(args: string[]): Promise<CommandResult> {
132− return await runCommand(["bunx", "convex", ...args]);
133−}
134−
135−function ensureHttpsUrl(name: string, raw: string): void {
136− let parsed: URL;
137− try {
138− parsed = new URL(raw);
139− } catch {
140− throw new Error(`${name} is not a valid URL: ${raw}`);
141− }
142−
143− if (parsed.protocol !== "https:") {
144− throw new Error(`${name} must use https: ${raw}`);
145− }
146−}
147−
148−async function parseSandboxWorkerName(): Promise<string> {
149− const wranglerConfigPath = path.join(SANDBOX_HOST_DIR, "wrangler.jsonc");
150− const text = await fs.readFile(wranglerConfigPath, "utf8");
151− const match = text.match(/"name"\s*:\s*"([^"]+)"/);
152− if (!match?.[1]) {
153− throw new Error(`Could not parse worker name from ${wranglerConfigPath}`);
154− }
155− return match[1];
156−}
157−
158−function inferWorkersUrlFromDeployOutput(output: string): string | null {
159− const match = output.match(/https:\/\/[a-zA-Z0-9._-]+\.workers\.dev/);
160− if (!match?.[0]) {
161− return null;
162− }
163− return `${match[0]}/v1/runs`;
164−}
165−
166−async function deploySandboxHostIfNeeded(enabled: boolean): Promise<string | null> {
167− if (!enabled) {
168− return null;
169− }
170−
171− console.log("[setup] deploying sandbox host worker...");
172− const deploy = await runCommand(["bunx", "wrangler", "deploy"], { cwd: SANDBOX_HOST_DIR });
173− if (deploy.exitCode !== 0) {
174− throw new Error(`wrangler deploy failed:\n${deploy.stderr || deploy.stdout}`);
175− }
176−
177− const combined = `${deploy.stdout}\n${deploy.stderr}`;
178− const inferred = inferWorkersUrlFromDeployOutput(combined);
179− if (inferred) {
180− console.log(`[setup] inferred run URL from deploy output: ${inferred}`);
181− }
182− return inferred;
183−}
184−
185−async function putWorkerSecret(secretName: string, value: string): Promise<void> {
186− const workerName = await parseSandboxWorkerName();
187− console.log(`[setup] setting wrangler secret ${secretName} on ${workerName}...`);
188−
189− const result = await runCommand(
190− ["bunx", "wrangler", "secret", "put", secretName, "--name", workerName],
191− { cwd: SANDBOX_HOST_DIR, stdinData: value },
192− );
193− if (result.exitCode !== 0) {
194− throw new Error(`wrangler secret put ${secretName} failed:\n${result.stderr || result.stdout}`);
195− }
196−}
197−
198−async function setConvexEnv(key: string, value: string): Promise<void> {
199− const result = await runConvex(["env", "set", key, value, "--prod"]);
200− if (result.exitCode !== 0) {
201− throw new Error(`convex env set ${key} failed:\n${result.stderr || result.stdout}`);
202− }
203−}
204−
205−async function getProdEnvMap(): Promise<Map<string, string>> {
206− const envResult = await runConvex(["env", "list", "--prod"]);
207− if (envResult.exitCode !== 0) {
208− throw new Error(`convex env list --prod failed:\n${envResult.stderr || envResult.stdout}`);
209− }
210− return parseConvexEnvList(envResult.stdout);
211−}
212−
213−function resolveConvexUrl(options: Options, env: Map<string, string>): string {
214− const selected = options.convexUrl?.trim()
215− || env.get("CONVEX_URL")?.trim()
216− || Bun.env.CONVEX_URL?.trim();
217−
218− if (!selected) {
219− throw new Error(
220− "Could not determine CONVEX_URL. Pass --convex-url or set CONVEX_URL in your environment.",
221− );
222− }
223−
224− ensureHttpsUrl("CONVEX_URL", selected);
225− return selected;
226−}
227−
228−function resolveRunUrl(options: Options, env: Map<string, string>, deployedRunUrl: string | null): string {
229− const selected = options.runUrl?.trim()
230− || deployedRunUrl
231− || env.get("CLOUDFLARE_SANDBOX_RUN_URL")?.trim();
232−
233− if (!selected) {
234− throw new Error(
235− "Could not determine CLOUDFLARE_SANDBOX_RUN_URL. Pass --run-url or allow deploy to infer it.",
236− );
237− }
238−
239− ensureHttpsUrl("CLOUDFLARE_SANDBOX_RUN_URL", selected);
240− if (!selected.includes("/v1/runs")) {
241− throw new Error(`CLOUDFLARE_SANDBOX_RUN_URL must include /v1/runs: ${selected}`);
242− }
243− return selected;
244−}
245−
246−async function main(): Promise<void> {
247− const options = parseArgs(process.argv.slice(2));
248− const env = await getProdEnvMap();
249−
250− const deployedRunUrl = await deploySandboxHostIfNeeded(options.deploy);
251−
252− const runUrl = resolveRunUrl(options, env, deployedRunUrl);
253− const convexUrl = resolveConvexUrl(options, env);
254− const authToken = options.authToken?.trim() || env.get("CLOUDFLARE_SANDBOX_AUTH_TOKEN")?.trim() || randomToken();
255− const internalToken = options.internalToken?.trim() || env.get("EXECUTOR_INTERNAL_TOKEN")?.trim() || randomToken();
256−
257− await putWorkerSecret("AUTH_TOKEN", authToken);
258−
259− console.log("[setup] setting production Convex env vars...");
260− await setConvexEnv("CLOUDFLARE_SANDBOX_RUN_URL", runUrl);
261− await setConvexEnv("CLOUDFLARE_SANDBOX_AUTH_TOKEN", authToken);
262− await setConvexEnv("EXECUTOR_INTERNAL_TOKEN", internalToken);
263− await setConvexEnv("CONVEX_URL", convexUrl);
264−
265− console.log("\n[setup] cloudflare production runtime configured");
266− console.log(` CLOUDFLARE_SANDBOX_RUN_URL: ${runUrl}`);
267− console.log(` CLOUDFLARE_SANDBOX_AUTH_TOKEN: ${redact(authToken)}`);
268− console.log(` EXECUTOR_INTERNAL_TOKEN: ${redact(internalToken)}`);
269− console.log(` CONVEX_URL: ${convexUrl}`);
270−
271− if (options.runDoctor) {
272− console.log("\n[setup] running doctor:prod...");
273− const doctor = await runCommand(["bun", "run", "doctor:prod"], { cwd: EXECUTOR_ROOT });
274− const output = [doctor.stdout.trim(), doctor.stderr.trim()].filter(Boolean).join("\n");
275− if (output) {
276− console.log(output);
277− }
278− if (doctor.exitCode !== 0) {
279− process.exit(doctor.exitCode);
280− }
281− }
282−}
283−
284−try {
285− await main();
286−} catch (error) {
287− const message = error instanceof Error ? error.message : String(error);
288− console.error(`[setup] failed: ${message}`);
289− process.exit(1);
290−}
executor/scripts/prod/setup/prod-env.ts+0−165
@@ -1,165 +0,0 @@
1−interface CommandResult {
2− exitCode: number;
3− stdout: string;
4− stderr: string;
5−}
6−
7−interface Options {
8− fromEnv: boolean;
9− strict: boolean;
10−}
11−
12−const REQUIRED_KEYS = [
13− "WORKOS_CLIENT_ID",
14− "WORKOS_API_KEY",
15− "WORKOS_WEBHOOK_SECRET",
16− "WORKOS_COOKIE_PASSWORD",
17− "STRIPE_SECRET_KEY",
18− "STRIPE_WEBHOOK_SECRET",
19− "STRIPE_PRICE_ID",
20− "MCP_AUTHORIZATION_SERVER",
21−] as const;
22−
23−const OPTIONAL_KEYS = [
24− "BILLING_SUCCESS_URL",
25− "BILLING_CANCEL_URL",
26− "BILLING_RETURN_URL",
27−] as const;
28−
29−function printHelp(): void {
30− console.log(`Setup production Convex app env vars
31−
32−Usage:
33− bun run scripts/prod/setup/prod-env.ts --from-env [--strict]
34−
35−Options:
36− --from-env Read values from current shell env and push to Convex prod
37− --strict Fail if any required key is missing locally
38− -h, --help Show this help
39−
40−Required local env keys:
41− ${REQUIRED_KEYS.join("\n ")}
42−
43−Optional local env keys:
44− ${OPTIONAL_KEYS.join("\n ")}
45−`);
46−}
47−
48−function parseArgs(argv: string[]): Options {
49− const options: Options = {
50− fromEnv: false,
51− strict: false,
52− };
53−
54− for (const arg of argv) {
55− if (arg === "--from-env") {
56− options.fromEnv = true;
57− continue;
58− }
59− if (arg === "--strict") {
60− options.strict = true;
61− continue;
62− }
63− if (arg === "--help" || arg === "-h") {
64− printHelp();
65− process.exit(0);
66− }
67− throw new Error(`Unknown option: ${arg}`);
68− }
69−
70− if (!options.fromEnv) {
71− throw new Error("Use --from-env to read values from your local environment.");
72− }
73−
74− return options;
75−}
76−
77−async function runCommand(command: string[]): Promise<CommandResult> {
78− const proc = Bun.spawn(command, {
79− env: Bun.env,
80− stdin: "ignore",
81− stdout: "pipe",
82− stderr: "pipe",
83− });
84−
85− const [stdout, stderr, exitCode] = await Promise.all([
86− new Response(proc.stdout).text(),
87− new Response(proc.stderr).text(),
88− proc.exited,
89− ]);
90−
91− return {
92− exitCode,
93− stdout,
94− stderr,
95− };
96−}
97−
98−async function setConvexEnv(key: string, value: string): Promise<void> {
99− const result = await runCommand(["bunx", "convex", "env", "set", key, value, "--prod"]);
100− if (result.exitCode !== 0) {
101− throw new Error(`Failed setting ${key}: ${result.stderr || result.stdout}`);
102− }
103−}
104−
105−function redact(value: string): string {
106− if (value.length < 8) return "********";
107− return `${value.slice(0, 4)}...${value.slice(-4)}`;
108−}
109−
110−async function main(): Promise<void> {
111− const options = parseArgs(process.argv.slice(2));
112−
113− const missingRequired: string[] = [];
114− const toSet: Array<{ key: string; value: string; optional: boolean }> = [];
115−
116− for (const key of REQUIRED_KEYS) {
117− const value = Bun.env[key]?.trim();
118− if (!value) {
119− missingRequired.push(key);
120− continue;
121− }
122− toSet.push({ key, value, optional: false });
123− }
124−
125− for (const key of OPTIONAL_KEYS) {
126− const value = Bun.env[key]?.trim();
127− if (!value) {
128− continue;
129− }
130− toSet.push({ key, value, optional: true });
131− }
132−
133− if (missingRequired.length > 0) {
134− console.log("[setup] missing required local env vars:");
135− for (const key of missingRequired) {
136− console.log(` - ${key}`);
137− }
138− if (options.strict) {
139− throw new Error("Required local env vars are missing and --strict was set.");
140− }
141− console.log("[setup] continuing with available values (use --strict to enforce all required vars)");
142− }
143−
144− if (toSet.length === 0) {
145− throw new Error("No env vars found to set. Export values and rerun with --from-env.");
146− }
147−
148− console.log("[setup] pushing env vars to Convex prod...");
149− for (const item of toSet) {
150− await setConvexEnv(item.key, item.value);
151− console.log(` - ${item.key}=${redact(item.value)}${item.optional ? " (optional)" : ""}`);
152− }
153−
154− console.log("[setup] done.");
155−}
156−
157−try {
158− await main();
159−} catch (error) {
160− const message = error instanceof Error ? error.message : String(error);
161− console.error(`[setup] failed: ${message}`);
162− process.exit(1);
163−}
164−
165−export {};
package.json+1−18
@@ -12,15 +12,6 @@ · 11 unmodified lines
1212 "scripts": {
1313 "with-env": "bun run --env-file=.env --",
1414 "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",
2415 "test": "bun run test:executor && bun run test:assistant",
2516 "test:executor": "bun run --cwd executor test",
2617 "test:executor:integration": "bun run --cwd executor test:integration",
@@ -30,18 +21,10 @@ · 3 unmodified lines
3021 "typecheck": "bun run typecheck:workspaces",
3122 "typecheck:slow": "bun run typecheck:workspaces:slow",
3223 "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",
3824 "clean": "bun run --filter '*' --if-present clean && rm -rf node_modules .turbo",
3925 "knip": "bunx knip --config knip.json",
4026 "db:clear:executor": "bun with-env bun run executor/scripts/dev/wipe/convex-db.ts",
41− "convex:codegen": "bunx --cwd executor convex codegen",
42− "smoke:storage:execute-flow:local": "bun with-env bun run --cwd executor smoke:storage:execute-flow:local",
43− "smoke:storage:execute-flow": "bun with-env bun run --cwd executor smoke:storage:execute-flow",
44− "smoke:storage:execute-flow:cloudflare": "bun with-env bun run --cwd executor smoke:storage:execute-flow:cloudflare"
27+ "convex:codegen": "bunx --cwd executor convex codegen"
4528 },
4629 "dependencies": {
4730 "@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