History

Improve billing workflow and OpenAPI type fidelity

Enable Stripe checkout and portal flows in the billing UI, and harden OpenAPI type extraction plus cache invalidation so tool hints remain accurate on real-world specs.

Rhys Sullivan committed 9 months ago

Commit d88f1d71 parent 333786c17 files · +1526 −486Browse files

Unified · Split

17 files
  1. .env.example +8 −0
  2. bun.lock +1 −0
  3. executor/apps/web/next.config.ts +1 −0
  4. executor/apps/web/package.json +1 −0
  5. executor/apps/web/src/components/billing-view.tsx +222 −33
  6. executor/apps/web/src/components/code-editor.tsx +72 −15
  7. executor/apps/web/src/components/tool-explorer.tsx +90 −17
  8. executor/apps/web/src/components/tools-view.tsx +34 −6
  9. executor/convex.json +4 −1
  10. executor/convex/_generated/api.d.ts +791 −108
  11. executor/convex/billing.ts +9 −3
  12. executor/convex/executorNode.ts +2 −2
  13. executor/lib/openapi-cache.test.ts +108 −0
  14. executor/lib/openapi-real-specs.test.ts +70 −0
  15. executor/lib/tool_sources.ts +112 −10
  16. executor/package.json +1 −2
  17. executor/scripts/postcodegen.ts +0 −289
