History

Refactor AddSourceDialog and useAddSourceFormState for improved credential handling

- Replaced the custom source configuration logic with a new `saveSourceWithCredentials` function in `AddSourceDialog`. - Updated `useAddSourceFormState` to manage authentication state revisions more effectively. - Removed unused functions and streamlined error handling related to credential input. - Enhanced user feedback on source addition and updates with clearer success messages.

Rhys Sullivan committed 8 months ago

Commit 2c3cc0e1 parent 910fd0a19 files · +185 −448Browse files

Unified · Split

19 files
  1. executor/apps/web/src/components/tools/add-source-dialog.tsx +23 −69
  2. executor/apps/web/src/components/tools/add-source-submit.ts +129 −0
  3. executor/apps/web/src/components/tools/use-add-source-form-state.ts +19 −14
  4. executor/packages/convex/auth/db_queries.ts +1 −13
  5. executor/packages/convex/auth/identity.ts +1 −1
  6. executor/packages/convex/database/mappers.ts +1 −1
  7. executor/packages/convex/task/events.ts +1 −1
  8. executor/packages/convex/task/finish.ts +1 −1
  9. executor/packages/convex/task/status.ts +1 −1
  10. executor/packages/core/src/declaration-generation.ts +0 −195
  11. executor/packages/core/src/events.ts +0 −43
  12. executor/packages/core/src/runtimes/runtime-catalog.ts +1 −1
  13. executor/packages/core/src/runtimes/transpile.ts +1 −1
  14. executor/packages/core/src/tool-discovery/indexing.ts +1 −1
  15. executor/packages/core/src/tool-source-execution.ts +3 −3
  16. executor/packages/core/src/tool-source-loaders/openapi-loader.ts +1 −1
  17. executor/packages/core/src/tool-sources.ts +0 −46
  18. executor/packages/core/src/type-hints.ts +1 −1
  19. executor/packages/core/src/typechecker-ts-utils.ts +0 −55
