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 f85d21c1 parent a07b40b17 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
13# WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32>13# WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32>
14# WORKOS_WEBHOOK_SECRET=...14# WORKOS_WEBHOOK_SECRET=...
15 15
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
16# ── Discord bot (optional — omit to skip bot startup) ──24# ── Discord bot (optional — omit to skip bot startup) ──
17# DISCORD_BOT_TOKEN=...25# DISCORD_BOT_TOKEN=...
18 26
bun.lock+1−0
@@ -106,6 +106,7 @@ · 105 unmodified lines
106 "radix-ui": "^1.4.3",106 "radix-ui": "^1.4.3",
107 "react": "19.2.3",107 "react": "19.2.3",
108 "react-dom": "19.2.3",108 "react-dom": "19.2.3",
109 "shiki": "^3.22.0",
109 "sonner": "^2.0.7",110 "sonner": "^2.0.7",
110 "streamdown": "^2.1.0",111 "streamdown": "^2.1.0",
111 "tailwind-merge": "^3.4.0",112 "tailwind-merge": "^3.4.0",
executor/apps/web/next.config.ts+1−0
@@ -6,6 +6,7 @@ const nextConfig: NextConfig = { · 5 unmodified lines
6 // This lets us keep a single root .env without NEXT_PUBLIC_ prefixes.6 // This lets us keep a single root .env without NEXT_PUBLIC_ prefixes.
7 NEXT_PUBLIC_CONVEX_URL: process.env.CONVEX_URL,7 NEXT_PUBLIC_CONVEX_URL: process.env.CONVEX_URL,
8 NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,8 NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,
9 NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
9 },10 },
10};11};
11 12
executor/apps/web/package.json+1−0
@@ -28,6 +28,7 @@ · 27 unmodified lines
28 "radix-ui": "^1.4.3",28 "radix-ui": "^1.4.3",
29 "react": "19.2.3",29 "react": "19.2.3",
30 "react-dom": "19.2.3",30 "react-dom": "19.2.3",
31 "shiki": "^3.22.0",
31 "sonner": "^2.0.7",32 "sonner": "^2.0.7",
32 "streamdown": "^2.1.0",33 "streamdown": "^2.1.0",
33 "tailwind-merge": "^3.4.0",34 "tailwind-merge": "^3.4.0",
executor/apps/web/src/components/billing-view.tsx+222−33
@@ -1,20 +1,59 @@
1"use client";1"use client";
2 2
3import { useMemo, useState } from "react";3import { useMemo, useState } from "react";
4import { useSearchParams } from "next/navigation";
5import { useAction, useMutation, useQuery } from "convex/react";
4import { AlertTriangle, BadgeCheck, CreditCard, RefreshCcw } from "lucide-react";6import { AlertTriangle, BadgeCheck, CreditCard, RefreshCcw } from "lucide-react";
5import { PageHeader } from "@/components/page-header";7import { PageHeader } from "@/components/page-header";
8import { Badge } from "@/components/ui/badge";
6import { Button } from "@/components/ui/button";9import { Button } from "@/components/ui/button";
7import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";10import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
11import { Input } from "@/components/ui/input";
12import { convexApi } from "@/lib/convex-api";
8import { useSession } from "@/lib/session-context";13import { useSession } from "@/lib/session-context";
9 14
10interface BillingViewProps {15interface BillingViewProps {
11 showHeader?: boolean;16 showHeader?: boolean;
12}17}
13 18
19type 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
42function 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
14export function BillingView({ showHeader = true }: BillingViewProps) {51export function BillingView({ showHeader = true }: BillingViewProps) {
15 const { context, organizations, workspaces } = useSession();52 const { context, organizations, organizationsLoading, workspaces } = useSession();
16 const [actionState, setActionState] = useState<"idle" | "running" | "error">("idle");53 const searchParams = useSearchParams();
54 const [actionState, setActionState] = useState<"idle" | "running" | "success" | "error">("idle");
17 const [actionMessage, setActionMessage] = useState<string | null>(null);55 const [actionMessage, setActionMessage] = useState<string | null>(null);
56 const [priceId, setPriceId] = useState(() => process.env.NEXT_PUBLIC_STRIPE_PRICE_ID ?? "");
18 57
19 const derivedOrganizationId = context58 const derivedOrganizationId = context
20 ? workspaces.find((workspace) => workspace.id === context.workspaceId)?.organizationId ?? null59 ? workspaces.find((workspace) => workspace.id === context.workspaceId)?.organizationId ?? null
@@ -25,13 +64,98 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 4 unmodified lines
25 () => organizations.find((organization) => organization.id === effectiveOrganizationId) ?? null,64 () => organizations.find((organization) => organization.id === effectiveOrganizationId) ?? null,
26 [organizations, effectiveOrganizationId],65 [organizations, effectiveOrganizationId],
27 );66 );
67
28 const canManageBilling = activeOrganization68 const canManageBilling = activeOrganization
29 ? ["owner", "billing_admin"].includes(activeOrganization.role)69 ? ["owner", "admin", "billing_admin"].includes(activeOrganization.role)
30 : false;70 : false;
31 71
32 const notReady = () => {72 const summary = useQuery(
33 setActionState("error");73 convexApi.billing.getSummary,
34 setActionMessage("Billing actions are waiting on backend endpoints.");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 }
35 };159 };
36 160
37 if (!effectiveOrganizationId) {161 if (!effectiveOrganizationId) {
@@ -51,41 +175,76 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 13 unmodified lines
51 <div className="space-y-6">175 <div className="space-y-6">
52 {showHeader ? <PageHeader title="Billing" description="Manage subscription, seats, and billing portal access" /> : null}176 {showHeader ? <PageHeader title="Billing" description="Manage subscription, seats, and billing portal access" /> : null}
53 177
54 <Card>178 {checkoutSuccess ? (
55 <CardContent className="p-6 text-sm text-muted-foreground">179 <Card>
56 Billing backend wiring is still in progress. This screen is ready for `billing.getSummary`,180 <CardContent className="p-4 text-sm text-terminal-green">
57 `billing.createSubscriptionCheckout`, `billing.createCustomerPortal`, and `billing.retrySeatSync`.181 Checkout completed. Billing updates may take a few seconds while Stripe webhooks process.
58 </CardContent>182 </CardContent>
59 </Card>183 </Card>
184 ) : null}
60 185
61 <Card>186 {checkoutCanceled ? (
62 <CardContent className="p-4 text-sm text-muted-foreground flex items-center gap-2">187 <Card>
63 <RefreshCcw className="h-4 w-4" />188 <CardContent className="p-4 text-sm text-muted-foreground">
64 Billing sync pending. Numbers may be briefly stale during webhook processing.189 Checkout canceled. You can restart checkout at any time.
65 </CardContent>190 </CardContent>
66 </Card>191 </Card>
192 ) : null}
67 193
68 <Card>194 <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">
70 <span className="flex items-center gap-2">196 <span className="flex items-center gap-2">
71 <AlertTriangle className="h-4 w-4" />197 <RefreshCcw className="h-4 w-4" />
72 If seat sync fails, use retry once the backend endpoint is available.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."}
73 </span>205 </span>
74 <Button variant="outline" size="sm" onClick={notReady} disabled={!canManageBilling || actionState === "running"}>206 <Badge variant="outline" className="text-[10px] font-mono uppercase tracking-wider">
75 Retry sync207 {summary?.sync.status ?? "pending"}
76 </Button>208 </Badge>
77 </CardContent>209 </CardContent>
78 </Card>210 </Card>
79 211
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
80 <div className="grid gap-4 md:grid-cols-3">239 <div className="grid gap-4 md:grid-cols-3">
81 <Card>240 <Card>
82 <CardHeader>241 <CardHeader>
83 <CardTitle className="text-sm">Subscription</CardTitle>242 <CardTitle className="text-sm">Subscription</CardTitle>
84 </CardHeader>243 </CardHeader>
85 <CardContent className="space-y-2 text-sm">244 <CardContent className="space-y-2 text-sm">
86 <p>Status: pending</p>245 <p>Status: {summary?.subscription?.status ?? "none"}</p>
87 <p>Price: -</p>246 <p>Price: {summary?.subscription?.stripePriceId ?? "-"}</p>
88 <p>Renewal: -</p>247 <p>Renewal: {formatTimestamp(summary?.subscription?.currentPeriodEnd ?? null)}</p>
89 </CardContent>248 </CardContent>
90 </Card>249 </Card>
91 250
@@ -94,9 +253,9 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 2 unmodified lines
94 <CardTitle className="text-sm">Seats</CardTitle>253 <CardTitle className="text-sm">Seats</CardTitle>
95 </CardHeader>254 </CardHeader>
96 <CardContent className="space-y-2 text-sm">255 <CardContent className="space-y-2 text-sm">
97 <p>Billable members: -</p>256 <p>Billable members: {summary?.seats.billableMembers ?? "-"}</p>
98 <p>Desired seats: -</p>257 <p>Desired seats: {summary?.seats.desiredSeats ?? "-"}</p>
99 <p>Last applied: -</p>258 <p>Last applied: {summary?.seats.lastAppliedSeats ?? "-"}</p>
100 </CardContent>259 </CardContent>
101 </Card>260 </Card>
102 261
@@ -105,11 +264,26 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 2 unmodified lines
105 <CardTitle className="text-sm">Actions</CardTitle>264 <CardTitle className="text-sm">Actions</CardTitle>
106 </CardHeader>265 </CardHeader>
107 <CardContent className="space-y-2">266 <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 >
109 <CreditCard className="mr-2 h-4 w-4" />278 <CreditCard className="mr-2 h-4 w-4" />
110 Start checkout279 Start checkout
111 </Button>280 </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 >
113 <BadgeCheck className="mr-2 h-4 w-4" />287 <BadgeCheck className="mr-2 h-4 w-4" />
114 Manage billing portal288 Manage billing portal
115 </Button>289 </Button>
@@ -117,8 +291,23 @@ export function BillingView({ showHeader = true }: BillingViewProps) { · 1 unmodified line
117 </Card>291 </Card>
118 </div>292 </div>
119 293
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
120 {actionMessage ? (301 {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 >
122 {actionMessage}311 {actionMessage}
123 </p>312 </p>
124 ) : null}313 ) : null}
executor/apps/web/src/components/code-editor.tsx+72−15
@@ -3,6 +3,7 @@ · 2 unmodified lines
3import { useRef, useEffect } from "react";3import { useRef, useEffect } from "react";
4import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";4import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";
5import { Loader2 } from "lucide-react";5import { Loader2 } from "lucide-react";
6import { useTheme } from "next-themes";
6import type { ToolDescriptor } from "@/lib/types";7import type { ToolDescriptor } from "@/lib/types";
7import { cn } from "@/lib/utils";8import { cn } from "@/lib/utils";
8 9
@@ -34,7 +35,7 @@ function buildTree(tools: ToolDescriptor[]): NamespaceNode { · 25 unmodified lines
34 return root;35 return root;
35}36}
36 37
37function emitToolMethod(tool: ToolDescriptor): string {38function emitToolMethod(tool: ToolDescriptor, dtsSources: Set<string>): string {
38 const funcName = tool.path.split(".").pop()!;39 const funcName = tool.path.split(".").pop()!;
39 const approvalNote =40 const approvalNote =
40 tool.approval === "required"41 tool.approval === "required"
@@ -44,8 +45,10 @@ function emitToolMethod(tool: ToolDescriptor): string { · 3 unmodified lines
44 ? `${tool.description}${approvalNote}`45 ? `${tool.description}${approvalNote}`
45 : approvalNote || "Call this tool.";46 : approvalNote || "Call this tool.";
46 47
47 // For OpenAPI tools with operationId, use indexed access types for precise IntelliSense48 // For OpenAPI tools with operationId, use indexed access types only when
48 if (tool.operationId) {49 // this source has a loaded .d.ts block.
50 const hasSourceDts = Boolean(tool.source && dtsSources.has(tool.source));
51 if (tool.operationId && hasSourceDts) {
49 const opKey = JSON.stringify(tool.operationId);52 const opKey = JSON.stringify(tool.operationId);
50 return ` /**53 return ` /**
51 * ${desc}54 * ${desc}
@@ -72,11 +75,12 @@ function emitToolMethod(tool: ToolDescriptor): string { · 20 unmodified lines
72function emitNamespaceInterface(75function emitNamespaceInterface(
73 name: string,76 name: string,
74 node: NamespaceNode,77 node: NamespaceNode,
78 dtsSources: Set<string>,
75 out: string[],79 out: string[],
76): void {80): void {
77 // First, recursively emit child namespace interfaces81 // First, recursively emit child namespace interfaces
78 for (const [childName, childNode] of node.children) {82 for (const [childName, childNode] of node.children) {
79 emitNamespaceInterface(`${name}_${childName}`, childNode, out);83 emitNamespaceInterface(`${name}_${childName}`, childNode, dtsSources, out);
80 }84 }
81 85
82 // Build this namespace's interface86 // Build this namespace's interface
@@ -92,7 +96,7 @@ function emitNamespaceInterface( · 9 unmodified lines
92 96
93 // Add tool methods97 // Add tool methods
94 for (const tool of node.tools) {98 for (const tool of node.tools) {
95 members.push(emitToolMethod(tool));99 members.push(emitToolMethod(tool, dtsSources));
96 }100 }
97 101
98 out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`);102 out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`);
@@ -119,11 +123,12 @@ type ToolOutput<Op> = · 20 unmodified lines
119 Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R :123 Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R :
120 Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R :124 Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R :
121 Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R :125 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 :
123 unknown;128 unknown;
124`;129`;
125 130
126function generateToolsDts(tools: ToolDescriptor[]): string {131function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string {
127 const root = buildTree(tools);132 const root = buildTree(tools);
128 133
129 // Legacy compat: collect schemaTypes from tools that use the old format134 // Legacy compat: collect schemaTypes from tools that use the old format
@@ -141,7 +146,7 @@ function generateToolsDts(tools: ToolDescriptor[]): string { · 11 unmodified lines
141 146
142 // Emit all namespace interfaces recursively147 // Emit all namespace interfaces recursively
143 for (const [name, node] of root.children) {148 for (const [name, node] of root.children) {
144 emitNamespaceInterface(name, node, interfaces);149 emitNamespaceInterface(name, node, dtsSources, interfaces);
145 }150 }
146 151
147 // Build root ToolsProxy interface152 // Build root ToolsProxy interface
@@ -154,7 +159,7 @@ function generateToolsDts(tools: ToolDescriptor[]): string { · 6 unmodified lines
154 159
155 // Root-level tools (rare but possible)160 // Root-level tools (rare but possible)
156 for (const tool of root.tools) {161 for (const tool of root.tools) {
157 rootMembers.push(emitToolMethod(tool));162 rootMembers.push(emitToolMethod(tool, dtsSources));
158 }163 }
159 164
160 let dts = `165 let dts = `
@@ -241,6 +246,7 @@ export function CodeEditor({ · 80 unmodified lines
241 className,246 className,
242 height = "400px",247 height = "400px",
243}: CodeEditorProps) {248}: CodeEditorProps) {
249 const { resolvedTheme } = useTheme();
244 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);250 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
245 const monacoRef = useRef<Monaco | null>(null);251 const monacoRef = useRef<Monaco | null>(null);
246 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);252 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);
@@ -248,6 +254,7 @@ export function CodeEditor({ · 1 unmodified line
248 const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]);254 const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]);
249 const toolsLibVersion = useRef(0);255 const toolsLibVersion = useRef(0);
250 const fetchedDtsUrls = useRef<string>("");256 const fetchedDtsUrls = useRef<string>("");
257 const dtsSources = new Set(Object.keys(dtsUrls ?? {}));
251 258
252 // Fetch and register .d.ts blobs from OpenAPI sources259 // Fetch and register .d.ts blobs from OpenAPI sources
253 useEffect(() => {260 useEffect(() => {
@@ -318,7 +325,7 @@ export function CodeEditor({ · 64 unmodified lines
318 // Dispose previous tool type declarations325 // Dispose previous tool type declarations
319 toolsLibDisposable.current?.dispose();326 toolsLibDisposable.current?.dispose();
320 327
321 const dts = generateToolsDts(tools);328 const dts = generateToolsDts(tools, dtsSources);
322 329
323 // Use a versioned filename — disposing + re-adding the same filename330 // Use a versioned filename — disposing + re-adding the same filename
324 // can cause the TS worker to serve stale completions from its cache.331 // can cause the TS worker to serve stale completions from its cache.
@@ -327,7 +334,7 @@ export function CodeEditor({ · 2 unmodified lines
327 dts,334 dts,
328 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,335 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
329 );336 );
330 }, [tools]);337 }, [tools, dtsUrls]);
331 338
332 // Avoid transient semantic errors while tool metadata is still loading.339 // Avoid transient semantic errors while tool metadata is still loading.
333 useEffect(() => {340 useEffect(() => {
@@ -382,14 +389,62 @@ export function CodeEditor({ · 48 unmodified lines
382 // Add initial tool type declarations389 // Add initial tool type declarations
383 // (will be replaced by useEffect when tools load from the API)390 // (will be replaced by useEffect when tools load from the API)
384 toolsLibDisposable.current?.dispose();391 toolsLibDisposable.current?.dispose();
385 const dts = generateToolsDts(tools);392 const dts = generateToolsDts(tools, dtsSources);
386 const version = ++toolsLibVersion.current;393 const version = ++toolsLibVersion.current;
387 toolsLibDisposable.current = ts.javascriptDefaults.addExtraLib(394 toolsLibDisposable.current = ts.javascriptDefaults.addExtraLib(
388 dts,395 dts,
389 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,396 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
390 );397 );
391 398
392 // Define the dark theme matching our UI399 // 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
393 monaco.editor.defineTheme("executor-dark", {448 monaco.editor.defineTheme("executor-dark", {
394 base: "vs-dark",449 base: "vs-dark",
395 inherit: true,450 inherit: true,
@@ -434,6 +489,8 @@ export function CodeEditor({ · 38 unmodified lines
434 });489 });
435 };490 };
436 491
492 const monacoTheme = resolvedTheme === "light" ? "executor-light" : "executor-dark";
493
437 const handleMount: OnMount = (editor) => {494 const handleMount: OnMount = (editor) => {
438 editorRef.current = editor;495 editorRef.current = editor;
439 496
@@ -460,7 +517,7 @@ export function CodeEditor({ · 20 unmodified lines
460 height={height}517 height={height}
461 language="javascript"518 language="javascript"
462 path="task.js"519 path="task.js"
463 theme="executor-dark"520 theme={monacoTheme}
464 value={value}521 value={value}
465 onChange={(v) => onChange(v ?? "")}522 onChange={(v) => onChange(v ?? "")}
466 beforeMount={handleBeforeMount}523 beforeMount={handleBeforeMount}
@@ -524,7 +581,7 @@ export function CodeEditor({ · 57 unmodified lines
524 smoothScrolling: true,581 smoothScrolling: true,
525 }}582 }}
526 loading={583 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">
528 Loading editor...585 Loading editor...
529 </div>586 </div>
530 }587 }
executor/apps/web/src/components/tool-explorer.tsx+90−17
@@ -256,6 +256,93 @@ function CopyButton({ · 255 unmodified lines
256 );256 );
257}257}
258 258
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
263function 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
310function 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
259// ── Inline tool detail (shown when tool row is expanded) ──346// ── Inline tool detail (shown when tool row is expanded) ──
260 347
261function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) {348function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) {
@@ -289,26 +376,12 @@ function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) { · 27 unmodified lines
289 )}376 )}
290 </div>377 </div>
291 378
292 {/* Args / Returns rendered as highlighted TypeScript via Streamdown */}379 {/* Args / Returns — pretty-printed type signatures */}
293 {tool.argsType && (380 {tool.argsType && (
294 <div className="tool-description">381 <TypeSignature raw={tool.argsType} label="Arguments" />
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>
302 )}382 )}
303 {tool.returnsType && (383 {tool.returnsType && (
304 <div className="tool-description">384 <TypeSignature raw={tool.returnsType} label="Returns" />
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>
312 )}385 )}
313 </div>386 </div>
314 );387 );
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
207 }207 }
208}208}
209 209
210function 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
221function 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
210function getFaviconUrl(preset: ApiPreset): string | null {238function 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]));
212}240}
213 241
214function getSourceFavicon(source: ToolSourceRecord): string | null {242function getSourceFavicon(source: ToolSourceRecord): string | null {
215 const url =243 const urls =
216 source.type === "mcp"244 source.type === "mcp"
217 ? (source.config.url as string)245 ? [source.config.url as string]
218 : source.type === "graphql"246 : source.type === "graphql"
219 ? (source.config.endpoint as string)247 ? [source.config.endpoint as string]
220 : (source.config.baseUrl as string) ?? (source.config.spec as string);248 : [source.config.baseUrl as string, source.config.spec as string];
221 return faviconForUrl(url);249 return faviconForUrl(pickFaviconTarget(urls));
222}250}
223 251
224function sourceKeyForSource(source: ToolSourceRecord): string | null {252function sourceKeyForSource(source: ToolSourceRecord): string | null {
executor/convex.json+4−1
@@ -1,3 +1,6 @@
1{1{
2 "functions": "convex"2 "functions": "convex",
3 "codegen": {
4 "staticApi": true
5 }
3}6}
executor/convex/_generated/api.d.ts+791−108
@@ -5,15 +5,11 @@ · 4 unmodified lines
5 * THIS CODE IS AUTOMATICALLY GENERATED.5 * THIS CODE IS AUTOMATICALLY GENERATED.
6 *6 *
7 * To regenerate, run `npx convex dev`.7 * 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 *
13 * @module8 * @module
14 */9 */
15 10
16import type { FunctionReference } from "convex/server";11import type { FunctionReference } from "convex/server";
12import type { GenericId as Id } from "convex/values";
17 13
18/**14/**
19 * A utility for referencing Convex functions in your app's public API.15 * 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
25 */21 */
26export declare const api: {22export declare const api: {
27 app: {23 app: {
28 getClientConfig: FunctionReference<"query", "public", any, any>;24 getClientConfig: FunctionReference<"query", "public", {}, any>;
29 getCurrentAccount: FunctionReference<"query", "public", any, any>;25 getCurrentAccount: FunctionReference<
26 "query",
27 "public",
28 { sessionId?: string },
29 any
30 >;
30 };31 };
31 auth: {32 auth: {
32 bootstrapCurrentWorkosAccount: FunctionReference<"mutation", "public", any, any>;33 bootstrapCurrentWorkosAccount: FunctionReference<
34 "mutation",
35 "public",
36 {},
37 any
38 >;
33 };39 };
34 billing: {40 billing: {
35 getSummary: FunctionReference<"query", "public", any, any>;41 createCustomerPortal: FunctionReference<
36 createSubscriptionCheckout: FunctionReference<"action", "public", any, any>;42 "action",
37 createCustomerPortal: FunctionReference<"action", "public", any, any>;43 "public",
38 retrySeatSync: FunctionReference<"mutation", "public", any, any>;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 >;
39 };75 };
40 credentialsNode: {76 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 >;
42 };92 };
43 database: {93 database: {
44 createAgentTask: FunctionReference<"mutation", "public", any, any>;94 createAgentTask: FunctionReference<
45 getAgentTask: FunctionReference<"query", "public", any, any>;95 "mutation",
46 updateAgentTask: FunctionReference<"mutation", "public", any, any>;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 >;
47 };124 };
48 executor: {125 executor: {
49 createTask: FunctionReference<"mutation", "public", any, any>;126 createTask: FunctionReference<
50 resolveApproval: FunctionReference<"mutation", "public", any, any>;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 >;
51 };154 };
52 executorNode: {155 executorNode: {
53 listTools: FunctionReference<"action", "public", any, any>;156 listTools: FunctionReference<
54 listToolsWithWarnings: FunctionReference<"action", "public", any, any>;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 >;
55 };178 };
56 invites: {179 invites: {
57 list: FunctionReference<"query", "public", any, any>;180 create: FunctionReference<
58 create: FunctionReference<"mutation", "public", any, any>;181 "mutation",
59 revoke: FunctionReference<"mutation", "public", any, any>;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 >;
60 };209 };
61 organizationMembers: {210 organizationMembers: {
62 list: FunctionReference<"query", "public", any, any>;211 list: FunctionReference<
63 updateRole: FunctionReference<"mutation", "public", any, any>;212 "query",
64 updateBillable: FunctionReference<"mutation", "public", any, any>;213 "public",
65 remove: FunctionReference<"mutation", "public", any, any>;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 >;
66 };249 };
67 organizations: {250 organizations: {
68 create: FunctionReference<"mutation", "public", any, any>;251 create: FunctionReference<
69 listMine: FunctionReference<"query", "public", any, any>;252 "mutation",
70 getNavigationState: FunctionReference<"query", "public", any, any>;253 "public",
71 getOrganizationAccess: FunctionReference<"query", "public", any, any>;254 { name: string; sessionId?: string },
72 resolveWorkosOrganizationId: FunctionReference<"query", "public", any, any>;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 >;
73 };276 };
74 workspace: {277 workspace: {
75 bootstrapAnonymousSession: FunctionReference<"mutation", "public", any, any>;278 bootstrapAnonymousSession: FunctionReference<
76 listRuntimeTargets: FunctionReference<"query", "public", any, any>;279 "mutation",
77 getTask: FunctionReference<"query", "public", any, any>;280 "public",
78 getTaskInWorkspace: FunctionReference<"query", "public", any, any>;281 { sessionId?: string },
79 listTasks: FunctionReference<"query", "public", any, any>;282 any
80 listApprovals: FunctionReference<"query", "public", any, any>;283 >;
81 listPendingApprovals: FunctionReference<"query", "public", any, any>;284 deleteToolSource: FunctionReference<
82 listTaskEvents: FunctionReference<"query", "public", any, any>;285 "mutation",
83 upsertAccessPolicy: FunctionReference<"mutation", "public", any, any>;286 "public",
84 listAccessPolicies: FunctionReference<"query", "public", any, any>;287 { sessionId?: string; sourceId: string; workspaceId: string },
85 upsertCredential: FunctionReference<"mutation", "public", any, any>;288 any
86 listCredentials: FunctionReference<"query", "public", any, any>;289 >;
87 listCredentialProviders: FunctionReference<"query", "public", any, any>;290 getTask: FunctionReference<
88 resolveCredential: FunctionReference<"query", "public", any, any>;291 "query",
89 upsertToolSource: FunctionReference<"mutation", "public", any, any>;292 "public",
90 listToolSources: FunctionReference<"query", "public", any, any>;293 { sessionId?: string; taskId: string; workspaceId: string },
91 deleteToolSource: FunctionReference<"mutation", "public", any, any>;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 >;
92 };406 };
93 workspaces: {407 workspaces: {
94 create: FunctionReference<"mutation", "public", any, any>;408 create: FunctionReference<
95 list: FunctionReference<"query", "public", any, any>;409 "mutation",
96 generateWorkspaceIconUploadUrl: FunctionReference<"mutation", "public", any, any>;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 >;
97 };431 };
98};432};
99 433
@@ -107,79 +441,429 @@ export declare const api: { · 7 unmodified lines
107 */441 */
108export declare const internal: {442export declare const internal: {
109 auth: {443 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 >;
111 };450 };
112 billingInternal: {451 billingInternal: {
113 getBillingAccessForRequest: FunctionReference<"query", "internal", any, any>;452 bumpSeatSyncVersion: FunctionReference<
114 getSeatSyncSnapshot: FunctionReference<"query", "internal", any, any>;453 "mutation",
115 upsertCustomerLink: FunctionReference<"mutation", "internal", any, any>;454 "internal",
116 bumpSeatSyncVersion: FunctionReference<"mutation", "internal", any, any>;455 { organizationId: Id<"organizations"> },
117 upsertSeatState: FunctionReference<"mutation", "internal", any, any>;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 >;
118 };488 };
119 billingSync: {489 billingSync: {
120 syncSeatQuantity: FunctionReference<"action", "internal", any, any>;490 syncSeatQuantity: FunctionReference<
491 "action",
492 "internal",
493 { expectedVersion: number; organizationId: Id<"organizations"> },
494 any
495 >;
121 };496 };
122 database: {497 database: {
123 createTask: FunctionReference<"mutation", "internal", any, any>;498 bootstrapAnonymousSession: FunctionReference<
124 getTask: FunctionReference<"query", "internal", any, any>;499 "mutation",
125 listTasks: FunctionReference<"query", "internal", any, any>;500 "internal",
126 listQueuedTaskIds: FunctionReference<"query", "internal", any, any>;501 { sessionId?: string },
127 listRuntimeTargets: FunctionReference<"query", "internal", any, any>;502 any
128 getTaskInWorkspace: FunctionReference<"query", "internal", any, any>;503 >;
129 markTaskRunning: FunctionReference<"mutation", "internal", any, any>;504 createApproval: FunctionReference<
130 markTaskFinished: FunctionReference<"mutation", "internal", any, any>;505 "mutation",
131 createApproval: FunctionReference<"mutation", "internal", any, any>;506 "internal",
132 getApproval: FunctionReference<"query", "internal", any, any>;507 { id: string; input?: any; taskId: string; toolPath: string },
133 listApprovals: FunctionReference<"query", "internal", any, any>;508 any
134 listPendingApprovals: FunctionReference<"query", "internal", any, any>;509 >;
135 resolveApproval: FunctionReference<"mutation", "internal", any, any>;510 createTask: FunctionReference<
136 getApprovalInWorkspace: FunctionReference<"query", "internal", any, any>;511 "mutation",
137 bootstrapAnonymousSession: FunctionReference<"mutation", "internal", any, any>;512 "internal",
138 upsertAccessPolicy: FunctionReference<"mutation", "internal", any, any>;513 {
139 listAccessPolicies: FunctionReference<"query", "internal", any, any>;514 actorId: string;
140 upsertCredential: FunctionReference<"mutation", "internal", any, any>;515 clientId?: string;
141 listCredentials: FunctionReference<"query", "internal", any, any>;516 code: string;
142 listCredentialProviders: FunctionReference<"query", "internal", any, any>;517 id: string;
143 resolveCredential: FunctionReference<"query", "internal", any, any>;518 metadata?: any;
144 upsertToolSource: FunctionReference<"mutation", "internal", any, any>;519 runtimeId: string;
145 listToolSources: FunctionReference<"query", "internal", any, any>;520 timeoutMs?: number;
146 deleteToolSource: FunctionReference<"mutation", "internal", any, any>;521 workspaceId: string;
147 createTaskEvent: FunctionReference<"mutation", "internal", any, any>;522 },
148 listTaskEvents: FunctionReference<"query", "internal", any, any>;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 >;
149 };688 };
150 executor: {689 executor: {
151 createTaskInternal: FunctionReference<"mutation", "internal", any, any>;690 appendRuntimeOutput: FunctionReference<
152 resolveApprovalInternal: FunctionReference<"mutation", "internal", any, any>;691 "mutation",
153 appendRuntimeOutput: FunctionReference<"mutation", "internal", any, any>;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 >;
154 };727 };
155 executorNode: {728 executorNode: {
156 listToolsInternal: FunctionReference<"action", "internal", any, any>;729 handleExternalToolCall: FunctionReference<
157 listToolsWithWarningsInternal: FunctionReference<"action", "internal", any, any>;730 "action",
158 handleExternalToolCall: FunctionReference<"action", "internal", any, any>;731 "internal",
159 runTask: FunctionReference<"action", "internal", any, any>;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>;
160 };748 };
161 invites: {749 invites: {
162 deliverWorkosInvite: FunctionReference<"action", "internal", any, any>;750 deliverWorkosInvite: FunctionReference<
163 revokeWorkosInvite: FunctionReference<"action", "internal", any, any>;751 "action",
164 getInviteDeliveryContext: FunctionReference<"query", "internal", any, any>;752 "internal",
165 linkOrganizationToWorkos: FunctionReference<"mutation", "internal", any, any>;753 {
166 getInviteById: FunctionReference<"query", "internal", any, any>;754 expiresInDays?: number;
167 markInviteDelivered: FunctionReference<"mutation", "internal", any, any>;755 inviteId: Id<"invites">;
168 markInviteDeliveryFailed: FunctionReference<"mutation", "internal", any, any>;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 >;
169 };801 };
170 openApiSpecCache: {802 openApiSpecCache: {
171 getEntry: FunctionReference<"query", "internal", any, any>;803 getEntry: FunctionReference<
172 putEntry: FunctionReference<"mutation", "internal", any, any>;804 "query",
173 pruneExpired: FunctionReference<"mutation", "internal", any, any>;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 >;
174 };826 };
175 workspaceAuthInternal: {827 workspaceAuthInternal: {
176 getWorkspaceAccessForRequest: FunctionReference<"query", "internal", any, any>;828 getWorkspaceAccessForRequest: FunctionReference<
177 getWorkspaceAccessForWorkosSubject: FunctionReference<"query", "internal", any, any>;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 >;
178 };840 };
179 workspaceToolCache: {841 workspaceToolCache: {
180 getEntry: FunctionReference<"query", "internal", any, any>;842 getDtsStorageIds: FunctionReference<
181 putEntry: FunctionReference<"mutation", "internal", any, any>;843 "query",
182 getDtsStorageIds: FunctionReference<"query", "internal", any, any>;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 >;
183 };867 };
184};868};
185 869
@@ -573,4 +1257,3 @@ export declare const components: { · 387 unmodified lines
573 };1257 };
574 };1258 };
575};1259};
576
executor/convex/billing.ts+9−3
@@ -115,8 +115,14 @@ export const createSubscriptionCheckout = action({ · 114 unmodified lines
115 }115 }
116 116
117 const quantity = Math.max(1, access.billableMembers);117 const quantity = Math.max(1, access.billableMembers);
118 const successUrl = args.successUrl ?? process.env.BILLING_SUCCESS_URL ?? "http://localhost:3000/billing?success=true";118 const successUrl =
119 const cancelUrl = args.cancelUrl ?? process.env.BILLING_CANCEL_URL ?? "http://localhost:3000/billing?canceled=true";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";
120 126
121 const session = await stripeClient.createCheckoutSession(ctx, {127 const session = await stripeClient.createCheckoutSession(ctx, {
122 priceId: args.priceId,128 priceId: args.priceId,
@@ -187,7 +193,7 @@ export const createCustomerPortal = action({ · 64 unmodified lines
187 193
188 return await stripeClient.createCustomerPortalSession(ctx, {194 return await stripeClient.createCustomerPortalSession(ctx, {
189 customerId,195 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",
191 });197 });
192 },198 },
193});199});
executor/convex/executorNode.ts+2−2
@@ -56,7 +56,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 55 unmodified lines
56}56}
57 57
58function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {58function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
59 const signatureVersion = "v2";59 const signatureVersion = "v4";
60 const parts = sources60 const parts = sources
61 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)61 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
62 .sort();62 .sort();
@@ -131,7 +131,7 @@ interface DtsStorageEntry { · 68 unmodified lines
131const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;131const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
132 132
133/** Cache version — bump when PreparedOpenApiSpec shape changes. */133/** Cache version — bump when PreparedOpenApiSpec shape changes. */
134const OPENAPI_CACHE_VERSION = "v4";134const OPENAPI_CACHE_VERSION = "v7";
135 135
136async function publish(136async function publish(
137 ctx: any,137 ctx: any,
executor/lib/openapi-cache.test.ts+108−0
@@ -314,6 +314,114 @@ describe("buildOpenApiToolsFromPrepared", () => { · 313 unmodified lines
314 }314 }
315 });315 });
316 316
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
317 test("schemaTypes only attached to first tool from a source", async () => {425 test("schemaTypes only attached to first tool from a source", async () => {
318 const specWithSchemas: Record<string, unknown> = {426 const specWithSchemas: Record<string, unknown> = {
319 openapi: "3.0.3",427 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
154 // The sourceDts should contain operations interface154 // The sourceDts should contain operations interface
155 const dts = withSourceDts[0].metadata!.sourceDts!;155 const dts = withSourceDts[0].metadata!.sourceDts!;
156 expect(dts).toContain("operations");156 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);
157 }167 }
158 168
159 if (prepared.warnings.length > 0) {169 if (prepared.warnings.length > 0) {
@@ -164,4 +174,64 @@ describe("real-world OpenAPI specs", () => { · 4 unmodified lines
164 300_000,174 300_000,
165 );175 );
166 }176 }
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 );
167});237});
executor/lib/tool_sources.ts+112−10
@@ -232,6 +232,78 @@ function getPreferredContentSchema(content: Record<string, unknown>): Record<str · 231 unmodified lines
232 return {};232 return {};
233}233}
234 234
235function 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
251function 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
269function 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
294function 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
235/** Simple depth-limited type hint generator for schemas (used as fallback) */307/** Simple depth-limited type hint generator for schemas (used as fallback) */
236function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string {308function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string {
237 if (!schema || typeof schema !== "object") return "unknown";309 if (!schema || typeof schema !== "object") return "unknown";
@@ -254,6 +326,7 @@ function jsonSchemaTypeHintFallback(schema: unknown, depth = 0): string { · 16 unmodified lines
254 }326 }
255 327
256 const type = typeof shape.type === "string" ? shape.type : undefined;328 const type = typeof shape.type === "string" ? shape.type : undefined;
329 if (type === "integer") return "number";
257 if (type === "string" || type === "number" || type === "boolean" || type === "null") {330 if (type === "string" || type === "number" || type === "boolean" || type === "null") {
258 return type;331 return type;
259 }332 }
@@ -466,21 +539,39 @@ function buildOpenApiUrl( · 206 unmodified lines
466function compactOpenApiPaths(539function compactOpenApiPaths(
467 pathsValue: unknown,540 pathsValue: unknown,
468 operationTypeIds: Set<string>,541 operationTypeIds: Set<string>,
542 componentParameters?: Record<string, unknown>,
543 componentSchemas?: Record<string, unknown>,
469): Record<string, unknown> {544): Record<string, unknown> {
470 const paths = asRecord(pathsValue);545 const paths = asRecord(pathsValue);
471 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;546 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
472 const compactPaths: Record<string, unknown> = {};547 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 };
473 564
474 const normalizeParameters = (entries: unknown): Array<Record<string, unknown>> => {565 const normalizeParameters = (entries: unknown): Array<Record<string, unknown>> => {
475 if (!Array.isArray(entries)) return [];566 if (!Array.isArray(entries)) return [];
476 return entries567 return entries
477 .map((entry) => asRecord(entry))568 .map((entry) => resolveParam(asRecord(entry)))
478 .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string")569 .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string")
479 .map((entry) => ({570 .map((entry) => ({
480 name: String(entry.name),571 name: String(entry.name),
481 in: String(entry.in),572 in: String(entry.in),
482 required: Boolean(entry.required),573 required: Boolean(entry.required),
483 schema: asRecord(entry.schema),574 schema: parameterSchemaFromEntry(entry),
484 }));575 }));
485 };576 };
486 577
@@ -523,15 +614,20 @@ function compactOpenApiPaths( · 36 unmodified lines
523 {614 {
524 const requestBody = asRecord(operation.requestBody);615 const requestBody = asRecord(operation.requestBody);
525 const requestBodyContent = asRecord(requestBody.content);616 const requestBodyContent = asRecord(requestBody.content);
526 const requestBodySchema = getPreferredContentSchema(requestBodyContent);617 const requestBodySchema = resolveSchemaRef(
618 getPreferredContentSchema(requestBodyContent),
619 compSchemas,
620 );
527 621
528 const responses = asRecord(operation.responses);622 const responses = asRecord(operation.responses);
529 let responseSchema: Record<string, unknown> = {};623 let responseSchema: Record<string, unknown> = {};
530 let responseStatus = "";624 let responseStatus = "";
531 for (const [status, responseValue] of Object.entries(responses)) {625 for (const [status, responseValue] of Object.entries(responses)) {
532 if (!status.startsWith("2")) continue;626 if (!status.startsWith("2")) continue;
533 const responseContent = asRecord(asRecord(responseValue).content);627 responseSchema = resolveSchemaRef(
534 responseSchema = getPreferredContentSchema(responseContent);628 getPreferredResponseSchema(asRecord(responseValue)),
629 compSchemas,
630 );
535 responseStatus = status;631 responseStatus = status;
536 if (Object.keys(responseSchema).length > 0) break;632 if (Object.keys(responseSchema).length > 0) break;
537 }633 }
@@ -559,7 +655,7 @@ function compactOpenApiPaths( · 21 unmodified lines
559 ],655 ],
560 };656 };
561 compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema);657 compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema);
562 compactOperation._returnsTypeHint = jsonSchemaTypeHintFallback(responseSchema);658 compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus);
563 } else {659 } else {
564 // Keep full schemas for the fallback path660 // Keep full schemas for the fallback path
565 if (Object.keys(requestBodySchema).length > 0) {661 if (Object.keys(requestBodySchema).length > 0) {
@@ -665,7 +761,12 @@ export async function prepareOpenApiSpec( · 99 unmodified lines
665 servers: servers761 servers: servers
666 .map((server) => (typeof server.url === "string" ? server.url : ""))762 .map((server) => (typeof server.url === "string" ? server.url : ""))
667 .filter((url) => url.length > 0),763 .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 ),
669 dts: dts ?? undefined,770 dts: dts ?? undefined,
670 warnings,771 warnings,
671 };772 };
@@ -736,10 +837,11 @@ export function buildOpenApiToolsFromPrepared( · 64 unmodified lines
736 837
737 const responses = asRecord(operation.responses);838 const responses = asRecord(operation.responses);
738 let responseSchema: Record<string, unknown> = {};839 let responseSchema: Record<string, unknown> = {};
840 let responseStatus = "";
739 for (const [status, responseValue] of Object.entries(responses)) {841 for (const [status, responseValue] of Object.entries(responses)) {
740 if (!status.startsWith("2")) continue;842 if (!status.startsWith("2")) continue;
741 const responseContent = asRecord(asRecord(responseValue).content);843 responseSchema = getPreferredResponseSchema(asRecord(responseValue));
742 responseSchema = getPreferredContentSchema(responseContent);844 responseStatus = status;
743 if (Object.keys(responseSchema).length > 0) break;845 if (Object.keys(responseSchema).length > 0) break;
744 }846 }
745 847
@@ -758,7 +860,7 @@ export function buildOpenApiToolsFromPrepared( · 12 unmodified lines
758 };860 };
759 861
760 argsType = jsonSchemaTypeHintFallback(combinedSchema);862 argsType = jsonSchemaTypeHintFallback(combinedSchema);
761 returnsType = jsonSchemaTypeHintFallback(responseSchema);863 returnsType = responseTypeHintFromSchema(responseSchema, responseStatus);
762 }864 }
763 865
764 const approval = config.overrides?.[operationIdRaw]?.approval866 const approval = config.overrides?.[operationIdRaw]?.approval
executor/package.json+1−2
@@ -4,8 +4,7 @@ · 3 unmodified lines
4 "type": "module",4 "type": "module",
5 "scripts": {5 "scripts": {
6 "deploy": "bunx convex deploy",6 "deploy": "bunx convex deploy",
7 "codegen": "bunx convex codegen && bun scripts/postcodegen.ts",7 "codegen": "bunx convex codegen"
8 "fix-types": "bun scripts/postcodegen.ts"
9 },8 },
10 "dependencies": {9 "dependencies": {
11 "convex": "^1.31.7"10 "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
17import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
18import { join, resolve } from "node:path";
19import * as ts from "typescript";
20
21const EXECUTOR_DIR = resolve(import.meta.dirname, "..");
22const CONVEX_DIR = join(EXECUTOR_DIR, "convex");
23const 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. */
26function 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
43type FunctionKind = "query" | "mutation" | "action";
44type FunctionVisibility = "public" | "internal";
45
46interface ParsedExport {
47 name: string;
48 kind: FunctionKind;
49 visibility: FunctionVisibility;
50}
51
52/** Determine the function kind and visibility from a call expression. */
53function 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. */
66function 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. */
168function 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). */
176function funcRef(kind: FunctionKind, visibility: FunctionVisibility): string {
177 return `FunctionReference<"${kind}", "${visibility}", any, any>`;
178}
179
180/** Generate the type block for one namespace (module). */
181function 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
189function 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
244import 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 */
254export 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 */
266export 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
289main();