.env.example+8−0
@@ -13,6 +13,14 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 12 unmodified lines
1313 # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32>
1414 # WORKOS_WEBHOOK_SECRET=...
1515
16+# ── Stripe Billing (optional — required for checkout/portal) ──
17+# STRIPE_SECRET_KEY=sk_test_...
18+# STRIPE_WEBHOOK_SECRET=whsec_...
19+# STRIPE_PRICE_ID=price_...
20+# BILLING_SUCCESS_URL=http://localhost:3000/organization?tab=billing&success=true
21+# BILLING_CANCEL_URL=http://localhost:3000/organization?tab=billing&canceled=true
22+# BILLING_RETURN_URL=http://localhost:3000/organization?tab=billing
23+
1624 # ── Discord bot (optional — omit to skip bot startup) ──
1725 # DISCORD_BOT_TOKEN=...
1826
bun.lock+1−0
@@ -106,6 +106,7 @@ · 105 unmodified lines
106106 "radix-ui": "^1.4.3",
107107 "react": "19.2.3",
108108 "react-dom": "19.2.3",
109+ "shiki": "^3.22.0",
109110 "sonner": "^2.0.7",
110111 "streamdown": "^2.1.0",
111112 "tailwind-merge": "^3.4.0",
executor/apps/web/next.config.ts+1−0
@@ -6,6 +6,7 @@ const nextConfig: NextConfig = { · 5 unmodified lines
66 // This lets us keep a single root .env without NEXT_PUBLIC_ prefixes.
77 NEXT_PUBLIC_CONVEX_URL: process.env.CONVEX_URL,
88 NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,
9+ NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
910 },
1011 };
1112
executor/apps/web/package.json+1−0
@@ -28,6 +28,7 @@ · 27 unmodified lines
2828 "radix-ui": "^1.4.3",
2929 "react": "19.2.3",
3030 "react-dom": "19.2.3",
31+ "shiki": "^3.22.0",
3132 "sonner": "^2.0.7",
3233 "streamdown": "^2.1.0",
3334 "tailwind-merge": "^3.4.0",
executor/apps/web/src/components/billing-view.tsx+222−33
@@ -1,20 +1,59 @@
11 "use client";
22
33 import { useMemo, useState } from "react";
4+import { useSearchParams } from "next/navigation";
5+import { useAction, useMutation, useQuery } from "convex/react";
46 import { AlertTriangle, BadgeCheck, CreditCard, RefreshCcw } from "lucide-react";
57 import { PageHeader } from "@/components/page-header";
8+import { Badge } from "@/components/ui/badge";
69 import { Button } from "@/components/ui/button";
710 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
11+import { Input } from "@/components/ui/input";
12+import { convexApi } from "@/lib/convex-api";
813 import { useSession } from "@/lib/session-context";
914
1015 interface BillingViewProps {
1116 showHeader?: boolean;
1217 }
1318
19+type BillingSummary = {
20+ customer: {
21+ stripeCustomerId: string;
22+ } | null;
23+ subscription: {
24+ stripeSubscriptionId: string;
25+ stripePriceId: string;
26+ status: string;
27+ currentPeriodEnd: number | null;
28+ cancelAtPeriodEnd: boolean;
29+ } | null;
30+ seats: {
31+ billableMembers: number;
32+ desiredSeats: number;
33+ lastAppliedSeats: number | null;
34+ };
35+ sync: {
36+ status: "ok" | "error" | "pending";
37+ lastSyncAt: number | null;
38+ error: string | null;
39+ };
40+};
41+
42+function formatTimestamp(value: number | null | undefined): string {
43+ if (!value || !Number.isFinite(value)) {
44+ return "-";
45+ }
46+
47+ const millis = value > 1_000_000_000_000 ? value : value * 1_000;
48+ return new Date(millis).toLocaleString();
49+}
50+
1451 export function BillingView({ showHeader = true }: BillingViewProps) {
15− const { context, organizations, workspaces } = useSession();
16− const [actionState, setActionState] = useState<"idle" | "running" | "error">("idle");
52+ const { context, organizations, organizationsLoading, workspaces } = useSession();
53+ const searchParams = useSearchParams();
54+ const [actionState, setActionState] = useState<"idle" | "running" | "success" | "error">("idle");
1755 const [actionMessage, setActionMessage] = useState<string | null>(null);
56+ const [priceId, setPriceId] = useState(() => process.env.NEXT_PUBLIC_STRIPE_PRICE_ID ?? "");
1857
1958 const derivedOrganizationId = context
2059 ? workspaces.find((workspace) => workspace.id === context.workspaceId)?.organizationId ?? null
@@ -25,13 +64,98 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 4 unmodified lines
2564 () => organizations.find((organization) => organization.id === effectiveOrganizationId) ?? null,
2665 [organizations, effectiveOrganizationId],
2766 );
67+
2868 const canManageBilling = activeOrganization
29− ? ["owner", "billing_admin"].includes(activeOrganization.role)
69+ ? ["owner", "admin", "billing_admin"].includes(activeOrganization.role)
3070 : false;
3171
32− const notReady = () => {
33− setActionState("error");
34− setActionMessage("Billing actions are waiting on backend endpoints.");
72+ const summary = useQuery(
73+ convexApi.billing.getSummary,
74+ effectiveOrganizationId
75+ ? {
76+ organizationId: effectiveOrganizationId,
77+ sessionId: context?.sessionId ?? undefined,
78+ }
79+ : "skip",
80+ ) as BillingSummary | undefined;
81+
82+ const createSubscriptionCheckout = useAction(convexApi.billing.createSubscriptionCheckout);
83+ const createCustomerPortal = useAction(convexApi.billing.createCustomerPortal);
84+ const retrySeatSync = useMutation(convexApi.billing.retrySeatSync);
85+
86+ const checkoutSuccess = searchParams.get("success") === "true";
87+ const checkoutCanceled = searchParams.get("canceled") === "true";
88+
89+ const handleStartCheckout = async () => {
90+ if (!effectiveOrganizationId || !canManageBilling) {
91+ return;
92+ }
93+
94+ if (!priceId.trim()) {
95+ setActionState("error");
96+ setActionMessage("Set a Stripe price ID before starting checkout.");
97+ return;
98+ }
99+
100+ setActionState("running");
101+ setActionMessage(null);
102+ try {
103+ const session = await createSubscriptionCheckout({
104+ organizationId: effectiveOrganizationId,
105+ priceId: priceId.trim(),
106+ sessionId: context?.sessionId ?? undefined,
107+ });
108+
109+ if (session.url) {
110+ window.location.assign(session.url);
111+ return;
112+ }
113+
114+ setActionState("success");
115+ setActionMessage("Checkout session created.");
116+ } catch (error) {
117+ setActionState("error");
118+ setActionMessage(error instanceof Error ? error.message : "Failed to start checkout");
119+ }
120+ };
121+
122+ const handleOpenPortal = async () => {
123+ if (!effectiveOrganizationId || !canManageBilling) {
124+ return;
125+ }
126+
127+ setActionState("running");
128+ setActionMessage(null);
129+ try {
130+ const result = await createCustomerPortal({
131+ organizationId: effectiveOrganizationId,
132+ sessionId: context?.sessionId ?? undefined,
133+ });
134+ window.location.assign(result.url);
135+ } catch (error) {
136+ setActionState("error");
137+ setActionMessage(error instanceof Error ? error.message : "Failed to open billing portal");
138+ }
139+ };
140+
141+ const handleRetrySync = async () => {
142+ if (!effectiveOrganizationId || !canManageBilling) {
143+ return;
144+ }
145+
146+ setActionState("running");
147+ setActionMessage(null);
148+ try {
149+ await retrySeatSync({
150+ organizationId: effectiveOrganizationId,
151+ sessionId: context?.sessionId ?? undefined,
152+ });
153+ setActionState("success");
154+ setActionMessage("Seat sync queued.");
155+ } catch (error) {
156+ setActionState("error");
157+ setActionMessage(error instanceof Error ? error.message : "Failed to queue seat sync");
158+ }
35159 };
36160
37161 if (!effectiveOrganizationId) {
@@ -51,41 +175,76 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 13 unmodified lines
51175 <div className="space-y-6">
52176 {showHeader ? <PageHeader title="Billing" description="Manage subscription, seats, and billing portal access" /> : null}
53177
54− <Card>
55− <CardContent className="p-6 text-sm text-muted-foreground">
56− Billing backend wiring is still in progress. This screen is ready for `billing.getSummary`,
57− `billing.createSubscriptionCheckout`, `billing.createCustomerPortal`, and `billing.retrySeatSync`.
58− </CardContent>
59− </Card>
178+ {checkoutSuccess ? (
179+ <Card>
180+ <CardContent className="p-4 text-sm text-terminal-green">
181+ Checkout completed. Billing updates may take a few seconds while Stripe webhooks process.
182+ </CardContent>
183+ </Card>
184+ ) : null}
60185
61− <Card>
62− <CardContent className="p-4 text-sm text-muted-foreground flex items-center gap-2">
63− <RefreshCcw className="h-4 w-4" />
64− Billing sync pending. Numbers may be briefly stale during webhook processing.
65− </CardContent>
66− </Card>
186+ {checkoutCanceled ? (
187+ <Card>
188+ <CardContent className="p-4 text-sm text-muted-foreground">
189+ Checkout canceled. You can restart checkout at any time.
190+ </CardContent>
191+ </Card>
192+ ) : null}
67193
68194 <Card>
69− <CardContent className="p-4 text-sm text-destructive flex items-center justify-between gap-3">
195+ <CardContent className="p-4 text-sm text-muted-foreground flex items-center justify-between gap-3">
70196 <span className="flex items-center gap-2">
71− <AlertTriangle className="h-4 w-4" />
72− If seat sync fails, use retry once the backend endpoint is available.
197+ <RefreshCcw className="h-4 w-4" />
198+ {summary === undefined
199+ ? "Loading billing sync status..."
200+ : summary.sync.status === "error"
201+ ? "Seat sync needs attention."
202+ : summary.sync.status === "ok"
203+ ? "Seat sync healthy."
204+ : "Seat sync pending. Numbers may be briefly stale during webhook processing."}
73205 </span>
74− <Button variant="outline" size="sm" onClick={notReady} disabled={!canManageBilling || actionState === "running"}>
75− Retry sync
76− </Button>
206+ <Badge variant="outline" className="text-[10px] font-mono uppercase tracking-wider">
207+ {summary?.sync.status ?? "pending"}
208+ </Badge>
77209 </CardContent>
78210 </Card>
79211
212+ {summary?.sync.error ? (
213+ <Card>
214+ <CardContent className="p-4 text-sm text-destructive flex items-center justify-between gap-3">
215+ <span className="flex items-center gap-2">
216+ <AlertTriangle className="h-4 w-4" />
217+ {summary.sync.error}
218+ </span>
219+ <Button
220+ variant="outline"
221+ size="sm"
222+ onClick={handleRetrySync}
223+ disabled={!canManageBilling || actionState === "running"}
224+ >
225+ Retry sync
226+ </Button>
227+ </CardContent>
228+ </Card>
229+ ) : null}
230+
231+ {!canManageBilling && !organizationsLoading ? (
232+ <Card>
233+ <CardContent className="p-4 text-sm text-muted-foreground">
234+ You need owner, admin, or billing admin access to start checkout and open the billing portal.
235+ </CardContent>
236+ </Card>
237+ ) : null}
238+
80239 <div className="grid gap-4 md:grid-cols-3">
81240 <Card>
82241 <CardHeader>
83242 <CardTitle className="text-sm">Subscription</CardTitle>
84243 </CardHeader>
85244 <CardContent className="space-y-2 text-sm">
86− <p>Status: pending</p>
87− <p>Price: -</p>
88− <p>Renewal: -</p>
245+ <p>Status: {summary?.subscription?.status ?? "none"}</p>
246+ <p>Price: {summary?.subscription?.stripePriceId ?? "-"}</p>
247+ <p>Renewal: {formatTimestamp(summary?.subscription?.currentPeriodEnd ?? null)}</p>
89248 </CardContent>
90249 </Card>
91250
@@ -94,9 +253,9 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 2 unmodified lines
94253 <CardTitle className="text-sm">Seats</CardTitle>
95254 </CardHeader>
96255 <CardContent className="space-y-2 text-sm">
97− <p>Billable members: -</p>
98− <p>Desired seats: -</p>
99− <p>Last applied: -</p>
256+ <p>Billable members: {summary?.seats.billableMembers ?? "-"}</p>
257+ <p>Desired seats: {summary?.seats.desiredSeats ?? "-"}</p>
258+ <p>Last applied: {summary?.seats.lastAppliedSeats ?? "-"}</p>
100259 </CardContent>
101260 </Card>
102261
@@ -105,11 +264,26 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 2 unmodified lines
105264 <CardTitle className="text-sm">Actions</CardTitle>
106265 </CardHeader>
107266 <CardContent className="space-y-2">
108− <Button className="w-full" onClick={notReady} disabled={!canManageBilling || actionState === "running"}>
267+ <Input
268+ value={priceId}
269+ onChange={(event) => setPriceId(event.target.value)}
270+ placeholder="price_..."
271+ disabled={!canManageBilling || actionState === "running"}
272+ />
273+ <Button
274+ className="w-full"
275+ onClick={handleStartCheckout}
276+ disabled={!canManageBilling || actionState === "running"}
277+ >
109278 <CreditCard className="mr-2 h-4 w-4" />
110279 Start checkout
111280 </Button>
112− <Button variant="outline" className="w-full" onClick={notReady} disabled={!canManageBilling || actionState === "running"}>
281+ <Button
282+ variant="outline"
283+ className="w-full"
284+ onClick={handleOpenPortal}
285+ disabled={!canManageBilling || actionState === "running"}
286+ >
113287 <BadgeCheck className="mr-2 h-4 w-4" />
114288 Manage billing portal
115289 </Button>
@@ -117,8 +291,23 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 1 unmodified line
117291 </Card>
118292 </div>
119293
294+ <Card>
295+ <CardContent className="p-4 text-sm text-muted-foreground flex items-center gap-2">
296+ <BadgeCheck className="h-4 w-4" />
297+ Customer: {summary?.customer?.stripeCustomerId ?? "No Stripe customer linked"}
298+ </CardContent>
299+ </Card>
300+
120301 {actionMessage ? (
121− <p className={actionState === "error" ? "text-sm text-destructive" : "text-sm text-muted-foreground"}>
302+ <p
303+ className={
304+ actionState === "error"
305+ ? "text-sm text-destructive"
306+ : actionState === "success"
307+ ? "text-sm text-terminal-green"
308+ : "text-sm text-muted-foreground"
309+ }
310+ >
122311 {actionMessage}
123312 </p>
124313 ) : null}
executor/apps/web/src/components/code-editor.tsx+72−15
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 import { useRef, useEffect } from "react";
44 import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";
55 import { Loader2 } from "lucide-react";
6+import { useTheme } from "next-themes";
67 import type { ToolDescriptor } from "@/lib/types";
78 import { cn } from "@/lib/utils";
89
@@ -34,7 +35,7 @@ function buildTree(tools: ToolDescriptor[]): NamespaceNode { · 25 unmodified lines
3435 return root;
3536 }
3637
37−function emitToolMethod(tool: ToolDescriptor): string {
38+function emitToolMethod(tool: ToolDescriptor, dtsSources: Set<string>): string {
3839 const funcName = tool.path.split(".").pop()!;
3940 const approvalNote =
4041 tool.approval === "required"
@@ -44,8 +45,10 @@ function emitToolMethod(tool: ToolDescriptor): string { · 3 unmodified lines
4445 ? `${tool.description}${approvalNote}`
4546 : approvalNote || "Call this tool.";
4647
47− // For OpenAPI tools with operationId, use indexed access types for precise IntelliSense
48− if (tool.operationId) {
48+ // For OpenAPI tools with operationId, use indexed access types only when
49+ // this source has a loaded .d.ts block.
50+ const hasSourceDts = Boolean(tool.source && dtsSources.has(tool.source));
51+ if (tool.operationId && hasSourceDts) {
4952 const opKey = JSON.stringify(tool.operationId);
5053 return ` /**
5154 * ${desc}
@@ -72,11 +75,12 @@ function emitToolMethod(tool: ToolDescriptor): string { · 20 unmodified lines
7275 function emitNamespaceInterface(
7376 name: string,
7477 node: NamespaceNode,
78+ dtsSources: Set<string>,
7579 out: string[],
7680 ): void {
7781 // First, recursively emit child namespace interfaces
7882 for (const [childName, childNode] of node.children) {
79− emitNamespaceInterface(`${name}_${childName}`, childNode, out);
83+ emitNamespaceInterface(`${name}_${childName}`, childNode, dtsSources, out);
8084 }
8185
8286 // Build this namespace's interface
@@ -92,7 +96,7 @@ function emitNamespaceInterface( · 9 unmodified lines
9296
9397 // Add tool methods
9498 for (const tool of node.tools) {
95− members.push(emitToolMethod(tool));
99+ members.push(emitToolMethod(tool, dtsSources));
96100 }
97101
98102 out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`);
@@ -119,11 +123,12 @@ type ToolOutput<Op> = · 20 unmodified lines
119123 Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R :
120124 Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R :
121125 Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R :
122− Op extends { responses: { 204: { content: never } } } ? void :
126+ Op extends { responses: { 204: unknown } } ? void :
127+ Op extends { responses: { 205: unknown } } ? void :
123128 unknown;
124129 `;
125130
126−function generateToolsDts(tools: ToolDescriptor[]): string {
131+function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string {
127132 const root = buildTree(tools);
128133
129134 // Legacy compat: collect schemaTypes from tools that use the old format
@@ -141,7 +146,7 @@ function generateToolsDts(tools: ToolDescriptor[]): string { · 11 unmodified lines
141146
142147 // Emit all namespace interfaces recursively
143148 for (const [name, node] of root.children) {
144− emitNamespaceInterface(name, node, interfaces);
149+ emitNamespaceInterface(name, node, dtsSources, interfaces);
145150 }
146151
147152 // Build root ToolsProxy interface
@@ -154,7 +159,7 @@ function generateToolsDts(tools: ToolDescriptor[]): string { · 6 unmodified lines
154159
155160 // Root-level tools (rare but possible)
156161 for (const tool of root.tools) {
157− rootMembers.push(emitToolMethod(tool));
162+ rootMembers.push(emitToolMethod(tool, dtsSources));
158163 }
159164
160165 let dts = `
@@ -241,6 +246,7 @@ export function CodeEditor({ · 80 unmodified lines
241246 className,
242247 height = "400px",
243248 }: CodeEditorProps) {
249+ const { resolvedTheme } = useTheme();
244250 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
245251 const monacoRef = useRef<Monaco | null>(null);
246252 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);
@@ -248,6 +254,7 @@ export function CodeEditor({ · 1 unmodified line
248254 const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]);
249255 const toolsLibVersion = useRef(0);
250256 const fetchedDtsUrls = useRef<string>("");
257+ const dtsSources = new Set(Object.keys(dtsUrls ?? {}));
251258
252259 // Fetch and register .d.ts blobs from OpenAPI sources
253260 useEffect(() => {
@@ -318,7 +325,7 @@ export function CodeEditor({ · 64 unmodified lines
318325 // Dispose previous tool type declarations
319326 toolsLibDisposable.current?.dispose();
320327
321− const dts = generateToolsDts(tools);
328+ const dts = generateToolsDts(tools, dtsSources);
322329
323330 // Use a versioned filename — disposing + re-adding the same filename
324331 // can cause the TS worker to serve stale completions from its cache.
@@ -327,7 +334,7 @@ export function CodeEditor({ · 2 unmodified lines
327334 dts,
328335 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
329336 );
330− }, [tools]);
337+ }, [tools, dtsUrls]);
331338
332339 // Avoid transient semantic errors while tool metadata is still loading.
333340 useEffect(() => {
@@ -382,14 +389,62 @@ export function CodeEditor({ · 48 unmodified lines
382389 // Add initial tool type declarations
383390 // (will be replaced by useEffect when tools load from the API)
384391 toolsLibDisposable.current?.dispose();
385− const dts = generateToolsDts(tools);
392+ const dts = generateToolsDts(tools, dtsSources);
386393 const version = ++toolsLibVersion.current;
387394 toolsLibDisposable.current = ts.javascriptDefaults.addExtraLib(
388395 dts,
389396 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
390397 );
391398
392− // Define the dark theme matching our UI
399+ // Define themes that track the app's light/dark mode.
400+ monaco.editor.defineTheme("executor-light", {
401+ base: "vs",
402+ inherit: true,
403+ rules: [
404+ { token: "comment", foreground: "7f8692", fontStyle: "italic" },
405+ { token: "keyword", foreground: "0f8a6a" },
406+ { token: "string", foreground: "a46822" },
407+ { token: "number", foreground: "a46822" },
408+ { token: "type", foreground: "0f8a6a" },
409+ { token: "function", foreground: "5c470f" },
410+ { token: "variable", foreground: "1f2430" },
411+ { token: "operator", foreground: "6f7785" },
412+ ],
413+ colors: {
414+ "editor.background": "#ffffff",
415+ "editor.foreground": "#1f2430",
416+ "editor.lineHighlightBackground": "#f4f7fb",
417+ "editor.selectionBackground": "#c7def5",
418+ "editor.inactiveSelectionBackground": "#dbe8f7",
419+ "editorCursor.foreground": "#0f8a6a",
420+ "editorLineNumber.foreground": "#9aa3b2",
421+ "editorLineNumber.activeForeground": "#6f7785",
422+ "editorIndentGuide.background": "#e3e8ef",
423+ "editorIndentGuide.activeBackground": "#ccd5e2",
424+ "editor.selectionHighlightBackground": "#c7def540",
425+ "editorWidget.background": "#ffffff",
426+ "editorWidget.border": "#d6dde8",
427+ "editorSuggestWidget.background": "#ffffff",
428+ "editorSuggestWidget.border": "#d6dde8",
429+ "editorSuggestWidget.selectedBackground": "#cfe1f6",
430+ "editorSuggestWidget.selectedForeground": "#111827",
431+ "editorSuggestWidget.selectedIconForeground": "#0f8a6a",
432+ "editorSuggestWidget.highlightForeground": "#0f8a6a",
433+ "editorHoverWidget.background": "#ffffff",
434+ "editorHoverWidget.border": "#d6dde8",
435+ "list.focusBackground": "#cfe1f6",
436+ "list.focusForeground": "#111827",
437+ "list.highlightForeground": "#0f8a6a",
438+ "input.background": "#ffffff",
439+ "input.border": "#d6dde8",
440+ "scrollbar.shadow": "#00000000",
441+ "scrollbarSlider.background": "#c2cad880",
442+ "scrollbarSlider.hoverBackground": "#a6afbe",
443+ "scrollbarSlider.activeBackground": "#8f98a8",
444+ "focusBorder": "#0f8a6a30",
445+ },
446+ });
447+
393448 monaco.editor.defineTheme("executor-dark", {
394449 base: "vs-dark",
395450 inherit: true,
@@ -434,6 +489,8 @@ export function CodeEditor({ · 38 unmodified lines
434489 });
435490 };
436491
492+ const monacoTheme = resolvedTheme === "light" ? "executor-light" : "executor-dark";
493+
437494 const handleMount: OnMount = (editor) => {
438495 editorRef.current = editor;
439496
@@ -460,7 +517,7 @@ export function CodeEditor({ · 20 unmodified lines
460517 height={height}
461518 language="javascript"
462519 path="task.js"
463− theme="executor-dark"
520+ theme={monacoTheme}
464521 value={value}
465522 onChange={(v) => onChange(v ?? "")}
466523 beforeMount={handleBeforeMount}
@@ -524,7 +581,7 @@ export function CodeEditor({ · 57 unmodified lines
524581 smoothScrolling: true,
525582 }}
526583 loading={
527− <div className="flex items-center justify-center h-full bg-[#0f1117] text-muted-foreground text-xs font-mono">
584+ <div className="flex h-full items-center justify-center bg-background text-xs font-mono text-muted-foreground">
528585 Loading editor...
529586 </div>
530587 }
executor/apps/web/src/components/tool-explorer.tsx+90−17
@@ -256,6 +256,93 @@ function CopyButton({ · 255 unmodified lines
256256 );
257257 }
258258
259+// ── Pretty-print a TS type literal for display ──
260+// Turns `{ owner: string; repo: string; ref?: string }` into a multi-line
261+// indented format. Handles nested objects/arrays. Pure string transform.
262+
263+function prettyType(raw: string): string {
264+ const trimmed = raw.trim();
265+ // If it's already multi-line or short enough, leave it
266+ if (trimmed.includes("\n") || trimmed.length < 50) return trimmed;
267+
268+ let out = "";
269+ let indent = 0;
270+ const TAB = " ";
271+ let i = 0;
272+
273+ while (i < trimmed.length) {
274+ const ch = trimmed[i];
275+
276+ // Array type bracket — e.g. `string[]` or `}[]` — keep inline
277+ if (ch === "[" && trimmed[i + 1] === "]") {
278+ out += "[]";
279+ i += 2;
280+ } else if (ch === "{") {
281+ indent++;
282+ out += ch + "\n" + TAB.repeat(indent);
283+ i++;
284+ // skip whitespace after brace
285+ while (i < trimmed.length && trimmed[i] === " ") i++;
286+ } else if (ch === "}") {
287+ indent = Math.max(0, indent - 1);
288+ // trim trailing whitespace on current line
289+ out = out.replace(/\s+$/, "");
290+ out += "\n" + TAB.repeat(indent) + ch;
291+ i++;
292+ } else if (ch === ";" && trimmed[i + 1] === " ") {
293+ // property separator — newline after semicolon
294+ out += ";\n" + TAB.repeat(indent);
295+ i += 2; // skip the semicolon + space
296+ } else if (ch === "," && indent > 0 && trimmed[i + 1] === " ") {
297+ out += ",\n" + TAB.repeat(indent);
298+ i += 2;
299+ } else {
300+ out += ch;
301+ i++;
302+ }
303+ }
304+
305+ return out;
306+}
307+
308+// ── Formatted + highlighted type signature display ──
309+
310+function TypeSignature({ raw, label }: { raw: string; label: string }) {
311+ const formatted = useMemo(() => prettyType(raw), [raw]);
312+ const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
313+
314+ useEffect(() => {
315+ let cancelled = false;
316+ import("shiki").then(({ codeToHtml }) =>
317+ codeToHtml(formatted, {
318+ lang: "typescript",
319+ themes: { light: "github-light", dark: "github-dark" },
320+ }).then((html) => {
321+ if (!cancelled) setHighlightedHtml(html);
322+ }),
323+ );
324+ return () => { cancelled = true; };
325+ }, [formatted]);
326+
327+ return (
328+ <div>
329+ <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50 mb-1">
330+ {label}
331+ </p>
332+ {highlightedHtml ? (
333+ <div
334+ className="type-signature text-[11px] leading-relaxed bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_code]:!text-[11px] [&_code]:!leading-relaxed"
335+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
336+ />
337+ ) : (
338+ <pre className="text-[11px] font-mono leading-relaxed text-foreground/80 bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto whitespace-pre">
339+ {formatted}
340+ </pre>
341+ )}
342+ </div>
343+ );
344+}
345+
259346 // ── Inline tool detail (shown when tool row is expanded) ──
260347
261348 function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) {
@@ -289,26 +376,12 @@ function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) { · 27 unmodified lines
289376 )}
290377 </div>
291378
292− {/* Args / Returns rendered as highlighted TypeScript via Streamdown */}
379+ {/* Args / Returns — pretty-printed type signatures */}
293380 {tool.argsType && (
294− <div className="tool-description">
295− <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50 mb-1">
296− Arguments
297− </p>
298− <Streamdown plugins={{ code: codePlugin }}>
299− {"```ts\n" + tool.argsType + "\n```"}
300− </Streamdown>
301− </div>
381+ <TypeSignature raw={tool.argsType} label="Arguments" />
302382 )}
303383 {tool.returnsType && (
304− <div className="tool-description">
305− <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50 mb-1">
306− Returns
307− </p>
308− <Streamdown plugins={{ code: codePlugin }}>
309− {"```ts\n" + tool.returnsType + "\n```"}
310− </Streamdown>
311− </div>
384+ <TypeSignature raw={tool.returnsType} label="Returns" />
312385 )}
313386 </div>
314387 );
executor/apps/web/src/components/tools-view.tsx+34−6
@@ -207,18 +207,46 @@ function faviconForUrl(url: string | undefined | null): string | null { · 206 unmodified lines
207207 }
208208 }
209209
210+function isTemplateHostname(hostname: string): boolean {
211+ const lower = hostname.toLowerCase();
212+ return (
213+ lower === "localhost" ||
214+ lower === "example.com" ||
215+ lower.endsWith(".example.com") ||
216+ lower.startsWith("your-domain") ||
217+ lower.includes(".your-domain")
218+ );
219+}
220+
221+function pickFaviconTarget(urls: Array<string | undefined | null>): string | null {
222+ const parsed = urls
223+ .filter((value): value is string => Boolean(value))
224+ .map((value) => {
225+ try {
226+ return { value, hostname: new URL(value).hostname };
227+ } catch {
228+ return null;
229+ }
230+ })
231+ .filter((entry): entry is { value: string; hostname: string } => entry !== null);
232+
233+ const preferred = parsed.find((entry) => !isTemplateHostname(entry.hostname));
234+ if (preferred) return preferred.value;
235+ return parsed[0]?.value ?? null;
236+}
237+
210238 function getFaviconUrl(preset: ApiPreset): string | null {
211− return faviconForUrl(preset.baseUrl ?? preset.endpoint ?? preset.url);
239+ return faviconForUrl(pickFaviconTarget([preset.baseUrl, preset.endpoint, preset.url, preset.spec]));
212240 }
213241
214242 function getSourceFavicon(source: ToolSourceRecord): string | null {
215− const url =
243+ const urls =
216244 source.type === "mcp"
217− ? (source.config.url as string)
245+ ? [source.config.url as string]
218246 : source.type === "graphql"
219− ? (source.config.endpoint as string)
220− : (source.config.baseUrl as string) ?? (source.config.spec as string);
221− return faviconForUrl(url);
247+ ? [source.config.endpoint as string]
248+ : [source.config.baseUrl as string, source.config.spec as string];
249+ return faviconForUrl(pickFaviconTarget(urls));
222250 }
223251
224252 function sourceKeyForSource(source: ToolSourceRecord): string | null {
executor/convex.json+4−1
@@ -1,3 +1,6 @@
11 {
2− "functions": "convex"
2+ "functions": "convex",
3+ "codegen": {
4+ "staticApi": true
5+ }
36 }
executor/convex/_generated/api.d.ts+791−108
@@ -5,15 +5,11 @@ · 4 unmodified lines
55 * THIS CODE IS AUTOMATICALLY GENERATED.
66 *
77 * To regenerate, run `npx convex dev`.
8− *
9− * NOTE: This file has been post-processed by scripts/postcodegen.ts
10− * to replace recursive ApiFromModules/FilterApi types with explicit
11− * declarations, avoiding TS2589 depth errors.
12− *
138 * @module
149 */
1510
1611 import type { FunctionReference } from "convex/server";
12+import type { GenericId as Id } from "convex/values";
1713
1814 /**
1915 * A utility for referencing Convex functions in your app's public API.
@@ -25,75 +21,413 @@ import type { FunctionReference } from "convex/server"; · 5 unmodified lines
2521 */
2622 export declare const api: {
2723 app: {
28− getClientConfig: FunctionReference<"query", "public", any, any>;
29− getCurrentAccount: FunctionReference<"query", "public", any, any>;
24+ getClientConfig: FunctionReference<"query", "public", {}, any>;
25+ getCurrentAccount: FunctionReference<
26+ "query",
27+ "public",
28+ { sessionId?: string },
29+ any
30+ >;
3031 };
3132 auth: {
32− bootstrapCurrentWorkosAccount: FunctionReference<"mutation", "public", any, any>;
33+ bootstrapCurrentWorkosAccount: FunctionReference<
34+ "mutation",
35+ "public",
36+ {},
37+ any
38+ >;
3339 };
3440 billing: {
35− getSummary: FunctionReference<"query", "public", any, any>;
36− createSubscriptionCheckout: FunctionReference<"action", "public", any, any>;
37− createCustomerPortal: FunctionReference<"action", "public", any, any>;
38− retrySeatSync: FunctionReference<"mutation", "public", any, any>;
41+ createCustomerPortal: FunctionReference<
42+ "action",
43+ "public",
44+ {
45+ organizationId: Id<"organizations">;
46+ returnUrl?: string;
47+ sessionId?: string;
48+ },
49+ { url: string }
50+ >;
51+ createSubscriptionCheckout: FunctionReference<
52+ "action",
53+ "public",
54+ {
55+ cancelUrl?: string;
56+ organizationId: Id<"organizations">;
57+ priceId: string;
58+ sessionId?: string;
59+ successUrl?: string;
60+ },
61+ any
62+ >;
63+ getSummary: FunctionReference<
64+ "query",
65+ "public",
66+ { organizationId: Id<"organizations">; sessionId?: string },
67+ any
68+ >;
69+ retrySeatSync: FunctionReference<
70+ "mutation",
71+ "public",
72+ { organizationId: Id<"organizations">; sessionId?: string },
73+ any
74+ >;
3975 };
4076 credentialsNode: {
41− upsertCredential: FunctionReference<"action", "public", any, any>;
77+ upsertCredential: FunctionReference<
78+ "action",
79+ "public",
80+ {
81+ actorId?: string;
82+ id?: string;
83+ provider?: "managed" | "workos-vault";
84+ scope: "workspace" | "actor";
85+ secretJson: any;
86+ sessionId?: string;
87+ sourceKey: string;
88+ workspaceId: string;
89+ },
90+ any
91+ >;
4292 };
4393 database: {
44− createAgentTask: FunctionReference<"mutation", "public", any, any>;
45− getAgentTask: FunctionReference<"query", "public", any, any>;
46− updateAgentTask: FunctionReference<"mutation", "public", any, any>;
94+ createAgentTask: FunctionReference<
95+ "mutation",
96+ "public",
97+ {
98+ actorId: string;
99+ id: string;
100+ prompt: string;
101+ requesterId: string;
102+ workspaceId: string;
103+ },
104+ any
105+ >;
106+ getAgentTask: FunctionReference<
107+ "query",
108+ "public",
109+ { agentTaskId: string },
110+ any
111+ >;
112+ updateAgentTask: FunctionReference<
113+ "mutation",
114+ "public",
115+ {
116+ agentTaskId: string;
117+ codeRuns?: number;
118+ error?: string;
119+ resultText?: string;
120+ status?: "running" | "completed" | "failed";
121+ },
122+ any
123+ >;
47124 };
48125 executor: {
49− createTask: FunctionReference<"mutation", "public", any, any>;
50− resolveApproval: FunctionReference<"mutation", "public", any, any>;
126+ createTask: FunctionReference<
127+ "mutation",
128+ "public",
129+ {
130+ actorId?: string;
131+ clientId?: string;
132+ code: string;
133+ metadata?: any;
134+ runtimeId?: string;
135+ sessionId?: string;
136+ timeoutMs?: number;
137+ workspaceId: string;
138+ },
139+ any
140+ >;
141+ resolveApproval: FunctionReference<
142+ "mutation",
143+ "public",
144+ {
145+ approvalId: string;
146+ decision: "approved" | "denied";
147+ reason?: string;
148+ reviewerId?: string;
149+ sessionId?: string;
150+ workspaceId: string;
151+ },
152+ any
153+ >;
51154 };
52155 executorNode: {
53− listTools: FunctionReference<"action", "public", any, any>;
54− listToolsWithWarnings: FunctionReference<"action", "public", any, any>;
156+ listTools: FunctionReference<
157+ "action",
158+ "public",
159+ {
160+ actorId?: string;
161+ clientId?: string;
162+ sessionId?: string;
163+ workspaceId: string;
164+ },
165+ any
166+ >;
167+ listToolsWithWarnings: FunctionReference<
168+ "action",
169+ "public",
170+ {
171+ actorId?: string;
172+ clientId?: string;
173+ sessionId?: string;
174+ workspaceId: string;
175+ },
176+ any
177+ >;
55178 };
56179 invites: {
57− list: FunctionReference<"query", "public", any, any>;
58− create: FunctionReference<"mutation", "public", any, any>;
59− revoke: FunctionReference<"mutation", "public", any, any>;
180+ create: FunctionReference<
181+ "mutation",
182+ "public",
183+ {
184+ email: string;
185+ expiresInDays?: number;
186+ organizationId: Id<"organizations">;
187+ role: "owner" | "admin" | "member" | "billing_admin";
188+ sessionId?: string;
189+ workspaceId?: Id<"workspaces">;
190+ },
191+ any
192+ >;
193+ list: FunctionReference<
194+ "query",
195+ "public",
196+ { organizationId: Id<"organizations">; sessionId?: string },
197+ any
198+ >;
199+ revoke: FunctionReference<
200+ "mutation",
201+ "public",
202+ {
203+ inviteId: Id<"invites">;
204+ organizationId: Id<"organizations">;
205+ sessionId?: string;
206+ },
207+ any
208+ >;
60209 };
61210 organizationMembers: {
62− list: FunctionReference<"query", "public", any, any>;
63− updateRole: FunctionReference<"mutation", "public", any, any>;
64− updateBillable: FunctionReference<"mutation", "public", any, any>;
65− remove: FunctionReference<"mutation", "public", any, any>;
211+ list: FunctionReference<
212+ "query",
213+ "public",
214+ { organizationId: Id<"organizations">; sessionId?: string },
215+ any
216+ >;
217+ remove: FunctionReference<
218+ "mutation",
219+ "public",
220+ {
221+ accountId: Id<"accounts">;
222+ organizationId: Id<"organizations">;
223+ sessionId?: string;
224+ },
225+ any
226+ >;
227+ updateBillable: FunctionReference<
228+ "mutation",
229+ "public",
230+ {
231+ accountId: Id<"accounts">;
232+ billable: boolean;
233+ organizationId: Id<"organizations">;
234+ sessionId?: string;
235+ },
236+ any
237+ >;
238+ updateRole: FunctionReference<
239+ "mutation",
240+ "public",
241+ {
242+ accountId: Id<"accounts">;
243+ organizationId: Id<"organizations">;
244+ role: "owner" | "admin" | "member" | "billing_admin";
245+ sessionId?: string;
246+ },
247+ any
248+ >;
66249 };
67250 organizations: {
68− create: FunctionReference<"mutation", "public", any, any>;
69− listMine: FunctionReference<"query", "public", any, any>;
70− getNavigationState: FunctionReference<"query", "public", any, any>;
71− getOrganizationAccess: FunctionReference<"query", "public", any, any>;
72− resolveWorkosOrganizationId: FunctionReference<"query", "public", any, any>;
251+ create: FunctionReference<
252+ "mutation",
253+ "public",
254+ { name: string; sessionId?: string },
255+ any
256+ >;
257+ getNavigationState: FunctionReference<
258+ "query",
259+ "public",
260+ { sessionId?: string },
261+ any
262+ >;
263+ getOrganizationAccess: FunctionReference<
264+ "query",
265+ "public",
266+ { organizationId: Id<"organizations">; sessionId?: string },
267+ any
268+ >;
269+ listMine: FunctionReference<"query", "public", { sessionId?: string }, any>;
270+ resolveWorkosOrganizationId: FunctionReference<
271+ "query",
272+ "public",
273+ { organizationId: Id<"organizations">; sessionId?: string },
274+ any
275+ >;
73276 };
74277 workspace: {
75− bootstrapAnonymousSession: FunctionReference<"mutation", "public", any, any>;
76− listRuntimeTargets: FunctionReference<"query", "public", any, any>;
77− getTask: FunctionReference<"query", "public", any, any>;
78− getTaskInWorkspace: FunctionReference<"query", "public", any, any>;
79− listTasks: FunctionReference<"query", "public", any, any>;
80− listApprovals: FunctionReference<"query", "public", any, any>;
81− listPendingApprovals: FunctionReference<"query", "public", any, any>;
82− listTaskEvents: FunctionReference<"query", "public", any, any>;
83− upsertAccessPolicy: FunctionReference<"mutation", "public", any, any>;
84− listAccessPolicies: FunctionReference<"query", "public", any, any>;
85− upsertCredential: FunctionReference<"mutation", "public", any, any>;
86− listCredentials: FunctionReference<"query", "public", any, any>;
87− listCredentialProviders: FunctionReference<"query", "public", any, any>;
88− resolveCredential: FunctionReference<"query", "public", any, any>;
89− upsertToolSource: FunctionReference<"mutation", "public", any, any>;
90− listToolSources: FunctionReference<"query", "public", any, any>;
91− deleteToolSource: FunctionReference<"mutation", "public", any, any>;
278+ bootstrapAnonymousSession: FunctionReference<
279+ "mutation",
280+ "public",
281+ { sessionId?: string },
282+ any
283+ >;
284+ deleteToolSource: FunctionReference<
285+ "mutation",
286+ "public",
287+ { sessionId?: string; sourceId: string; workspaceId: string },
288+ any
289+ >;
290+ getTask: FunctionReference<
291+ "query",
292+ "public",
293+ { sessionId?: string; taskId: string; workspaceId: string },
294+ any
295+ >;
296+ getTaskInWorkspace: FunctionReference<
297+ "query",
298+ "public",
299+ { sessionId?: string; taskId: string; workspaceId: string },
300+ any
301+ >;
302+ listAccessPolicies: FunctionReference<
303+ "query",
304+ "public",
305+ { sessionId?: string; workspaceId: string },
306+ any
307+ >;
308+ listApprovals: FunctionReference<
309+ "query",
310+ "public",
311+ {
312+ sessionId?: string;
313+ status?: "pending" | "approved" | "denied";
314+ workspaceId: string;
315+ },
316+ any
317+ >;
318+ listCredentialProviders: FunctionReference<"query", "public", {}, any>;
319+ listCredentials: FunctionReference<
320+ "query",
321+ "public",
322+ { sessionId?: string; workspaceId: string },
323+ any
324+ >;
325+ listPendingApprovals: FunctionReference<
326+ "query",
327+ "public",
328+ { sessionId?: string; workspaceId: string },
329+ any
330+ >;
331+ listRuntimeTargets: FunctionReference<"query", "public", {}, any>;
332+ listTaskEvents: FunctionReference<
333+ "query",
334+ "public",
335+ { sessionId?: string; taskId: string; workspaceId: string },
336+ any
337+ >;
338+ listTasks: FunctionReference<
339+ "query",
340+ "public",
341+ { sessionId?: string; workspaceId: string },
342+ any
343+ >;
344+ listToolSources: FunctionReference<
345+ "query",
346+ "public",
347+ { sessionId?: string; workspaceId: string },
348+ any
349+ >;
350+ resolveCredential: FunctionReference<
351+ "query",
352+ "public",
353+ {
354+ actorId?: string;
355+ scope: "workspace" | "actor";
356+ sessionId?: string;
357+ sourceKey: string;
358+ workspaceId: string;
359+ },
360+ any
361+ >;
362+ upsertAccessPolicy: FunctionReference<
363+ "mutation",
364+ "public",
365+ {
366+ actorId?: string;
367+ clientId?: string;
368+ decision: "allow" | "require_approval" | "deny";
369+ id?: string;
370+ priority?: number;
371+ sessionId?: string;
372+ toolPathPattern: string;
373+ workspaceId: string;
374+ },
375+ any
376+ >;
377+ upsertCredential: FunctionReference<
378+ "mutation",
379+ "public",
380+ {
381+ actorId?: string;
382+ id?: string;
383+ provider?: "managed" | "workos-vault";
384+ scope: "workspace" | "actor";
385+ secretJson: any;
386+ sessionId?: string;
387+ sourceKey: string;
388+ workspaceId: string;
389+ },
390+ any
391+ >;
392+ upsertToolSource: FunctionReference<
393+ "mutation",
394+ "public",
395+ {
396+ config: any;
397+ enabled?: boolean;
398+ id?: string;
399+ name: string;
400+ sessionId?: string;
401+ type: "mcp" | "openapi" | "graphql";
402+ workspaceId: string;
403+ },
404+ any
405+ >;
92406 };
93407 workspaces: {
94− create: FunctionReference<"mutation", "public", any, any>;
95− list: FunctionReference<"query", "public", any, any>;
96− generateWorkspaceIconUploadUrl: FunctionReference<"mutation", "public", any, any>;
408+ create: FunctionReference<
409+ "mutation",
410+ "public",
411+ {
412+ iconStorageId?: Id<"_storage">;
413+ name: string;
414+ organizationId?: Id<"organizations">;
415+ sessionId?: string;
416+ },
417+ any
418+ >;
419+ generateWorkspaceIconUploadUrl: FunctionReference<
420+ "mutation",
421+ "public",
422+ { sessionId?: string },
423+ any
424+ >;
425+ list: FunctionReference<
426+ "query",
427+ "public",
428+ { organizationId?: Id<"organizations">; sessionId?: string },
429+ any
430+ >;
97431 };
98432 };
99433
@@ -107,79 +441,429 @@ export declare const api: { · 7 unmodified lines
107441 */
108442 export declare const internal: {
109443 auth: {
110− authKitEvent: FunctionReference<"mutation", "internal", any, any>;
444+ authKitEvent: FunctionReference<
445+ "mutation",
446+ "internal",
447+ { data: Record<string, any>; event: string },
448+ null
449+ >;
111450 };
112451 billingInternal: {
113− getBillingAccessForRequest: FunctionReference<"query", "internal", any, any>;
114− getSeatSyncSnapshot: FunctionReference<"query", "internal", any, any>;
115− upsertCustomerLink: FunctionReference<"mutation", "internal", any, any>;
116− bumpSeatSyncVersion: FunctionReference<"mutation", "internal", any, any>;
117− upsertSeatState: FunctionReference<"mutation", "internal", any, any>;
452+ bumpSeatSyncVersion: FunctionReference<
453+ "mutation",
454+ "internal",
455+ { organizationId: Id<"organizations"> },
456+ any
457+ >;
458+ getBillingAccessForRequest: FunctionReference<
459+ "query",
460+ "internal",
461+ { organizationId: Id<"organizations">; sessionId?: string },
462+ any
463+ >;
464+ getSeatSyncSnapshot: FunctionReference<
465+ "query",
466+ "internal",
467+ { organizationId: Id<"organizations"> },
468+ any
469+ >;
470+ upsertCustomerLink: FunctionReference<
471+ "mutation",
472+ "internal",
473+ { organizationId: Id<"organizations">; stripeCustomerId: string },
474+ any
475+ >;
476+ upsertSeatState: FunctionReference<
477+ "mutation",
478+ "internal",
479+ {
480+ bumpVersion: boolean;
481+ desiredSeats: number;
482+ lastAppliedSeats: number | null;
483+ organizationId: Id<"organizations">;
484+ syncError: string | null;
485+ },
486+ any
487+ >;
118488 };
119489 billingSync: {
120− syncSeatQuantity: FunctionReference<"action", "internal", any, any>;
490+ syncSeatQuantity: FunctionReference<
491+ "action",
492+ "internal",
493+ { expectedVersion: number; organizationId: Id<"organizations"> },
494+ any
495+ >;
121496 };
122497 database: {
123− createTask: FunctionReference<"mutation", "internal", any, any>;
124− getTask: FunctionReference<"query", "internal", any, any>;
125− listTasks: FunctionReference<"query", "internal", any, any>;
126− listQueuedTaskIds: FunctionReference<"query", "internal", any, any>;
127− listRuntimeTargets: FunctionReference<"query", "internal", any, any>;
128− getTaskInWorkspace: FunctionReference<"query", "internal", any, any>;
129− markTaskRunning: FunctionReference<"mutation", "internal", any, any>;
130− markTaskFinished: FunctionReference<"mutation", "internal", any, any>;
131− createApproval: FunctionReference<"mutation", "internal", any, any>;
132− getApproval: FunctionReference<"query", "internal", any, any>;
133− listApprovals: FunctionReference<"query", "internal", any, any>;
134− listPendingApprovals: FunctionReference<"query", "internal", any, any>;
135− resolveApproval: FunctionReference<"mutation", "internal", any, any>;
136− getApprovalInWorkspace: FunctionReference<"query", "internal", any, any>;
137− bootstrapAnonymousSession: FunctionReference<"mutation", "internal", any, any>;
138− upsertAccessPolicy: FunctionReference<"mutation", "internal", any, any>;
139− listAccessPolicies: FunctionReference<"query", "internal", any, any>;
140− upsertCredential: FunctionReference<"mutation", "internal", any, any>;
141− listCredentials: FunctionReference<"query", "internal", any, any>;
142− listCredentialProviders: FunctionReference<"query", "internal", any, any>;
143− resolveCredential: FunctionReference<"query", "internal", any, any>;
144− upsertToolSource: FunctionReference<"mutation", "internal", any, any>;
145− listToolSources: FunctionReference<"query", "internal", any, any>;
146− deleteToolSource: FunctionReference<"mutation", "internal", any, any>;
147− createTaskEvent: FunctionReference<"mutation", "internal", any, any>;
148− listTaskEvents: FunctionReference<"query", "internal", any, any>;
498+ bootstrapAnonymousSession: FunctionReference<
499+ "mutation",
500+ "internal",
501+ { sessionId?: string },
502+ any
503+ >;
504+ createApproval: FunctionReference<
505+ "mutation",
506+ "internal",
507+ { id: string; input?: any; taskId: string; toolPath: string },
508+ any
509+ >;
510+ createTask: FunctionReference<
511+ "mutation",
512+ "internal",
513+ {
514+ actorId: string;
515+ clientId?: string;
516+ code: string;
517+ id: string;
518+ metadata?: any;
519+ runtimeId: string;
520+ timeoutMs?: number;
521+ workspaceId: string;
522+ },
523+ any
524+ >;
525+ createTaskEvent: FunctionReference<
526+ "mutation",
527+ "internal",
528+ { eventName: string; payload: any; taskId: string; type: string },
529+ any
530+ >;
531+ deleteToolSource: FunctionReference<
532+ "mutation",
533+ "internal",
534+ { sourceId: string; workspaceId: string },
535+ any
536+ >;
537+ getApproval: FunctionReference<
538+ "query",
539+ "internal",
540+ { approvalId: string },
541+ any
542+ >;
543+ getApprovalInWorkspace: FunctionReference<
544+ "query",
545+ "internal",
546+ { approvalId: string; workspaceId: string },
547+ any
548+ >;
549+ getTask: FunctionReference<"query", "internal", { taskId: string }, any>;
550+ getTaskInWorkspace: FunctionReference<
551+ "query",
552+ "internal",
553+ { taskId: string; workspaceId: string },
554+ any
555+ >;
556+ listAccessPolicies: FunctionReference<
557+ "query",
558+ "internal",
559+ { workspaceId: string },
560+ any
561+ >;
562+ listApprovals: FunctionReference<
563+ "query",
564+ "internal",
565+ { status?: "pending" | "approved" | "denied"; workspaceId: string },
566+ any
567+ >;
568+ listCredentialProviders: FunctionReference<"query", "internal", {}, any>;
569+ listCredentials: FunctionReference<
570+ "query",
571+ "internal",
572+ { workspaceId: string },
573+ any
574+ >;
575+ listPendingApprovals: FunctionReference<
576+ "query",
577+ "internal",
578+ { workspaceId: string },
579+ any
580+ >;
581+ listQueuedTaskIds: FunctionReference<
582+ "query",
583+ "internal",
584+ { limit?: number },
585+ any
586+ >;
587+ listRuntimeTargets: FunctionReference<"query", "internal", {}, any>;
588+ listTaskEvents: FunctionReference<
589+ "query",
590+ "internal",
591+ { taskId: string },
592+ any
593+ >;
594+ listTasks: FunctionReference<
595+ "query",
596+ "internal",
597+ { workspaceId: string },
598+ any
599+ >;
600+ listToolSources: FunctionReference<
601+ "query",
602+ "internal",
603+ { workspaceId: string },
604+ any
605+ >;
606+ markTaskFinished: FunctionReference<
607+ "mutation",
608+ "internal",
609+ {
610+ error?: string;
611+ exitCode?: number;
612+ status: "completed" | "failed" | "timed_out" | "denied";
613+ stderr: string;
614+ stdout: string;
615+ taskId: string;
616+ },
617+ any
618+ >;
619+ markTaskRunning: FunctionReference<
620+ "mutation",
621+ "internal",
622+ { taskId: string },
623+ any
624+ >;
625+ resolveApproval: FunctionReference<
626+ "mutation",
627+ "internal",
628+ {
629+ approvalId: string;
630+ decision: "approved" | "denied";
631+ reason?: string;
632+ reviewerId?: string;
633+ },
634+ any
635+ >;
636+ resolveCredential: FunctionReference<
637+ "query",
638+ "internal",
639+ {
640+ actorId?: string;
641+ scope: "workspace" | "actor";
642+ sourceKey: string;
643+ workspaceId: string;
644+ },
645+ any
646+ >;
647+ upsertAccessPolicy: FunctionReference<
648+ "mutation",
649+ "internal",
650+ {
651+ actorId?: string;
652+ clientId?: string;
653+ decision: "allow" | "require_approval" | "deny";
654+ id?: string;
655+ priority?: number;
656+ toolPathPattern: string;
657+ workspaceId: string;
658+ },
659+ any
660+ >;
661+ upsertCredential: FunctionReference<
662+ "mutation",
663+ "internal",
664+ {
665+ actorId?: string;
666+ id?: string;
667+ provider?: "managed" | "workos-vault";
668+ scope: "workspace" | "actor";
669+ secretJson: any;
670+ sourceKey: string;
671+ workspaceId: string;
672+ },
673+ any
674+ >;
675+ upsertToolSource: FunctionReference<
676+ "mutation",
677+ "internal",
678+ {
679+ config: any;
680+ enabled?: boolean;
681+ id?: string;
682+ name: string;
683+ type: "mcp" | "openapi" | "graphql";
684+ workspaceId: string;
685+ },
686+ any
687+ >;
149688 };
150689 executor: {
151− createTaskInternal: FunctionReference<"mutation", "internal", any, any>;
152− resolveApprovalInternal: FunctionReference<"mutation", "internal", any, any>;
153− appendRuntimeOutput: FunctionReference<"mutation", "internal", any, any>;
690+ appendRuntimeOutput: FunctionReference<
691+ "mutation",
692+ "internal",
693+ {
694+ line: string;
695+ runId: string;
696+ stream: "stdout" | "stderr";
697+ timestamp?: number;
698+ },
699+ any
700+ >;
701+ createTaskInternal: FunctionReference<
702+ "mutation",
703+ "internal",
704+ {
705+ actorId: string;
706+ clientId?: string;
707+ code: string;
708+ metadata?: any;
709+ runtimeId?: string;
710+ timeoutMs?: number;
711+ workspaceId: string;
712+ },
713+ any
714+ >;
715+ resolveApprovalInternal: FunctionReference<
716+ "mutation",
717+ "internal",
718+ {
719+ approvalId: string;
720+ decision: "approved" | "denied";
721+ reason?: string;
722+ reviewerId?: string;
723+ workspaceId: string;
724+ },
725+ any
726+ >;
154727 };
155728 executorNode: {
156− listToolsInternal: FunctionReference<"action", "internal", any, any>;
157− listToolsWithWarningsInternal: FunctionReference<"action", "internal", any, any>;
158− handleExternalToolCall: FunctionReference<"action", "internal", any, any>;
159− runTask: FunctionReference<"action", "internal", any, any>;
729+ handleExternalToolCall: FunctionReference<
730+ "action",
731+ "internal",
732+ { callId: string; input?: any; runId: string; toolPath: string },
733+ any
734+ >;
735+ listToolsInternal: FunctionReference<
736+ "action",
737+ "internal",
738+ { actorId?: string; clientId?: string; workspaceId: string },
739+ any
740+ >;
741+ listToolsWithWarningsInternal: FunctionReference<
742+ "action",
743+ "internal",
744+ { actorId?: string; clientId?: string; workspaceId: string },
745+ any
746+ >;
747+ runTask: FunctionReference<"action", "internal", { taskId: string }, any>;
160748 };
161749 invites: {
162− deliverWorkosInvite: FunctionReference<"action", "internal", any, any>;
163− revokeWorkosInvite: FunctionReference<"action", "internal", any, any>;
164− getInviteDeliveryContext: FunctionReference<"query", "internal", any, any>;
165− linkOrganizationToWorkos: FunctionReference<"mutation", "internal", any, any>;
166− getInviteById: FunctionReference<"query", "internal", any, any>;
167− markInviteDelivered: FunctionReference<"mutation", "internal", any, any>;
168− markInviteDeliveryFailed: FunctionReference<"mutation", "internal", any, any>;
750+ deliverWorkosInvite: FunctionReference<
751+ "action",
752+ "internal",
753+ {
754+ expiresInDays?: number;
755+ inviteId: Id<"invites">;
756+ inviterWorkosUserId: string;
757+ roleSlug?: string;
758+ },
759+ any
760+ >;
761+ getInviteById: FunctionReference<
762+ "query",
763+ "internal",
764+ { inviteId: Id<"invites"> },
765+ any
766+ >;
767+ getInviteDeliveryContext: FunctionReference<
768+ "query",
769+ "internal",
770+ { inviteId: Id<"invites"> },
771+ any
772+ >;
773+ linkOrganizationToWorkos: FunctionReference<
774+ "mutation",
775+ "internal",
776+ {
777+ organizationId: Id<"organizations">;
778+ workosOrgId: string;
779+ workspaceId?: Id<"workspaces">;
780+ },
781+ any
782+ >;
783+ markInviteDelivered: FunctionReference<
784+ "mutation",
785+ "internal",
786+ { inviteId: Id<"invites">; providerInviteId: string },
787+ any
788+ >;
789+ markInviteDeliveryFailed: FunctionReference<
790+ "mutation",
791+ "internal",
792+ { errorMessage: string; inviteId: Id<"invites"> },
793+ any
794+ >;
795+ revokeWorkosInvite: FunctionReference<
796+ "action",
797+ "internal",
798+ { inviteId: Id<"invites">; providerInviteId: string },
799+ any
800+ >;
169801 };
170802 openApiSpecCache: {
171− getEntry: FunctionReference<"query", "internal", any, any>;
172− putEntry: FunctionReference<"mutation", "internal", any, any>;
173− pruneExpired: FunctionReference<"mutation", "internal", any, any>;
803+ getEntry: FunctionReference<
804+ "query",
805+ "internal",
806+ { maxAgeMs: number; specUrl: string; version: string },
807+ any
808+ >;
809+ pruneExpired: FunctionReference<
810+ "mutation",
811+ "internal",
812+ { batchSize?: number; maxAgeMs: number },
813+ any
814+ >;
815+ putEntry: FunctionReference<
816+ "mutation",
817+ "internal",
818+ {
819+ sizeBytes: number;
820+ specUrl: string;
821+ storageId: Id<"_storage">;
822+ version: string;
823+ },
824+ any
825+ >;
174826 };
175827 workspaceAuthInternal: {
176− getWorkspaceAccessForRequest: FunctionReference<"query", "internal", any, any>;
177− getWorkspaceAccessForWorkosSubject: FunctionReference<"query", "internal", any, any>;
828+ getWorkspaceAccessForRequest: FunctionReference<
829+ "query",
830+ "internal",
831+ { sessionId?: string; workspaceId: string },
832+ any
833+ >;
834+ getWorkspaceAccessForWorkosSubject: FunctionReference<
835+ "query",
836+ "internal",
837+ { subject: string; workspaceId: string },
838+ any
839+ >;
178840 };
179841 workspaceToolCache: {
180− getEntry: FunctionReference<"query", "internal", any, any>;
181− putEntry: FunctionReference<"mutation", "internal", any, any>;
182− getDtsStorageIds: FunctionReference<"query", "internal", any, any>;
842+ getDtsStorageIds: FunctionReference<
843+ "query",
844+ "internal",
845+ { workspaceId: string },
846+ any
847+ >;
848+ getEntry: FunctionReference<
849+ "query",
850+ "internal",
851+ { signature: string; workspaceId: string },
852+ any
853+ >;
854+ putEntry: FunctionReference<
855+ "mutation",
856+ "internal",
857+ {
858+ dtsStorageIds?: Array<{ sourceKey: string; storageId: Id<"_storage"> }>;
859+ signature: string;
860+ sizeBytes: number;
861+ storageId: Id<"_storage">;
862+ toolCount: number;
863+ workspaceId: string;
864+ },
865+ any
866+ >;
183867 };
184868 };
185869
@@ -573,4 +1257,3 @@ export declare const components: { · 387 unmodified lines
5731257 };
5741258 };
5751259 };
576−
executor/convex/billing.ts+9−3
@@ -115,8 +115,14 @@ export const createSubscriptionCheckout = action({ · 114 unmodified lines
115115 }
116116
117117 const quantity = Math.max(1, access.billableMembers);
118− const successUrl = args.successUrl ?? process.env.BILLING_SUCCESS_URL ?? "http://localhost:3000/billing?success=true";
119− const cancelUrl = args.cancelUrl ?? process.env.BILLING_CANCEL_URL ?? "http://localhost:3000/billing?canceled=true";
118+ const successUrl =
119+ args.successUrl
120+ ?? process.env.BILLING_SUCCESS_URL
121+ ?? "http://localhost:3000/organization?tab=billing&success=true";
122+ const cancelUrl =
123+ args.cancelUrl
124+ ?? process.env.BILLING_CANCEL_URL
125+ ?? "http://localhost:3000/organization?tab=billing&canceled=true";
120126
121127 const session = await stripeClient.createCheckoutSession(ctx, {
122128 priceId: args.priceId,
@@ -187,7 +193,7 @@ export const createCustomerPortal = action({ · 64 unmodified lines
187193
188194 return await stripeClient.createCustomerPortalSession(ctx, {
189195 customerId,
190− returnUrl: args.returnUrl ?? process.env.BILLING_RETURN_URL ?? "http://localhost:3000/billing",
196+ returnUrl: args.returnUrl ?? process.env.BILLING_RETURN_URL ?? "http://localhost:3000/organization?tab=billing",
191197 });
192198 },
193199 });
executor/convex/executorNode.ts+2−2
@@ -56,7 +56,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 55 unmodified lines
5656 }
5757
5858 function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
59− const signatureVersion = "v2";
59+ const signatureVersion = "v4";
6060 const parts = sources
6161 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
6262 .sort();
@@ -131,7 +131,7 @@ interface DtsStorageEntry { · 68 unmodified lines
131131 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
132132
133133 /** Cache version — bump when PreparedOpenApiSpec shape changes. */
134−const OPENAPI_CACHE_VERSION = "v4";
134+const OPENAPI_CACHE_VERSION = "v7";
135135
136136 async function publish(
137137 ctx: any,
executor/lib/openapi-cache.test.ts+108−0
@@ -314,6 +314,114 @@ describe("buildOpenApiToolsFromPrepared", () => { · 313 unmodified lines
314314 }
315315 });
316316
317+ test("resolves shared parameter refs and maps 204 responses to void", async () => {
318+ const spec: Record<string, unknown> = {
319+ openapi: "3.0.3",
320+ info: { title: "GitHub-like", version: "1.0.0" },
321+ servers: [{ url: "https://api.example.com" }],
322+ components: {
323+ parameters: {
324+ owner: {
325+ name: "owner",
326+ in: "path",
327+ required: true,
328+ schema: { type: "string" },
329+ },
330+ repo: {
331+ name: "repo",
332+ in: "path",
333+ required: true,
334+ schema: { type: "string" },
335+ },
336+ },
337+ },
338+ paths: {
339+ "/repos/{owner}/{repo}/subscription": {
340+ parameters: [
341+ { $ref: "#/components/parameters/owner" },
342+ { $ref: "#/components/parameters/repo" },
343+ ],
344+ delete: {
345+ operationId: "activity/delete-repo-subscription",
346+ tags: ["activity"],
347+ responses: {
348+ "204": { description: "No Content" },
349+ },
350+ },
351+ },
352+ },
353+ };
354+
355+ const prepared = await prepareOpenApiSpec(spec, "github-like");
356+ const tools = buildOpenApiToolsFromPrepared(
357+ {
358+ type: "openapi",
359+ name: "github",
360+ spec,
361+ baseUrl: "https://api.example.com",
362+ },
363+ prepared,
364+ );
365+
366+ const tool = tools.find((t) => t.metadata?.operationId === "activity/delete-repo-subscription");
367+ expect(tool).toBeDefined();
368+ expect(tool!.metadata?.argsType).toContain("owner");
369+ expect(tool!.metadata?.argsType).toContain("repo");
370+ expect(tool!.metadata?.returnsType).toBe("void");
371+ });
372+
373+ test("supports Swagger 2.x parameter and response schemas for type hints", async () => {
374+ const spec: Record<string, unknown> = {
375+ swagger: "2.0",
376+ info: { title: "Slack-like", version: "1.0.0" },
377+ host: "api.example.com",
378+ schemes: ["https"],
379+ basePath: "/api",
380+ paths: {
381+ "/admin.apps.approved.list": {
382+ get: {
383+ operationId: "admin_apps_approved_list",
384+ tags: ["admin.apps.approved"],
385+ parameters: [
386+ { name: "token", in: "query", required: true, type: "string" },
387+ { name: "limit", in: "query", required: false, type: "integer" },
388+ ],
389+ responses: {
390+ "200": {
391+ description: "ok",
392+ schema: {
393+ type: "object",
394+ properties: {
395+ ok: { type: "boolean" },
396+ },
397+ required: ["ok"],
398+ },
399+ },
400+ },
401+ },
402+ },
403+ },
404+ };
405+
406+ const prepared = await prepareOpenApiSpec(spec, "slack-like");
407+ const tools = buildOpenApiToolsFromPrepared(
408+ {
409+ type: "openapi",
410+ name: "slack",
411+ spec,
412+ baseUrl: "https://api.example.com/api",
413+ },
414+ prepared,
415+ );
416+
417+ const tool = tools.find((t) => t.metadata?.operationId === "admin_apps_approved_list");
418+ expect(tool).toBeDefined();
419+ expect(tool!.metadata?.argsType).toContain("token: string");
420+ expect(tool!.metadata?.argsType).toContain("limit?: number");
421+ expect(tool!.metadata?.returnsType).toContain("ok");
422+ expect(tool!.metadata?.returnsType).not.toBe("unknown");
423+ });
424+
317425 test("schemaTypes only attached to first tool from a source", async () => {
318426 const specWithSchemas: Record<string, unknown> = {
319427 openapi: "3.0.3",
executor/lib/openapi-real-specs.test.ts+70−0
@@ -154,6 +154,16 @@ describe("real-world OpenAPI specs", () => { · 153 unmodified lines
154154 // The sourceDts should contain operations interface
155155 const dts = withSourceDts[0].metadata!.sourceDts!;
156156 expect(dts).toContain("operations");
157+
158+ // Real specs should produce useful type hints for at least some operations.
159+ const typedInputs = tools.filter(
160+ (t) => t.metadata?.argsType && t.metadata.argsType !== "Record<string, unknown>",
161+ );
162+ const typedOutputs = tools.filter(
163+ (t) => t.metadata?.returnsType && t.metadata.returnsType !== "unknown",
164+ );
165+ expect(typedInputs.length).toBeGreaterThan(0);
166+ expect(typedOutputs.length).toBeGreaterThan(0);
157167 }
158168
159169 if (prepared.warnings.length > 0) {
@@ -164,4 +174,64 @@ describe("real-world OpenAPI specs", () => { · 4 unmodified lines
164174 300_000,
165175 );
166176 }
177+
178+ test(
179+ "github: delete repo subscription keeps typed args and void return",
180+ async () => {
181+ const githubUrl =
182+ "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml";
183+
184+ const prepared = await prepareOpenApiSpec(githubUrl, "github");
185+ const tools = buildOpenApiToolsFromPrepared(
186+ {
187+ type: "openapi",
188+ name: "github",
189+ spec: githubUrl,
190+ baseUrl: prepared.servers[0] || "https://api.github.com",
191+ },
192+ prepared,
193+ );
194+
195+ const tool = tools.find(
196+ (t) => t.metadata?.operationId === "activity/delete-repo-subscription",
197+ );
198+
199+ expect(tool).toBeDefined();
200+ expect(tool!.path).toBe("github.activity.activity_delete_repo_subscription");
201+ expect(tool!.metadata!.argsType).toContain("owner");
202+ expect(tool!.metadata!.argsType).toContain("repo");
203+ expect(tool!.metadata!.argsType).not.toBe("Record<string, unknown>");
204+ expect(tool!.metadata!.returnsType).toBe("void");
205+ },
206+ 300_000,
207+ );
208+
209+ test(
210+ "slack: approved apps list keeps typed query params and non-unknown output",
211+ async () => {
212+ const slackUrl = "https://api.slack.com/specs/openapi/v2/slack_web.json";
213+
214+ const prepared = await prepareOpenApiSpec(slackUrl, "slack");
215+ const tools = buildOpenApiToolsFromPrepared(
216+ {
217+ type: "openapi",
218+ name: "slack",
219+ spec: slackUrl,
220+ baseUrl: "https://slack.com/api",
221+ },
222+ prepared,
223+ );
224+
225+ const tool = tools.find(
226+ (t) => t.metadata?.operationId === "admin_apps_approved_list",
227+ );
228+
229+ expect(tool).toBeDefined();
230+ expect(tool!.path).toBe("slack.admin_apps_approved.admin_apps_approved_list");
231+ expect(tool!.metadata!.argsType).toContain("token: string");
232+ expect(tool!.metadata!.argsType).toContain("limit?: number");
233+ expect(tool!.metadata!.returnsType).not.toBe("unknown");
234+ },
235+ 300_000,
236+ );
167237 });
executor/lib/tool_sources.ts+112−10
@@ -232,6 +232,78 @@ function getPreferredContentSchema(content: Record<string, unknown>): Record<str · 231 unmodified lines
232232 return {};
233233 }
234234
235+function getPreferredResponseSchema(responseValue: Record<string, unknown>): Record<string, unknown> {
236+ // OpenAPI 3.x response shape
237+ const contentSchema = getPreferredContentSchema(asRecord(responseValue.content));
238+ if (Object.keys(contentSchema).length > 0) {
239+ return contentSchema;
240+ }
241+
242+ // Swagger 2.x response shape
243+ const schema = asRecord(responseValue.schema);
244+ if (Object.keys(schema).length > 0) {
245+ return schema;
246+ }
247+
248+ return {};
249+}
250+
251+function resolveSchemaRef(
252+ schema: Record<string, unknown>,
253+ componentSchemas: Record<string, unknown>,
254+): Record<string, unknown> {
255+ const ref = typeof schema.$ref === "string" ? schema.$ref : "";
256+ const prefix = "#/components/schemas/";
257+ if (!ref.startsWith(prefix)) {
258+ return schema;
259+ }
260+
261+ const key = ref.slice(prefix.length);
262+ const resolved = asRecord(componentSchemas[key]);
263+ if (Object.keys(resolved).length === 0) {
264+ return schema;
265+ }
266+ return resolved;
267+}
268+
269+function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> {
270+ // OpenAPI 3.x (and Swagger body params) use `schema`
271+ const schema = asRecord(entry.schema);
272+ if (Object.keys(schema).length > 0) {
273+ return schema;
274+ }
275+
276+ // Swagger 2.x non-body params use top-level `type`, `enum`, `items`, etc.
277+ const type = typeof entry.type === "string" ? entry.type : "";
278+ if (!type) {
279+ return {};
280+ }
281+
282+ const fallback: Record<string, unknown> = { type };
283+ if (Array.isArray(entry.enum) && entry.enum.length > 0) {
284+ fallback.enum = entry.enum;
285+ }
286+ const items = asRecord(entry.items);
287+ if (Object.keys(items).length > 0) {
288+ fallback.items = items;
289+ }
290+
291+ return fallback;
292+}
293+
294+function responseTypeHintFromSchema(responseSchema: Record<string, unknown>, responseStatus: string): string {
295+ if (Object.keys(responseSchema).length > 0) {
296+ return jsonSchemaTypeHintFallback(responseSchema);
297+ }
298+
299+ // No-content success responses should be represented as void.
300+ if (responseStatus === "204" || responseStatus === "205") {
301+ return "void";
302+ }
303+
304+ return "unknown";
305+}
306+
235307 /** Simple depth-limited type hint generator for schemas (used as fallback) */
236308 function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string {
237309 if (!schema || typeof schema !== "object") return "unknown";
@@ -254,6 +326,7 @@ function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string { · 16 unmodified lines
254326 }
255327
256328 const type = typeof shape.type === "string" ? shape.type : undefined;
329+ if (type === "integer") return "number";
257330 if (type === "string" || type === "number" || type === "boolean" || type === "null") {
258331 return type;
259332 }
@@ -466,21 +539,39 @@ function buildOpenApiUrl( · 206 unmodified lines
466539 function compactOpenApiPaths(
467540 pathsValue: unknown,
468541 operationTypeIds: Set<string>,
542+ componentParameters?: Record<string, unknown>,
543+ componentSchemas?: Record<string, unknown>,
469544 ): Record<string, unknown> {
470545 const paths = asRecord(pathsValue);
471546 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
472547 const compactPaths: Record<string, unknown> = {};
548+ const compParams = componentParameters ? asRecord(componentParameters) : {};
549+ const compSchemas = componentSchemas ? asRecord(componentSchemas) : {};
550+
551+ const resolveParam = (entry: Record<string, unknown>): Record<string, unknown> => {
552+ if (typeof entry.$ref === "string") {
553+ // Resolve "#/components/parameters/foo" → components.parameters.foo
554+ const ref = entry.$ref;
555+ const prefix = "#/components/parameters/";
556+ if (ref.startsWith(prefix)) {
557+ const key = ref.slice(prefix.length);
558+ const resolved = asRecord(compParams[key]);
559+ if (Object.keys(resolved).length > 0) return resolved;
560+ }
561+ }
562+ return entry;
563+ };
473564
474565 const normalizeParameters = (entries: unknown): Array<Record<string, unknown>> => {
475566 if (!Array.isArray(entries)) return [];
476567 return entries
477− .map((entry) => asRecord(entry))
568+ .map((entry) => resolveParam(asRecord(entry)))
478569 .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string")
479570 .map((entry) => ({
480571 name: String(entry.name),
481572 in: String(entry.in),
482573 required: Boolean(entry.required),
483− schema: asRecord(entry.schema),
574+ schema: parameterSchemaFromEntry(entry),
484575 }));
485576 };
486577
@@ -523,15 +614,20 @@ function compactOpenApiPaths( · 36 unmodified lines
523614 {
524615 const requestBody = asRecord(operation.requestBody);
525616 const requestBodyContent = asRecord(requestBody.content);
526− const requestBodySchema = getPreferredContentSchema(requestBodyContent);
617+ const requestBodySchema = resolveSchemaRef(
618+ getPreferredContentSchema(requestBodyContent),
619+ compSchemas,
620+ );
527621
528622 const responses = asRecord(operation.responses);
529623 let responseSchema: Record<string, unknown> = {};
530624 let responseStatus = "";
531625 for (const [status, responseValue] of Object.entries(responses)) {
532626 if (!status.startsWith("2")) continue;
533− const responseContent = asRecord(asRecord(responseValue).content);
534− responseSchema = getPreferredContentSchema(responseContent);
627+ responseSchema = resolveSchemaRef(
628+ getPreferredResponseSchema(asRecord(responseValue)),
629+ compSchemas,
630+ );
535631 responseStatus = status;
536632 if (Object.keys(responseSchema).length > 0) break;
537633 }
@@ -559,7 +655,7 @@ function compactOpenApiPaths( · 21 unmodified lines
559655 ],
560656 };
561657 compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema);
562− compactOperation._returnsTypeHint = jsonSchemaTypeHintFallback(responseSchema);
658+ compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus);
563659 } else {
564660 // Keep full schemas for the fallback path
565661 if (Object.keys(requestBodySchema).length > 0) {
@@ -665,7 +761,12 @@ export async function prepareOpenApiSpec( · 99 unmodified lines
665761 servers: servers
666762 .map((server) => (typeof server.url === "string" ? server.url : ""))
667763 .filter((url) => url.length > 0),
668− paths: compactOpenApiPaths(bundled.paths, operationTypeIds),
764+ paths: compactOpenApiPaths(
765+ bundled.paths,
766+ operationTypeIds,
767+ asRecord(asRecord(bundled.components).parameters),
768+ asRecord(asRecord(bundled.components).schemas),
769+ ),
669770 dts: dts ?? undefined,
670771 warnings,
671772 };
@@ -736,10 +837,11 @@ export function buildOpenApiToolsFromPrepared( · 64 unmodified lines
736837
737838 const responses = asRecord(operation.responses);
738839 let responseSchema: Record<string, unknown> = {};
840+ let responseStatus = "";
739841 for (const [status, responseValue] of Object.entries(responses)) {
740842 if (!status.startsWith("2")) continue;
741− const responseContent = asRecord(asRecord(responseValue).content);
742− responseSchema = getPreferredContentSchema(responseContent);
843+ responseSchema = getPreferredResponseSchema(asRecord(responseValue));
844+ responseStatus = status;
743845 if (Object.keys(responseSchema).length > 0) break;
744846 }
745847
@@ -758,7 +860,7 @@ export function buildOpenApiToolsFromPrepared( · 12 unmodified lines
758860 };
759861
760862 argsType = jsonSchemaTypeHintFallback(combinedSchema);
761− returnsType = jsonSchemaTypeHintFallback(responseSchema);
863+ returnsType = responseTypeHintFromSchema(responseSchema, responseStatus);
762864 }
763865
764866 const approval = config.overrides?.[operationIdRaw]?.approval
executor/package.json+1−2
@@ -4,8 +4,7 @@ · 3 unmodified lines
44 "type": "module",
55 "scripts": {
66 "deploy": "bunx convex deploy",
7− "codegen": "bunx convex codegen && bun scripts/postcodegen.ts",
8− "fix-types": "bun scripts/postcodegen.ts"
7+ "codegen": "bunx convex codegen"
98 },
109 "dependencies": {
1110 "convex": "^1.31.7"
executor/scripts/postcodegen.ts+0−289
@@ -1,289 +0,0 @@
1−/**
2− * Post-codegen script for Convex.
3− *
4− * Rewrites `convex/_generated/api.d.ts` to replace the deeply-recursive
5− * `ApiFromModules` / `FilterApi` types with explicit per-module declarations.
6− *
7− * Why: With 18+ modules and 100+ exported functions, the recursive type chain
8− * `ApiFromModules → ExpandModulesAndDirs → UnionToIntersection → FilterApi`
9− * exceeds TypeScript's instantiation depth limit of 100, causing TS2589.
10− *
11− * Usage:
12− * bun scripts/postcodegen.ts
13− *
14− * Run this after `bunx convex codegen` or `bunx convex dev` (via postinstall).
15− */
16−
17−import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
18−import { join, resolve } from "node:path";
19−import * as ts from "typescript";
20−
21−const EXECUTOR_DIR = resolve(import.meta.dirname, "..");
22−const CONVEX_DIR = join(EXECUTOR_DIR, "convex");
23−const API_DTS_PATH = join(CONVEX_DIR, "_generated", "api.d.ts");
24−
25−/** Read the generated api.d.ts and extract the module list + components block. */
26−function parseGeneratedApi(content: string) {
27− const moduleImportRegex = /^import type \* as (\w+) from "\.\.\/(\w+)\.js";$/gm;
28− const modules: { name: string; file: string }[] = [];
29− let match: RegExpExecArray | null;
30− while ((match = moduleImportRegex.exec(content)) !== null) {
31− modules.push({ name: match[1], file: match[2] });
32− }
33−
34− // Extract the components block (everything from `export declare const components:` to the end)
35− const componentsMatch = content.match(
36− /export declare const components:\s*\{[\s\S]*$/,
37− );
38− const componentsBlock = componentsMatch ? componentsMatch[0] : "export declare const components: {};";
39−
40− return { modules, componentsBlock };
41−}
42−
43−type FunctionKind = "query" | "mutation" | "action";
44−type FunctionVisibility = "public" | "internal";
45−
46−interface ParsedExport {
47− name: string;
48− kind: FunctionKind;
49− visibility: FunctionVisibility;
50−}
51−
52−/** Determine the function kind and visibility from a call expression. */
53−function resolveRegistration(callName: string): { kind: FunctionKind; visibility: FunctionVisibility } | null {
54− switch (callName) {
55− case "query": return { kind: "query", visibility: "public" };
56− case "internalQuery": return { kind: "query", visibility: "internal" };
57− case "mutation": return { kind: "mutation", visibility: "public" };
58− case "internalMutation": return { kind: "mutation", visibility: "internal" };
59− case "action": return { kind: "action", visibility: "public" };
60− case "internalAction": return { kind: "action", visibility: "internal" };
61− default: return null;
62− }
63−}
64−
65−/** Parse a Convex module file to extract exported function registrations. */
66−function parseModuleExports(filePath: string): ParsedExport[] {
67− if (!existsSync(filePath)) return [];
68−
69− const content = readFileSync(filePath, "utf-8");
70− const sourceFile = ts.createSourceFile(
71− filePath,
72− content,
73− ts.ScriptTarget.Latest,
74− true,
75− );
76−
77− const exports: ParsedExport[] = [];
78−
79− function visit(node: ts.Node) {
80− // Match: export const foo = query({...}) / mutation({...}) / etc.
81− if (
82− ts.isVariableStatement(node) &&
83− node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
84− ) {
85− for (const decl of node.declarationList.declarations) {
86− if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
87− const exportName = decl.name.text;
88−
89− let callExpr: ts.CallExpression | undefined;
90−
91− // Direct call: export const foo = query({...})
92− if (ts.isCallExpression(decl.initializer)) {
93− callExpr = decl.initializer;
94− }
95−
96− // Nullish coalescing fallback: export const foo = bar?.baz ?? internalMutation({...})
97− // The RHS of ?? determines the Convex registration type.
98− if (
99− !callExpr &&
100− ts.isBinaryExpression(decl.initializer) &&
101− decl.initializer.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
102− ) {
103− const rhs = decl.initializer.right;
104− if (ts.isCallExpression(rhs)) {
105− callExpr = rhs;
106− }
107− }
108−
109− // Ternary fallback: export const foo = cond ? bar : internalMutation({...})
110− if (
111− !callExpr &&
112− ts.isConditionalExpression(decl.initializer)
113− ) {
114− const whenFalse = decl.initializer.whenFalse;
115− if (ts.isCallExpression(whenFalse)) {
116− callExpr = whenFalse;
117− }
118− }
119−
120− if (!callExpr) continue;
121−
122− // Get the function name being called
123− let calledName: string | undefined;
124− if (ts.isIdentifier(callExpr.expression)) {
125− calledName = callExpr.expression.text;
126− }
127−
128− if (!calledName) continue;
129−
130− const reg = resolveRegistration(calledName);
131− if (reg) {
132− exports.push({ name: exportName, ...reg });
133− continue;
134− }
135−
136− // Custom builders (workspaceQuery, authedMutation, organizationQuery, etc.)
137− // These wrap standard query/mutation/action builders. We need to determine
138− // which base type they correspond to.
139− const customBuilderMap: Record<string, { kind: FunctionKind; visibility: FunctionVisibility }> = {
140− workspaceQuery: { kind: "query", visibility: "public" },
141− workspaceMutation: { kind: "mutation", visibility: "public" },
142− authedQuery: { kind: "query", visibility: "public" },
143− authedMutation: { kind: "mutation", visibility: "public" },
144− organizationQuery: { kind: "query", visibility: "public" },
145− organizationMutation: { kind: "mutation", visibility: "public" },
146− optionalAccountQuery: { kind: "query", visibility: "public" },
147− internalOrganizationQuery: { kind: "query", visibility: "internal" },
148− };
149−
150− const customReg = customBuilderMap[calledName];
151− if (customReg) {
152− exports.push({ name: exportName, ...customReg });
153− }
154− }
155− }
156−
157− // Match: export default httpRouter() — skip (http module has no function refs)
158− // Match: export { authKitEvent } from ... — already handled above as variable declarations
159−
160− ts.forEachChild(node, visit);
161− }
162−
163− visit(sourceFile);
164− return exports;
165−}
166−
167−/** Group exports by visibility for a module. */
168−function groupByVisibility(exports: ParsedExport[]) {
169− return {
170− public: exports.filter((e) => e.visibility === "public"),
171− internal: exports.filter((e) => e.visibility === "internal"),
172− };
173−}
174−
175−/** Generate a FunctionReference type string (using `any` for args/returns to avoid deep instantiation). */
176−function funcRef(kind: FunctionKind, visibility: FunctionVisibility): string {
177− return `FunctionReference<"${kind}", "${visibility}", any, any>`;
178−}
179−
180−/** Generate the type block for one namespace (module). */
181−function generateModuleType(exports: ParsedExport[]): string {
182− if (exports.length === 0) return "Record<string, never>";
183− const lines = exports.map(
184− (e) => ` ${e.name}: ${funcRef(e.kind, e.visibility)};`,
185− );
186− return `{\n${lines.join("\n")}\n }`;
187−}
188−
189−function main() {
190− // 1. Read the generated api.d.ts
191− if (!existsSync(API_DTS_PATH)) {
192− console.error(`[postcodegen] ${API_DTS_PATH} not found — run 'bunx convex codegen' first`);
193− process.exit(1);
194− }
195−
196− const generatedContent = readFileSync(API_DTS_PATH, "utf-8");
197− const { modules, componentsBlock } = parseGeneratedApi(generatedContent);
198−
199− if (modules.length === 0) {
200− console.log("[postcodegen] No modules found in api.d.ts — skipping");
201− return;
202− }
203−
204− // 2. Parse each module's exports
205− const moduleExports = new Map<string, ParsedExport[]>();
206− for (const mod of modules) {
207− const filePath = join(CONVEX_DIR, `${mod.file}.ts`);
208− const exports = parseModuleExports(filePath);
209− moduleExports.set(mod.name, exports);
210− }
211−
212− // 3. Build public and internal API types
213− const publicModules: string[] = [];
214− const internalModules: string[] = [];
215−
216− for (const mod of modules) {
217− const exports = moduleExports.get(mod.name) ?? [];
218− const grouped = groupByVisibility(exports);
219−
220− if (grouped.public.length > 0) {
221− publicModules.push(` ${mod.name}: ${generateModuleType(grouped.public)};`);
222− }
223− if (grouped.internal.length > 0) {
224− internalModules.push(` ${mod.name}: ${generateModuleType(grouped.internal)};`);
225− }
226− }
227−
228− // 4. Generate the new api.d.ts
229− const output = `/* eslint-disable */
230−/**
231− * Generated \`api\` utility.
232− *
233− * THIS CODE IS AUTOMATICALLY GENERATED.
234− *
235− * To regenerate, run \`npx convex dev\`.
236− *
237− * NOTE: This file has been post-processed by scripts/postcodegen.ts
238− * to replace recursive ApiFromModules/FilterApi types with explicit
239− * declarations, avoiding TS2589 depth errors.
240− *
241− * @module
242− */
243−
244−import type { FunctionReference } from "convex/server";
245−
246−/**
247− * A utility for referencing Convex functions in your app's public API.
248− *
249− * Usage:
250− * \`\`\`js
251− * const myFunctionReference = api.myModule.myFunction;
252− * \`\`\`
253− */
254−export declare const api: {
255−${publicModules.join("\n")}
256−};
257−
258−/**
259− * A utility for referencing Convex functions in your app's internal API.
260− *
261− * Usage:
262− * \`\`\`js
263− * const myFunctionReference = internal.myModule.myFunction;
264− * \`\`\`
265− */
266−export declare const internal: {
267−${internalModules.join("\n")}
268−};
269−
270−${componentsBlock}
271−`;
272−
273− writeFileSync(API_DTS_PATH, output);
274−
275− const totalPublic = modules.reduce(
276− (sum, m) => sum + (moduleExports.get(m.name) ?? []).filter((e) => e.visibility === "public").length,
277− 0,
278− );
279− const totalInternal = modules.reduce(
280− (sum, m) => sum + (moduleExports.get(m.name) ?? []).filter((e) => e.visibility === "internal").length,
281− 0,
282− );
283−
284− console.log(
285− `[postcodegen] Rewrote api.d.ts: ${modules.length} modules, ${totalPublic} public + ${totalInternal} internal function refs`,
286− );
287−}
288−
289−main();