executor/apps/web/src/components/tools/add-source-dialog.tsx+23−69
@@ -17,19 +17,15 @@ import { convexApi } from "@/lib/convex-api"; · 16 unmodified lines
17import type { CredentialRecord, ToolSourceRecord } from "@/lib/types";17import type { CredentialRecord, ToolSourceRecord } from "@/lib/types";
18import { workspaceQueryArgs } from "@/lib/workspace-query-args";18import { workspaceQueryArgs } from "@/lib/workspace-query-args";
19import type { CatalogCollectionItem } from "@/lib/catalog-collections";19import type { CatalogCollectionItem } from "@/lib/catalog-collections";
20import { sourceKeyForSource } from "@/lib/tools-source-helpers";
21import {
22 createCustomSourceConfig,
23} from "./add-source-dialog-helpers";
24import {20import {
25 CatalogViewSection,21 CatalogViewSection,
26 CustomViewSection,22 CustomViewSection,
27} from "./add-source-dialog-sections";23} from "./add-source-dialog-sections";
28import { SourceAuthPanel } from "./add-source-auth-panel";24import { SourceAuthPanel } from "./add-source-auth-panel";
29import {25import {
30 existingCredentialMatchesAuthType,
31 useAddSourceFormState,26 useAddSourceFormState,
32} from "./use-add-source-form-state";27} from "./use-add-source-form-state";
28import { saveSourceWithCredentials } from "./add-source-submit";
33 29
34export function AddSourceDialog({30export function AddSourceDialog({
35 existingSourceNames,31 existingSourceNames,
@@ -90,76 +86,34 @@ export function AddSourceDialog({ · 54 unmodified lines
90 86
91 setSubmitting(true);87 setSubmitting(true);
92 try {88 try {
93 const authConfig = form.type === "openapi" || form.type === "graphql"89 const result = await saveSourceWithCredentials({
94 ? form.buildAuthConfig()90 context,
95 : undefined;91 sourceToEdit,
96 const config = createCustomSourceConfig({92 credentialsLoading,
97 type: form.type,93 upsertToolSource,
98 endpoint: form.endpoint.trim(),94 upsertCredential,
99 baseUrl: form.baseUrl,95 form: {
100 auth: authConfig,96 name: form.name,
101 mcpTransport: form.mcpTransport,97 endpoint: form.endpoint,
102 actorId: context.actorId,98 type: form.type,
99 baseUrl: form.baseUrl,
100 mcpTransport: form.mcpTransport,
101 authType: form.authType,
102 authScope: form.authScope,
103 existingScopedCredential: form.existingScopedCredential,
104 buildAuthConfig: form.buildAuthConfig,
105 hasCredentialInput: form.hasCredentialInput,
106 buildSecretJson: form.buildSecretJson,
107 },
103 });108 });
104 109
105 const created = await upsertToolSource({110 onSourceAdded?.(result.source, { connected: result.connected });
106 ...(sourceToEdit ? { id: sourceToEdit.id } : {}),
107 workspaceId: context.workspaceId,
108 sessionId: context.sessionId,
109 name: form.name.trim(),
110 type: form.type,
111 config,
112 }) as ToolSourceRecord;
113 let linkedCredential = false;
114
115 if ((form.type === "openapi" || form.type === "graphql") && form.authType !== "none") {
116 const sourceKey = sourceKeyForSource(created);
117 if (!sourceKey) {
118 throw new Error("Failed to resolve source key for credentials");
119 }
120
121 if (form.authScope === "actor" && !context.actorId) {
122 throw new Error("Actor credentials require an authenticated actor");
123 }
124
125 const enteredCredential = form.hasCredentialInput();
126 if (!enteredCredential && credentialsLoading) {
127 throw new Error("Loading existing connections, try again in a moment");
128 }
129
130 if (enteredCredential) {
131 const secret = form.buildSecretJson();
132 if (!secret.value) {
133 throw new Error(secret.error ?? "Credential values are required");
134 }
135
136 await upsertCredential({
137 ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}),
138 workspaceId: context.workspaceId,
139 sessionId: context.sessionId,
140 sourceKey,
141 scope: form.authScope,
142 ...(form.authScope === "actor" ? { actorId: context.actorId } : {}),
143 secretJson: secret.value,
144 });
145 linkedCredential = true;
146 } else if (form.existingScopedCredential) {
147 if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) {
148 throw new Error("Enter credentials for the selected auth type");
149 }
150 linkedCredential = true;
151 } else {
152 throw new Error("Enter credentials to finish setup");
153 }
154 }
155
156 onSourceAdded?.(created, { connected: linkedCredential });
157 toast.success(111 toast.success(
158 sourceToEdit112 sourceToEdit
159 ? linkedCredential113 ? result.connected
160 ? `Source "${form.name.trim()}" updated with credentials`114 ? `Source "${form.name.trim()}" updated with credentials`
161 : `Source "${form.name.trim()}" updated`115 : `Source "${form.name.trim()}" updated`
162 : linkedCredential116 : result.connected
163 ? `Source "${form.name.trim()}" added with credentials — loading tools…`117 ? `Source "${form.name.trim()}" added with credentials — loading tools…`
164 : `Source "${form.name.trim()}" added — loading tools…`,118 : `Source "${form.name.trim()}" added — loading tools…`,
165 );119 );
executor/apps/web/src/components/tools/add-source-submit.ts+129−0
@@ -0,0 +1,129 @@
1import { sourceKeyForSource } from "@/lib/tools-source-helpers";
2import type {
3 AnonymousContext,
4 CredentialRecord,
5 CredentialScope,
6 SourceAuthType,
7 ToolSourceRecord,
8} from "@/lib/types";
9import { createCustomSourceConfig, type SourceType } from "./add-source-dialog-helpers";
10import { existingCredentialMatchesAuthType } from "./add-source-form-utils";
11
12type UpsertToolSourceFn = (args: {
13 id?: ToolSourceRecord["id"];
14 workspaceId: AnonymousContext["workspaceId"];
15 sessionId: AnonymousContext["sessionId"];
16 name: string;
17 type: SourceType;
18 config: Record<string, unknown>;
19}) => Promise<unknown>;
20
21type UpsertCredentialFn = (args: {
22 id?: CredentialRecord["id"];
23 workspaceId: AnonymousContext["workspaceId"];
24 sessionId: AnonymousContext["sessionId"];
25 sourceKey: string;
26 scope: CredentialScope;
27 actorId?: AnonymousContext["actorId"];
28 secretJson: Record<string, unknown>;
29}) => Promise<unknown>;
30
31type SaveFormSnapshot = {
32 name: string;
33 endpoint: string;
34 type: SourceType;
35 baseUrl: string;
36 mcpTransport: "auto" | "streamable-http" | "sse";
37 authType: Exclude<SourceAuthType, "mixed">;
38 authScope: CredentialScope;
39 existingScopedCredential: CredentialRecord | null;
40 buildAuthConfig: () => Record<string, unknown> | undefined;
41 hasCredentialInput: () => boolean;
42 buildSecretJson: () => { value?: Record<string, unknown>; error?: string };
43};
44
45export async function saveSourceWithCredentials({
46 context,
47 sourceToEdit,
48 form,
49 credentialsLoading,
50 upsertToolSource,
51 upsertCredential,
52}: {
53 context: AnonymousContext;
54 sourceToEdit?: ToolSourceRecord;
55 form: SaveFormSnapshot;
56 credentialsLoading: boolean;
57 upsertToolSource: UpsertToolSourceFn;
58 upsertCredential: UpsertCredentialFn;
59}): Promise<{ source: ToolSourceRecord; connected: boolean }> {
60 const authConfig = form.type === "openapi" || form.type === "graphql"
61 ? form.buildAuthConfig()
62 : undefined;
63
64 const config = createCustomSourceConfig({
65 type: form.type,
66 endpoint: form.endpoint.trim(),
67 baseUrl: form.baseUrl,
68 auth: authConfig,
69 mcpTransport: form.mcpTransport,
70 actorId: context.actorId,
71 });
72
73 const created = await upsertToolSource({
74 ...(sourceToEdit ? { id: sourceToEdit.id } : {}),
75 workspaceId: context.workspaceId,
76 sessionId: context.sessionId,
77 name: form.name.trim(),
78 type: form.type,
79 config,
80 }) as ToolSourceRecord;
81
82 let linkedCredential = false;
83
84 if ((form.type === "openapi" || form.type === "graphql") && form.authType !== "none") {
85 const sourceKey = sourceKeyForSource(created);
86 if (!sourceKey) {
87 throw new Error("Failed to resolve source key for credentials");
88 }
89
90 if (form.authScope === "actor" && !context.actorId) {
91 throw new Error("Actor credentials require an authenticated actor");
92 }
93
94 const enteredCredential = form.hasCredentialInput();
95 if (!enteredCredential && credentialsLoading) {
96 throw new Error("Loading existing connections, try again in a moment");
97 }
98
99 if (enteredCredential) {
100 const secret = form.buildSecretJson();
101 if (!secret.value) {
102 throw new Error(secret.error ?? "Credential values are required");
103 }
104
105 await upsertCredential({
106 ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}),
107 workspaceId: context.workspaceId,
108 sessionId: context.sessionId,
109 sourceKey,
110 scope: form.authScope,
111 ...(form.authScope === "actor" ? { actorId: context.actorId } : {}),
112 secretJson: secret.value,
113 });
114 linkedCredential = true;
115 } else if (form.existingScopedCredential) {
116 if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) {
117 throw new Error("Enter credentials for the selected auth type");
118 }
119 linkedCredential = true;
120 } else {
121 throw new Error("Enter credentials to finish setup");
122 }
123 }
124
125 return {
126 source: created,
127 connected: linkedCredential,
128 };
129}
executor/apps/web/src/components/tools/use-add-source-form-state.ts+19−14
@@ -35,6 +35,17 @@ function patchUi( · 34 unmodified lines
35 setUi((current) => ({ ...current, ...patch }));35 setUi((current) => ({ ...current, ...patch }));
36}36}
37 37
38function patchUiWithAuthRevision(
39 setUi: Dispatch<SetStateAction<AddSourceUiState>>,
40 patch: Partial<AddSourceUiState> = {},
41) {
42 setUi((current) => ({
43 ...current,
44 ...patch,
45 authRevision: current.authRevision + 1,
46 }));
47}
48
38export function useAddSourceFormState({49export function useAddSourceFormState({
39 open,50 open,
40 sourceToEdit,51 sourceToEdit,
@@ -88,7 +99,7 @@ export function useAddSourceFormState({ · 47 unmodified lines
88 if (sourceToEdit) {99 if (sourceToEdit) {
89 const editValues = sourceToFormValues(sourceToEdit);100 const editValues = sourceToFormValues(sourceToEdit);
90 form.reset(editValues);101 form.reset(editValues);
91 patchUi(setUi, {102 patchUiWithAuthRevision(setUi, {
92 ...createDefaultUiState("custom"),103 ...createDefaultUiState("custom"),
93 openApiBaseUrlOptions: editValues.baseUrl104 openApiBaseUrlOptions: editValues.baseUrl
94 ? [editValues.baseUrl]105 ? [editValues.baseUrl]
@@ -97,16 +108,12 @@ export function useAddSourceFormState({ · 2 unmodified lines
97 return fallback ? [fallback] : [];108 return fallback ? [fallback] : [];
98 })(),109 })(),
99 authManuallyEdited: Boolean(sourceToEdit.config.auth),110 authManuallyEdited: Boolean(sourceToEdit.config.auth),
100 authRevision: ui.authRevision + 1,
101 });111 });
102 return;112 return;
103 }113 }
104 114
105 form.reset(createDefaultFormValues());115 form.reset(createDefaultFormValues());
106 patchUi(setUi, {116 patchUiWithAuthRevision(setUi, createDefaultUiState("catalog"));
107 ...createDefaultUiState("catalog"),
108 authRevision: ui.authRevision + 1,
109 });
110 }, [form, open, sourceToEdit]);117 }, [form, open, sourceToEdit]);
111 118
112 const getTakenSourceNames = () => {119 const getTakenSourceNames = () => {
@@ -157,11 +164,10 @@ export function useAddSourceFormState({ · 44 unmodified lines
157 endpoint: item.specUrl,164 endpoint: item.specUrl,
158 baseUrl: defaultBaseUrl,165 baseUrl: defaultBaseUrl,
159 });166 });
160 patchUi(setUi, {167 patchUiWithAuthRevision(setUi, {
161 view: "custom",168 view: "custom",
162 openApiBaseUrlOptions: defaultBaseUrl ? [defaultBaseUrl] : [],169 openApiBaseUrlOptions: defaultBaseUrl ? [defaultBaseUrl] : [],
163 authManuallyEdited: false,170 authManuallyEdited: false,
164 authRevision: ui.authRevision + 1,
165 });171 });
166 };172 };
167 173
@@ -180,10 +186,9 @@ export function useAddSourceFormState({ · 12 unmodified lines
180 return;186 return;
181 }187 }
182 188
183 patchUi(setUi, {189 patchUiWithAuthRevision(setUi, {
184 openApiBaseUrlOptions: [],190 openApiBaseUrlOptions: [],
185 authManuallyEdited: false,191 authManuallyEdited: false,
186 authRevision: ui.authRevision + 1,
187 });192 });
188 form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false });193 form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false });
189 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });194 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });
@@ -194,22 +199,22 @@ export function useAddSourceFormState({ · 4 unmodified lines
194 form.setValue("basicPassword", "", { shouldDirty: false, shouldTouch: false });199 form.setValue("basicPassword", "", { shouldDirty: false, shouldTouch: false });
195 };200 };
196 201
197 const bumpAuthRevision = () => patchUi(setUi, { authRevision: ui.authRevision + 1 });202 const bumpAuthRevision = () => patchUiWithAuthRevision(setUi);
198 203
199 const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => {204 const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => {
200 form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true });205 form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true });
201 patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 });206 patchUiWithAuthRevision(setUi, { authManuallyEdited: true });
202 };207 };
203 208
204 const handleAuthScopeChange = (authScope: CredentialScope) => {209 const handleAuthScopeChange = (authScope: CredentialScope) => {
205 form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true });210 form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true });
206 patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 });211 patchUiWithAuthRevision(setUi, { authManuallyEdited: true });
207 };212 };
208 213
209 const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => {214 const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => {
210 if (field === "apiKeyHeader") {215 if (field === "apiKeyHeader") {
211 form.setValue("apiKeyHeader", value, { shouldDirty: true, shouldTouch: true });216 form.setValue("apiKeyHeader", value, { shouldDirty: true, shouldTouch: true });
212 patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 });217 patchUiWithAuthRevision(setUi, { authManuallyEdited: true });
213 return;218 return;
214 }219 }
215 220
executor/packages/convex/auth/db_queries.ts+1−13
@@ -1,5 +1,4 @@
1import type { Doc } from "../_generated/dataModel";1import type { DbCtx, OrganizationId } from "./types";
2import type { AccountId, DbCtx, OrganizationId } from "./types";
3 2
4export async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) {3export async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) {
5 return await ctx.db4 return await ctx.db
@@ -28,14 +27,3 @@ export async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organization · 22 unmodified lines
28 .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))27 .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))
29 .first();28 .first();
30}29}
31
32export async function getWorkspaceMembershipByWorkspaceAndAccount(
33 ctx: DbCtx,
34 workspaceId: Doc<"workspaces">["_id"],
35 accountId: AccountId,
36) {
37 return await ctx.db
38 .query("workspaceMembers")
39 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))
40 .unique();
41}
executor/packages/convex/auth/identity.ts+1−1
@@ -12,7 +12,7 @@ function deriveFallbackUserLabel(workosUserId: string): string { · 11 unmodified lines
12 return `User ${workosUserId.slice(-6)}`;12 return `User ${workosUserId.slice(-6)}`;
13}13}
14 14
15export function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {15function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {
16 for (const key of keys) {16 for (const key of keys) {
17 const value = identity[key];17 const value = identity[key];
18 if (typeof value === "string" && value.trim().length > 0) {18 if (typeof value === "string" && value.trim().length > 0) {
executor/packages/convex/database/mappers.ts+1−1
@@ -2,7 +2,7 @@ import type { Doc } from "../_generated/dataModel"; · 1 unmodified line
2import { asRecord } from "../lib/object";2import { asRecord } from "../lib/object";
3import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";3import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";
4 4
5export function stableStringify(value: unknown): string {5function stableStringify(value: unknown): string {
6 if (Array.isArray(value)) {6 if (Array.isArray(value)) {
7 return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;7 return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
8 }8 }
executor/packages/convex/task/events.ts+1−1
@@ -2,7 +2,7 @@ import { internal } from "../_generated/api"; · 1 unmodified line
2 2
3export type TaskEventName = "task" | "approval";3export type TaskEventName = "task" | "approval";
4 4
5export interface TaskEventMutationContext {5interface TaskEventMutationContext {
6 runMutation: (6 runMutation: (
7 mutation: typeof internal.database.createTaskEvent,7 mutation: typeof internal.database.createTaskEvent,
8 args: {8 args: {
executor/packages/convex/task/finish.ts+1−1
@@ -5,7 +5,7 @@ import type { TaskRecord, TaskStatus } from "../../core/src/types"; · 4 unmodified lines
5type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">;5type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">;
6type TaskFinishContext = Pick<ActionCtx, "runMutation"> | Pick<MutationCtx, "runMutation">;6type TaskFinishContext = Pick<ActionCtx, "runMutation"> | Pick<MutationCtx, "runMutation">;
7 7
8export interface MarkTaskFinishedInput {8interface MarkTaskFinishedInput {
9 taskId: string;9 taskId: string;
10 status: TerminalTaskStatus;10 status: TerminalTaskStatus;
11 exitCode?: number;11 exitCode?: number;
executor/packages/convex/task/status.ts+1−1
@@ -1,6 +1,6 @@
1import type { TaskStatus } from "../../core/src/types";1import type { TaskStatus } from "../../core/src/types";
2 2
3export type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">;3type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">;
4 4
5export function isTerminalTaskStatus(status: TaskStatus): status is TerminalTaskStatus {5export function isTerminalTaskStatus(status: TaskStatus): status is TerminalTaskStatus {
6 return status === "completed" || status === "failed" || status === "timed_out" || status === "denied";6 return status === "completed" || status === "failed" || status === "timed_out" || status === "denied";
executor/packages/core/src/declaration-generation.ts+0−195
@@ -1,199 +1,4 @@
1import type { ToolDescriptor } from "./types";1import type { ToolDescriptor } from "./types";
2import {
3 getSourceFileParseDiagnostics,
4 getTypeScriptModule,
5 indentBlock,
6 propertyNameText,
7} from "./typechecker-ts-utils";
8import { OPENAPI_HELPER_TYPES } from "./openapi-helper-types";
9
10function isValidTypeExpression(typeExpression: string): boolean {
11 const ts = getTypeScriptModule();
12 if (!ts) {
13 return !/[\r\n`]/.test(typeExpression);
14 }
15
16 return (() => {
17 try {
18 const sourceFile = ts.createSourceFile(
19 "_type_expr_check.ts",
20 `type __T = ${typeExpression};`,
21 ts.ScriptTarget.ESNext,
22 true,
23 ts.ScriptKind.TS,
24 );
25 return getSourceFileParseDiagnostics(sourceFile).length === 0;
26 } catch {
27 return false;
28 }
29 })();
30}
31
32function safeTypeExpression(raw: string | undefined, fallback: string): string {
33 const typeExpression = raw?.trim();
34 if (!typeExpression) return fallback;
35 return isValidTypeExpression(typeExpression) ? typeExpression : fallback;
36}
37
38/**
39 * Build a minimal OpenAPI .d.ts containing only selected `operations` members.
40 * Returns null when slicing is not possible and callers should use full .d.ts.
41 */
42export function sliceOpenApiOperationsDts(
43 dts: string,
44 operationIds: Iterable<string>,
45): string | null {
46 const ts = getTypeScriptModule();
47 if (!ts) return null;
48
49 const wanted = new Set([...operationIds].filter((value) => value.trim().length > 0));
50 if (wanted.size === 0) return null;
51
52 const sourceFile = ts.createSourceFile(
53 "openapi-source.d.ts",
54 dts,
55 ts.ScriptTarget.ESNext,
56 true,
57 ts.ScriptKind.TS,
58 );
59
60 const operationsInterface = sourceFile.statements.find((statement) =>
61 ts.isInterfaceDeclaration(statement)
62 && statement.name.text === "operations",
63 );
64
65 if (!operationsInterface || !ts.isInterfaceDeclaration(operationsInterface)) {
66 return null;
67 }
68
69 const selectedMembers: string[] = [];
70 for (const member of operationsInterface.members) {
71 if (!ts.isPropertySignature(member) || !member.name) continue;
72 const key = propertyNameText(member.name, ts);
73 if (!key || !wanted.has(key)) continue;
74
75 const raw = dts.slice(member.getFullStart(), member.end).trim();
76 if (raw.length > 0) {
77 selectedMembers.push(raw);
78 }
79 }
80
81 if (selectedMembers.length === 0) return null;
82
83 const body = selectedMembers.map((member) => indentBlock(member)).join("\n");
84 return `export interface operations {\n${body}\n}`;
85}
86
87function stripExportKeywordsForTypechecker(dts: string): string {
88 return dts.replace(/\bexport\s+/g, "").trim();
89}
90
91export interface GenerateToolDeclarationOptions {
92 sourceDtsBySource?: Record<string, string>;
93}
94
95interface ToolTreeNode {
96 children: Map<string, ToolTreeNode>;
97 tool?: ToolDescriptor;
98}
99
100function createToolTree(tools: ToolDescriptor[]): ToolTreeNode {
101 const root: ToolTreeNode = { children: new Map() };
102
103 for (const tool of tools) {
104 const segments = tool.path.split(".");
105 let node = root;
106 for (let i = 0; i < segments.length; i += 1) {
107 const segment = segments[i];
108 if (!node.children.has(segment)) {
109 node.children.set(segment, { children: new Map() });
110 }
111 node = node.children.get(segment)!;
112 if (i === segments.length - 1) {
113 node.tool = tool;
114 }
115 }
116 }
117
118 return root;
119}
120
121function renderToolCallSignature(
122 key: string,
123 tool: ToolDescriptor,
124 dtsSources: Set<string>,
125 pad: string,
126): string {
127 if (tool.operationId && tool.source && dtsSources.has(tool.source)) {
128 const opKey = JSON.stringify(tool.operationId);
129 const openApiInputType = `ToolInput<operations[${opKey}]>`;
130 const openApiOutputType = `ToolOutput<operations[${opKey}]>`;
131 return `${pad}${key}(input: ${openApiInputType}): Promise<${openApiOutputType}>;`;
132 }
133
134 const strictArgsType = tool.strictArgsType?.trim();
135 const strictReturnsType = tool.strictReturnsType?.trim();
136 const effectiveArgs = strictArgsType || tool.argsType;
137 const effectiveReturns = strictReturnsType || tool.returnsType;
138 const hasArgsType = Boolean(effectiveArgs?.trim());
139 const args = safeTypeExpression(effectiveArgs, "Record<string, unknown>");
140 const returns = safeTypeExpression(effectiveReturns, "unknown");
141 const inputParam = !hasArgsType || args === "{}"
142 ? `input?: ${args}`
143 : `input: ${args}`;
144
145 return `${pad}${key}(${inputParam}): Promise<${returns}>;`;
146}
147
148function renderToolTree(
149 node: ToolTreeNode,
150 indent: number,
151 dtsSources: Set<string>,
152): string {
153 const pad = " ".repeat(indent);
154 const lines: string[] = [];
155
156 for (const [key, child] of node.children) {
157 if (child.tool) {
158 lines.push(renderToolCallSignature(key, child.tool, dtsSources, pad));
159 continue;
160 }
161
162 lines.push(`${pad}${key}: {`);
163 lines.push(renderToolTree(child, indent + 1, dtsSources));
164 lines.push(`${pad}};`);
165 }
166
167 return lines.join("\n");
168}
169
170/**
171 * Build a `declare const tools: { ... }` block from flat tool descriptors.
172 */
173export function generateToolDeclarations(
174 tools: ToolDescriptor[],
175 options?: GenerateToolDeclarationOptions,
176): string {
177 const sourceDtsBySource = options?.sourceDtsBySource ?? {};
178 const dtsSources = new Set(Object.keys(sourceDtsBySource));
179 const root = createToolTree(tools);
180
181 const parts: string[] = [];
182
183 const dtsEntries = Object.entries(sourceDtsBySource)
184 .filter(([, dts]) => typeof dts === "string" && dts.trim().length > 0)
185 .sort(([a], [b]) => a.localeCompare(b));
186
187 if (dtsEntries.length > 0) {
188 parts.push(OPENAPI_HELPER_TYPES);
189 for (const [sourceKey, dts] of dtsEntries) {
190 parts.push(`// OpenAPI types from ${sourceKey}\n${stripExportKeywordsForTypechecker(dts)}`);
191 }
192 }
193
194 parts.push(`declare const tools: {\n${renderToolTree(root, 1, dtsSources)}\n};`);
195 return parts.join("\n");
196}
197 2
198/**3/**
199 * Generate the tool inventory text for the MCP run_code description.4 * Generate the tool inventory text for the MCP run_code description.
executor/packages/core/src/events.ts+0−43
@@ -4,46 +4,3 @@ export interface LiveTaskEvent { · 3 unmodified lines
4 payload: unknown;4 payload: unknown;
5 createdAt: number;5 createdAt: number;
6}6}
7
8type Subscriber = (event: LiveTaskEvent) => void;
9
10export class TaskEventHub {
11 private readonly subscribers = new Map<string, Set<Subscriber>>();
12
13 publish(taskId: string, event: LiveTaskEvent): void {
14 const listeners = this.subscribers.get(taskId);
15 if (!listeners || listeners.size === 0) {
16 return;
17 }
18
19 for (const listener of listeners) {
20 try {
21 listener(event);
22 } catch {
23 listeners.delete(listener);
24 }
25 }
26
27 if (listeners.size === 0) {
28 this.subscribers.delete(taskId);
29 }
30 }
31
32 subscribe(taskId: string, listener: Subscriber): () => void {
33 const listeners = this.subscribers.get(taskId) ?? new Set<Subscriber>();
34 listeners.add(listener);
35 this.subscribers.set(taskId, listeners);
36
37 return () => {
38 const active = this.subscribers.get(taskId);
39 if (!active) {
40 return;
41 }
42
43 active.delete(listener);
44 if (active.size === 0) {
45 this.subscribers.delete(taskId);
46 }
47 };
48 }
49}
executor/packages/core/src/runtimes/runtime-catalog.ts+1−1
@@ -22,7 +22,7 @@ function isTruthyEnvValue(value: string | undefined): boolean { · 21 unmodified lines
22 return TRUTHY_ENV_VALUES.has(value.trim().toLowerCase());22 return TRUTHY_ENV_VALUES.has(value.trim().toLowerCase());
23}23}
24 24
25export function isCloudflareDynamicWorkerOnlyMode(): boolean {25function isCloudflareDynamicWorkerOnlyMode(): boolean {
26 return isTruthyEnvValue(process.env[CLOUDFLARE_DYNAMIC_WORKER_ONLY_ENV_KEY]);26 return isTruthyEnvValue(process.env[CLOUDFLARE_DYNAMIC_WORKER_ONLY_ENV_KEY]);
27}27}
28 28
executor/packages/core/src/runtimes/transpile.ts+1−1
@@ -6,7 +6,7 @@ import { Result, TaggedError } from "better-result"; · 5 unmodified lines
6// Errors6// Errors
7// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
8 8
9export class TranspileError extends TaggedError("TranspileError")<{9class TranspileError extends TaggedError("TranspileError")<{
10 message: string;10 message: string;
11}>() {}11}>() {}
12 12
executor/packages/core/src/tool-discovery/indexing.ts+1−1
@@ -55,7 +55,7 @@ function simplifyNamespaceSegment(segment: string): string { · 54 unmodified lines
55 return collapsed.join("_");55 return collapsed.join("_");
56}56}
57 57
58export function preferredToolPath(path: string): string {58function preferredToolPath(path: string): string {
59 const segments = path.split(".").filter(Boolean);59 const segments = path.split(".").filter(Boolean);
60 if (segments.length === 0) return path;60 if (segments.length === 0) return path;
61 61
executor/packages/core/src/tool-source-execution.ts+3−3
@@ -13,7 +13,7 @@ export interface GraphqlExecutionEnvelope { · 12 unmodified lines
13 errors: unknown[];13 errors: unknown[];
14}14}
15 15
16export function isMcpReconnectableError(error: unknown): boolean {16function isMcpReconnectableError(error: unknown): boolean {
17 const message = error instanceof Error ? error.message : String(error);17 const message = error instanceof Error ? error.message : String(error);
18 return /(socket|closed|ECONNRESET|fetch failed)/i.test(message);18 return /(socket|closed|ECONNRESET|fetch failed)/i.test(message);
19}19}
@@ -106,14 +106,14 @@ export async function executeOpenApiRequest( · 86 unmodified lines
106 return await response.text();106 return await response.text();
107}107}
108 108
109export function hasGraphqlData(data: unknown): boolean {109function hasGraphqlData(data: unknown): boolean {
110 if (data === null || data === undefined) return false;110 if (data === null || data === undefined) return false;
111 if (Array.isArray(data)) return data.length > 0;111 if (Array.isArray(data)) return data.length > 0;
112 if (typeof data === "object") return Object.keys(data as Record<string, unknown>).length > 0;112 if (typeof data === "object") return Object.keys(data as Record<string, unknown>).length > 0;
113 return true;113 return true;
114}114}
115 115
116export function normalizeGraphqlEnvelope(result: { data?: unknown; errors?: unknown[] }): GraphqlExecutionEnvelope {116function normalizeGraphqlEnvelope(result: { data?: unknown; errors?: unknown[] }): GraphqlExecutionEnvelope {
117 return {117 return {
118 data: result.data ?? null,118 data: result.data ?? null,
119 errors: Array.isArray(result.errors) ? result.errors : [],119 errors: Array.isArray(result.errors) ? result.errors : [],
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+1−1
@@ -21,7 +21,7 @@ import type { SerializedTool } from "../tool-source-serialization"; · 20 unmodified lines
21const POSTMAN_SPEC_PREFIX = "postman:";21const POSTMAN_SPEC_PREFIX = "postman:";
22const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy";22const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy";
23 23
24export function parsePostmanCollectionUid(spec: string): string | null {24function parsePostmanCollectionUid(spec: string): string | null {
25 if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) {25 if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) {
26 return null;26 return null;
27 }27 }
executor/packages/core/src/tool-sources.ts+0−46
@@ -24,52 +24,6 @@ export { prepareOpenApiSpec } from "./openapi-prepare"; · 23 unmodified lines
24export { parseGraphqlOperationPaths } from "./graphql-operation-paths";24export { parseGraphqlOperationPaths } from "./graphql-operation-paths";
25export { rehydrateTools, serializeTools, type SerializedTool } from "./tool-source-serialization";25export { rehydrateTools, serializeTools, type SerializedTool } from "./tool-source-serialization";
26export { buildOpenApiToolsFromPrepared } from "./openapi-tool-builder";26export { buildOpenApiToolsFromPrepared } from "./openapi-tool-builder";
27export { parsePostmanCollectionUid } from "./tool-source-loaders/openapi-loader";
28
29function isRecord(value: unknown): value is Record<string, unknown> {
30 return typeof value === "object" && value !== null;
31}
32
33function isNonEmptyString(value: unknown): value is string {
34 return typeof value === "string" && value.trim().length > 0;
35}
36
37function isExternalToolSourceType(value: unknown): value is ExternalToolSourceConfig["type"] {
38 return value === "mcp" || value === "openapi" || value === "graphql";
39}
40
41export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSourceConfig[] {
42 if (!raw || raw.trim().length === 0) {
43 return [];
44 }
45
46 let parsed: unknown;
47 try {
48 parsed = JSON.parse(raw) as unknown;
49 } catch (error) {
50 const message = error instanceof Error ? error.message : String(error);
51 throw new Error(`EXECUTOR_TOOL_SOURCES must be valid JSON: ${message}`);
52 }
53
54 if (!Array.isArray(parsed)) {
55 throw new Error("EXECUTOR_TOOL_SOURCES must be a JSON array");
56 }
57
58 for (let i = 0; i < parsed.length; i += 1) {
59 const entry = parsed[i];
60 if (!isRecord(entry)) {
61 throw new Error(`EXECUTOR_TOOL_SOURCES[${i}] must be an object`);
62 }
63 if (!isExternalToolSourceType(entry.type)) {
64 throw new Error(`EXECUTOR_TOOL_SOURCES[${i}].type must be one of: mcp, openapi, graphql`);
65 }
66 if (!isNonEmptyString(entry.name)) {
67 throw new Error(`EXECUTOR_TOOL_SOURCES[${i}].name must be a non-empty string`);
68 }
69 }
70
71 return parsed as ExternalToolSourceConfig[];
72}
73 27
74export interface CompiledToolSourceArtifact {28export interface CompiledToolSourceArtifact {
75 version: "v1";29 version: "v1";
executor/packages/core/src/type-hints.ts+1−1
@@ -188,7 +188,7 @@ export function extractTopLevelTypeKeys(typeHint: string): string[] { · 187 unmodified lines
188 return keys;188 return keys;
189}189}
190 190
191export function extractTopLevelTypeKeysFromExpression(typeHint: string): string[] {191function extractTopLevelTypeKeysFromExpression(typeHint: string): string[] {
192 const direct = extractTopLevelTypeKeys(typeHint);192 const direct = extractTopLevelTypeKeys(typeHint);
193 if (direct.length > 0) return direct;193 if (direct.length > 0) return direct;
194 194
executor/packages/core/src/typechecker-ts-utils.ts+0−55
@@ -1,55 +0,0 @@
1import { Result } from "better-result";
2
3export function getTypeScriptModule(): typeof import("typescript") | null {
4 const loaded = Result.try(() => require("typescript") as typeof import("typescript"));
5 return loaded.isOk() ? loaded.value : null;
6}
7
8export function getSourceFileParseDiagnostics(
9 sourceFile: import("typescript").SourceFile,
10): import("typescript").Diagnostic[] {
11 return (
12 sourceFile as { parseDiagnostics?: import("typescript").Diagnostic[] }
13 ).parseDiagnostics ?? [];
14}
15
16export function unwrapExpression(
17 expression: import("typescript").Expression,
18 ts: typeof import("typescript"),
19): import("typescript").Expression {
20 let current = expression;
21 while (
22 ts.isParenthesizedExpression(current)
23 || ts.isAsExpression(current)
24 || ts.isTypeAssertionExpression(current)
25 || ts.isNonNullExpression(current)
26 || ts.isPartiallyEmittedExpression(current)
27 || ts.isSatisfiesExpression(current)
28 ) {
29 current = current.expression;
30 }
31 return current;
32}
33
34export function propertyNameText(
35 name: import("typescript").PropertyName,
36 ts: typeof import("typescript"),
37): string | null {
38 if (ts.isIdentifier(name)) return name.text;
39 if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
40 if (ts.isNumericLiteral(name)) return name.text;
41 if (ts.isComputedPropertyName(name)) {
42 const expression = unwrapExpression(name.expression, ts);
43 if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression) || ts.isNumericLiteral(expression)) {
44 return expression.text;
45 }
46 }
47 return null;
48}
49
50export function indentBlock(text: string, indent = " "): string {
51 return text
52 .split("\n")
53 .map((line) => (line.trim().length === 0 ? "" : `${indent}${line}`))
54 .join("\n");
55}