History

small refactor

Rhys Sullivan committed 9 months ago

Commit af4f3611 parent e66daeb167 files · +16212 −14087Browse files

Unified · Split

167 files
  1. assistant/README.md +41 −1
  2. assistant/packages/bot/package.json +2 −8
  3. assistant/packages/bot/src/commands/ask.tsx +0 −59
  4. assistant/packages/bot/src/index.ts +480 −76
  5. assistant/packages/bot/src/views/task-message.tsx +0 −137
  6. assistant/packages/core/src/agent.ts +20 −4
  7. assistant/packages/server/src/index.ts +10 −55
  8. assistant/packages/server/src/link-store.ts +76 −0
  9. assistant/packages/server/src/routes.ts +212 −88
  10. bun.lock +5 −11
  11. executor/apps/web/src/components/app-shell.tsx +7 −629
  12. executor/apps/web/src/components/approval-notifier.tsx +2 −1
  13. executor/apps/web/src/components/approvals-view.tsx +9 −59
  14. executor/apps/web/src/components/code-editor-monaco.ts +193 −0
  15. executor/apps/web/src/components/code-editor-types.ts +204 −0
  16. executor/apps/web/src/components/code-editor.tsx +42 −445
  17. executor/apps/web/src/components/dashboard-pending-approvals-card.tsx +67 −0
  18. executor/apps/web/src/components/dashboard-recent-tasks-card.tsx +57 −0
  19. executor/apps/web/src/components/dashboard-setup-card.tsx +70 −0
  20. executor/apps/web/src/components/dashboard-stat-card.tsx +36 −0
  21. executor/apps/web/src/components/dashboard-tools-summary-card.tsx +83 −0
  22. executor/apps/web/src/components/dashboard-view-helpers.ts +101 −0
  23. executor/apps/web/src/components/dashboard-view.tsx +28 −383
  24. executor/apps/web/src/components/mcp-setup-card.tsx +9 −1
  25. executor/apps/web/src/components/no-organization-modal.tsx +84 −0
  26. executor/apps/web/src/components/session-info.tsx +237 −0
  27. executor/apps/web/src/components/task-composer.tsx +161 −0
  28. executor/apps/web/src/components/task-detail.tsx +255 −0
  29. executor/apps/web/src/components/task-list-item.tsx +53 −0
  30. executor/apps/web/src/components/tasks-view.tsx +10 −508
  31. executor/apps/web/src/components/tool-explorer-copy-button.tsx +55 −0
  32. executor/apps/web/src/components/tool-explorer-derived.ts +124 −0
  33. executor/apps/web/src/components/tool-explorer-groups.tsx +238 −0
  34. executor/apps/web/src/components/tool-explorer-helpers.ts +34 −0
  35. executor/apps/web/src/components/tool-explorer-loading.tsx +35 −0
  36. executor/apps/web/src/components/tool-explorer-rows.tsx +175 −0
  37. executor/apps/web/src/components/tool-explorer-tool-detail.tsx +43 −0
  38. executor/apps/web/src/components/tool-explorer-toolbar.tsx +319 −0
  39. executor/apps/web/src/components/tool-explorer-type-signature.tsx +89 −0
  40. executor/apps/web/src/components/tool-explorer.tsx +83 −1236
  41. executor/apps/web/src/components/tools-view-add-source-dialog-helpers.ts +92 −0
  42. executor/apps/web/src/components/tools-view-add-source-dialog-sections.tsx +264 −0
  43. executor/apps/web/src/components/tools-view-add-source-dialog.tsx +242 −0
  44. executor/apps/web/src/components/tools-view-connection-auth-fields.tsx +101 −0
  45. executor/apps/web/src/components/tools-view-connection-form-dialog-form.ts +257 −0
  46. executor/apps/web/src/components/tools-view-connection-form-dialog-state.ts +82 −0
  47. executor/apps/web/src/components/tools-view-connection-form-dialog.tsx +334 −0
  48. executor/apps/web/src/components/tools-view-connection-form-save.ts +76 −0
  49. executor/apps/web/src/components/tools-view-credentials.tsx +228 −0
  50. executor/apps/web/src/components/tools-view-source-auth-dialog.tsx +192 −0
  51. executor/apps/web/src/components/tools-view-source-card.tsx +176 −0
  52. executor/apps/web/src/components/tools-view-source-quality-details.tsx +110 −0
  53. executor/apps/web/src/components/tools-view-sources.tsx +2 −0
  54. executor/apps/web/src/components/tools-view.tsx +12 −1846
  55. executor/apps/web/src/components/workspace-selector.tsx +351 −0
  56. executor/apps/web/src/lib/approval-input-format.ts +79 −0
  57. executor/apps/web/src/lib/catalog-collections.ts +112 −0
  58. executor/apps/web/src/lib/credentials-source-helpers.ts +90 −0
  59. executor/apps/web/src/lib/session-context-derivation.ts +138 −0
  60. executor/apps/web/src/lib/session-context.tsx +43 −147
  61. executor/apps/web/src/lib/session-storage.ts +60 −0
  62. executor/apps/web/src/lib/tool-explorer-grouping.ts +156 −0
  63. executor/apps/web/src/lib/tool-source-utils.ts +37 −0
  64. executor/apps/web/src/lib/tools-source-helpers.ts +278 −0
  65. executor/apps/web/src/lib/workspace-query-args.ts +7 −0
  66. executor/packages/convex/auth-accounts.ts +48 −0
  67. executor/packages/convex/auth-bootstrap.ts +148 −0
  68. executor/packages/convex/auth-db-queries.ts +41 −0
  69. executor/packages/convex/auth-event-handlers.ts +335 −0
  70. executor/packages/convex/auth-identity.ts +70 −0
  71. executor/packages/convex/auth-memberships.ts +133 −0
  72. executor/packages/convex/auth-naming.ts +89 −0
  73. executor/packages/convex/auth-personal-workspace.ts +180 −0
  74. executor/packages/convex/auth-types.ts +13 −0
  75. executor/packages/convex/auth.ts +5 −894
  76. executor/packages/convex/credentialsNode.ts +1 −7
  77. executor/packages/convex/database-anonymous-session.ts +81 −0
  78. executor/packages/convex/database-anonymous.ts +163 −0
  79. executor/packages/convex/database-approvals.ts +156 −0
  80. executor/packages/convex/database-credentials.ts +201 −0
  81. executor/packages/convex/database-mappers.ts +139 −0
  82. executor/packages/convex/database-oauth.ts +182 −0
  83. executor/packages/convex/database-policies.ts +94 −0
  84. executor/packages/convex/database-readers.ts +77 −0
  85. executor/packages/convex/database-task-events.ts +61 −0
  86. executor/packages/convex/database-tasks.ts +145 −0
  87. executor/packages/convex/database-tool-calls.ts +127 −0
  88. executor/packages/convex/database-tool-sources.ts +117 −0
  89. executor/packages/convex/database-validators.ts +27 −0
  90. executor/packages/convex/database.ts +52 −1531
  91. executor/packages/convex/executor.ts +12 −30
  92. executor/packages/convex/executor_node/events.ts +3 −3
  93. executor/packages/convex/executor_node/task_runner.ts +58 −66
  94. executor/packages/convex/executor_node/tool_call_credentials.ts +74 −0
  95. executor/packages/convex/executor_node/tool_call_lifecycle.ts +90 −0
  96. executor/packages/convex/executor_node/tool_call_resolution.ts +133 −0
  97. executor/packages/convex/executor_node/tool_invocation.ts +33 −221
  98. executor/packages/convex/executor_node/tool_source_loading.ts +11 −10
  99. executor/packages/convex/executor_node/workspace_tools.ts +26 −34
  100. executor/packages/convex/http/mcp_service.ts +15 −13
  101. executor/packages/convex/invites-workos.ts +100 −0
  102. executor/packages/convex/invites.ts +9 −97
  103. executor/packages/convex/lib/object.ts +6 −0
  104. executor/packages/convex/runtimeCallbacks.ts +1 −1
  105. executor/packages/convex/schema.ts +2 −0
  106. executor/packages/convex/task-constants.ts +1 −0
  107. executor/packages/convex/task-events.ts +27 −0
  108. executor/packages/convex/task-finish.ts +21 −0
  109. executor/packages/convex/task-status.ts +14 −0
  110. executor/packages/core/src/anonymous-oauth-errors.ts +6 −0
  111. executor/packages/core/src/anonymous-oauth-pkce.ts +13 −0
  112. executor/packages/core/src/anonymous-oauth-server.ts +291 −0
  113. executor/packages/core/src/anonymous-oauth-storage-memory.ts +61 −0
  114. executor/packages/core/src/anonymous-oauth-types.ts +74 −0
  115. executor/packages/core/src/anonymous-oauth.ts +11 −584
  116. executor/packages/core/src/credential-providers.ts +0 −8
  117. executor/packages/core/src/declaration-generation.ts +308 −0
  118. executor/packages/core/src/graphql-field-tools.ts +289 −0
  119. executor/packages/core/src/graphql-operation-paths.ts +77 −0
  120. executor/packages/core/src/managed-runtime-archives.ts +96 −0
  121. executor/packages/core/src/managed-runtime-bootstrap.ts +158 −0
  122. executor/packages/core/src/managed-runtime-info.ts +178 −0
  123. executor/packages/core/src/managed-runtime-installation.ts +132 −0
  124. executor/packages/core/src/managed-runtime-process.ts +27 −0
  125. executor/packages/core/src/managed-runtime.ts +4 −563
  126. executor/packages/core/src/mcp-runtime.ts +51 −0
  127. executor/packages/core/src/mcp-server-approval.ts +195 −0
  128. executor/packages/core/src/mcp-server-contracts.ts +41 −0
  129. executor/packages/core/src/mcp-server-formatting.ts +65 −0
  130. executor/packages/core/src/mcp-server-utils.ts +7 −0
  131. executor/packages/core/src/mcp-server.ts +4 −313
  132. executor/packages/core/src/openapi-auth.ts +50 −0
  133. executor/packages/core/src/openapi-compaction.ts +159 −0
  134. executor/packages/core/src/openapi-prepare.ts +169 −0
  135. executor/packages/core/src/openapi-schema-hints.ts +354 −0
  136. executor/packages/core/src/openapi-tool-builder.ts +186 −0
  137. executor/packages/core/src/openapi-tool-path.ts +41 −0
  138. executor/packages/core/src/postman-collection-utils.ts +108 −0
  139. executor/packages/core/src/postman-runtime.ts +114 −0
  140. executor/packages/core/src/postman-utils.ts +40 −0
  141. executor/packages/core/src/tool-discovery.ts +13 −304
  142. executor/packages/core/src/tool-discovery/formatting.ts +59 −0
  143. executor/packages/core/src/tool-discovery/indexing.ts +125 −0
  144. executor/packages/core/src/tool-discovery/ranking.ts +110 −0
  145. executor/packages/core/src/tool-discovery/types.ts +21 −0
  146. executor/packages/core/src/tool-path-utils.ts +19 −0
  147. executor/packages/core/src/tool-reference-analysis.ts +130 −0
  148. executor/packages/core/src/tool-source-auth.ts +61 −0
  149. executor/packages/core/src/tool-source-execution.ts +151 −0
  150. executor/packages/core/src/tool-source-loaders/graphql-loader.ts +219 −0
  151. executor/packages/core/src/tool-source-loaders/mcp-loader.ts +75 −0
  152. executor/packages/core/src/tool-source-loaders/openapi-loader.ts +165 −0
  153. executor/packages/core/src/tool-source-serialization.ts +208 −0
  154. executor/packages/core/src/tool-source-types.ts +60 −0
  155. executor/packages/core/src/tool-sources.ts +55 −2530
  156. executor/packages/core/src/tsgo-engine.ts +224 −0
  157. executor/packages/core/src/typecheck-contract.ts +6 −0
  158. executor/packages/core/src/typecheck-engine.ts +156 −0
  159. executor/packages/core/src/typechecker-ts-utils.ts +55 −0
  160. executor/packages/core/src/typechecker.ts +24 −881
  161. executor/packages/runner-sandbox-host/src/auth.ts +35 −0
  162. executor/packages/runner-sandbox-host/src/bridge.ts +157 −0
  163. executor/packages/runner-sandbox-host/src/index.ts +15 −303
  164. executor/packages/runner-sandbox-host/src/isolate-text-modules.d.ts +4 −0
  165. executor/packages/runner-sandbox-host/src/request.ts +19 −0
  166. executor/packages/runner-sandbox-host/src/sandbox.ts +82 −0
  167. executor/packages/runner-sandbox-host/src/types.ts +66 −0
assistant/README.md+41−1
@@ -2,4 +2,44 @@ · 1 unmodified line
22
33 Assistant-side code lives here.
44
5−- `legacy/openassistant`: previous prototype code moved out of the executor-focused workspace.
5+## Packages
6+
7+- `packages/core`: agent loop that talks to executor over MCP and calls `run_code`.
8+- `packages/server`: chat-facing API that resolves user identity to MCP context and runs prompts.
9+- `packages/bot`: Discord bot with account-link commands.
10+
11+## Chat-first flow
12+
13+1. User mentions the bot in Discord.
14+2. Bot subscribes to the thread and sends prompts to `@assistant/server`.
15+3. Server resolves MCP context per chat user:
16+ - default: deterministic anonymous `sessionId` via executor bootstrap;
17+ - linked: saved anonymous session or WorkOS bearer token.
18+4. `@assistant/core` runs the prompt via MCP `run_code` and returns text.
19+5. Bot posts the response back to the thread.
20+
21+## Link commands (Discord)
22+
23+Preferred slash commands:
24+
25+- `/whoami`
26+- `/link-workos` (opens a private modal for workspace/token)
27+- `/link-anon [session_id]`
28+- `/unlink`
29+
30+Prompt commands:
31+
32+- `/ask <prompt>`
33+- mention-based thread mode (mention bot once, then keep chatting)
34+
35+Prefix fallback (optional):
36+
37+- `!whoami`
38+- `!link-anon [sessionId]`
39+- `!unlink`
40+
41+Links persist in `assistant/.chat-links.json` by default. Override with `ASSISTANT_LINKS_FILE`.
42+
43+Bot env requirements: `DISCORD_TOKEN` (optional prefix override via `ASSISTANT_COMMAND_PREFIX`).
44+
45+For better linking UX, set `EXECUTOR_WEB_URL` so `/link-workos` can send a one-click "Open Executor" button.
assistant/packages/bot/package.json+2−8
@@ -8,17 +8,11 @@ · 7 unmodified lines
88 "start": "bun with-env bun src/index.ts"
99 },
1010 "dependencies": {
11− "@assistant/core": "workspace:*",
1211 "@assistant/server": "workspace:*",
1312 "@elysiajs/eden": "latest",
14− "@openassistant/reacord": "workspace:*",
15− "convex": "^1.31.7",
16− "discord.js": "^14.17.3",
17− "effect": "latest",
18− "react": "^19.0.0"
13+ "discord.js": "^14.25.1"
1914 },
2015 "devDependencies": {
21− "@types/bun": "latest",
22− "@types/react": "^19.0.0"
16+ "@types/bun": "latest"
2317 }
2418 }
assistant/packages/bot/src/commands/ask.tsx+0−59
@@ -1,59 +0,0 @@
1−/**
2− * /ask command handler
3− */
4−
5−import type { ChatInputCommandInteraction, CommandInteraction } from "discord.js";
6−import type { Client } from "@assistant/server/client";
7−import { unwrap } from "@assistant/server/client";
8−import type { ConvexReactClient } from "convex/react";
9−import { ConvexProvider } from "convex/react";
10−import type { ReacordInstance } from "@openassistant/reacord";
11−import { Effect, Runtime } from "effect";
12−import type { Id } from "@executor/convex/_generated/dataModel";
13−import { TaskMessage } from "../views/task-message";
14−
15−interface AskCommandDeps {
16− readonly api: Client;
17− readonly convex: ConvexReactClient;
18− readonly reacord: {
19− reply: (interaction: CommandInteraction, content: React.ReactNode) => Effect.Effect<ReacordInstance>;
20− };
21−}
22−
23−export async function handleAskCommand(
24− interaction: ChatInputCommandInteraction,
25− deps: AskCommandDeps,
26−): Promise<void> {
27− const prompt = interaction.options.getString("prompt", true);
28− const requesterId = interaction.user.id;
29−
30− await interaction.deferReply();
31−
32− let agentTaskId: string;
33− let workspaceId: Id<"workspaces">;
34− try {
35− const data = await unwrap(
36− deps.api.api.tasks.post({ prompt, requesterId }),
37− );
38− agentTaskId = data.agentTaskId;
39− workspaceId = data.workspaceId as Id<"workspaces">;
40− } catch (error) {
41− await interaction.editReply({
42− content: `\u274c Failed to create task: ${error instanceof Error ? error.message : String(error)}`,
43− });
44− return;
45− }
46−
47− await Runtime.runPromise(Runtime.defaultRuntime)(
48− deps.reacord.reply(
49− interaction,
50− <ConvexProvider client={deps.convex}>
51− <TaskMessage
52− agentTaskId={agentTaskId}
53− prompt={prompt}
54− workspaceId={workspaceId}
55− />
56− </ConvexProvider>,
57− ),
58− );
59−}
assistant/packages/bot/src/index.ts+480−76
@@ -1,111 +1,515 @@
1−/**
2− * Assistant Discord Bot
3− *
4− * Connects to Discord, registers slash commands, and renders
5− * task results using Reacord + Convex reactivity.
6− */
7−
81 import {
2+ ActionRowBuilder,
3+ ButtonBuilder,
4+ ButtonStyle,
5+ ChannelType,
96 Client,
107 GatewayIntentBits,
8+ ModalBuilder,
9+ Partials,
1110 REST,
1211 Routes,
1312 SlashCommandBuilder,
13+ TextInputBuilder,
14+ TextInputStyle,
15+ type Message,
16+ type RESTPostAPIChatInputApplicationCommandsJSONBody,
1417 } from "discord.js";
15−import { makeReacord } from "@openassistant/reacord";
16−import { createClient } from "@assistant/server/client";
17−import { ConvexReactClient } from "convex/react";
18−import { handleAskCommand } from "./commands/ask";
18+import { createClient, unwrap } from "@assistant/server/client";
19+
20+type ChatIdentity = {
21+ readonly platform: "discord";
22+ readonly userId: string;
23+};
1924
20−// ---------------------------------------------------------------------------
21−// Config
22−// ---------------------------------------------------------------------------
25+const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${Bun.env.ASSISTANT_PORT ?? "3002"}`;
26+const DISCORD_TOKEN = Bun.env.DISCORD_TOKEN ?? Bun.env.DISCORD_BOT_TOKEN;
27+const DISCORD_GUILD_ID = Bun.env.DISCORD_GUILD_ID;
28+const COMMAND_PREFIX = Bun.env.ASSISTANT_COMMAND_PREFIX ?? "!";
29+const EXECUTOR_WEB_URL = Bun.env.EXECUTOR_WEB_URL
30+ ?? Bun.env.OPENASSISTANT_EXECUTOR_WEB_URL
31+ ?? Bun.env.OPENASSISTANT_EXECUTOR_URL
32+ ?? "http://localhost:3000";
2333
24−const DISCORD_TOKEN = Bun.env.DISCORD_BOT_TOKEN;
2534 if (!DISCORD_TOKEN) {
26− throw new Error("DISCORD_BOT_TOKEN is required");
35+ throw new Error("DISCORD_TOKEN (or DISCORD_BOT_TOKEN) is required");
2736 }
37+const BOT_TOKEN = DISCORD_TOKEN;
2838
29−const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${Bun.env.ASSISTANT_PORT ?? "3002"}`;
30−const CONVEX_URL = Bun.env.CONVEX_URL;
31−if (!CONVEX_URL) {
32− throw new Error("CONVEX_URL is required. Set it in your environment.");
33−}
39+const api = createClient(SERVER_URL);
40+const subscribedChannels = new Set<string>();
41+
42+const LINK_MODAL_ID = "assistant_link_workos_modal";
43+const LINK_OPEN_MODAL_BUTTON_ID = "assistant_link_workos_open_modal";
44+const LINK_FIELD_WORKSPACE = "workspace_id";
45+const LINK_FIELD_TOKEN = "access_token";
46+const LINK_FIELD_ACTOR = "actor_id";
3447
35−// ---------------------------------------------------------------------------
36−// Clients
37−// ---------------------------------------------------------------------------
48+const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [
49+ new SlashCommandBuilder()
50+ .setName("ask")
51+ .setDescription("Ask the assistant in your linked context")
52+ .addStringOption((option) => option
53+ .setName("prompt")
54+ .setDescription("What you want the assistant to do")
55+ .setRequired(true))
56+ .toJSON(),
57+ new SlashCommandBuilder()
58+ .setName("whoami")
59+ .setDescription("Show your current linked MCP context")
60+ .toJSON(),
61+ new SlashCommandBuilder()
62+ .setName("link-workos")
63+ .setDescription("Link this Discord user to a WorkOS MCP context")
64+ .toJSON(),
65+ new SlashCommandBuilder()
66+ .setName("link-anon")
67+ .setDescription("Link to an anonymous MCP session")
68+ .addStringOption((option) => option
69+ .setName("session_id")
70+ .setDescription("Optional session id to reuse"))
71+ .toJSON(),
72+ new SlashCommandBuilder()
73+ .setName("unlink")
74+ .setDescription("Remove your saved MCP link")
75+ .toJSON(),
76+];
3877
39−const client = new Client({
78+const bot = new Client({
4079 intents: [
4180 GatewayIntentBits.Guilds,
4281 GatewayIntentBits.GuildMessages,
82+ GatewayIntentBits.MessageContent,
83+ GatewayIntentBits.DirectMessages,
4384 ],
85+ partials: [Partials.Channel],
4486 });
4587
46−const reacord = makeReacord(client, { maxInstances: 50 });
47−const api = createClient(SERVER_URL);
48−const convex = new ConvexReactClient(CONVEX_URL, {
49− unsavedChangesWarning: false,
50−});
88+function identityFromUser(userId: string): ChatIdentity {
89+ return {
90+ platform: "discord",
91+ userId,
92+ };
93+}
5194
52−// ---------------------------------------------------------------------------
53−// Slash commands
54−// ---------------------------------------------------------------------------
95+function truncate(value: string, max = 3500): string {
96+ if (value.length <= max) return value;
97+ return `${value.slice(0, max)}...`;
98+}
5599
56−const commands = [
57− new SlashCommandBuilder()
58− .setName("ask")
59− .setDescription("Ask the AI assistant to do something")
60− .addStringOption((opt) =>
61− opt.setName("prompt").setDescription("What do you want the assistant to do?").setRequired(true),
62− ),
63− new SlashCommandBuilder()
64− .setName("clear")
65− .setDescription("Push chat history out of view"),
66−];
100+function chunkMessage(text: string, max = 1800): string[] {
101+ if (text.length <= max) return [text];
102+ const chunks: string[] = [];
103+ let remaining = text;
104+ while (remaining.length > max) {
105+ const splitAt = remaining.lastIndexOf("\n", max);
106+ const end = splitAt > max * 0.4 ? splitAt : max;
107+ chunks.push(remaining.slice(0, end));
108+ remaining = remaining.slice(end).trimStart();
109+ }
110+ if (remaining.length > 0) chunks.push(remaining);
111+ return chunks;
112+}
113+
114+async function reply(message: Message<boolean>, text: string) {
115+ const normalized = truncate(text, 12_000);
116+ const chunks = chunkMessage(normalized);
117+ for (const chunk of chunks) {
118+ await message.reply(chunk);
119+ }
120+}
121+
122+function commandParts(text: string): readonly string[] {
123+ return text.trim().split(/\s+/).filter((part) => part.length > 0);
124+}
125+
126+function stripBotMention(text: string, botUserId: string): string {
127+ return text
128+ .replaceAll(`<@${botUserId}>`, "")
129+ .replaceAll(`<@!${botUserId}>`, "")
130+ .trim();
131+}
132+
133+function isCommand(text: string): boolean {
134+ return text.startsWith(COMMAND_PREFIX);
135+}
136+
137+function parseCommand(text: string): { command: string; args: readonly string[] } {
138+ const withoutPrefix = text.slice(COMMAND_PREFIX.length).trim();
139+ const [command = "", ...args] = commandParts(withoutPrefix);
140+ return {
141+ command: command.toLowerCase(),
142+ args,
143+ };
144+}
145+
146+async function getContext(identity: ChatIdentity) {
147+ const data = await unwrap(
148+ api.api.context.resolve.post({
149+ platform: identity.platform,
150+ userId: identity.userId,
151+ }),
152+ );
153+ return data.context;
154+}
155+
156+async function linkWorkos(identity: ChatIdentity, workspaceId: string, accessToken: string, actorId?: string) {
157+ const data = await unwrap(
158+ api.api.context.link.post({
159+ platform: identity.platform,
160+ userId: identity.userId,
161+ provider: "workos",
162+ workspaceId,
163+ accessToken,
164+ actorId,
165+ }),
166+ );
167+ return data.context;
168+}
169+
170+async function linkAnonymous(identity: ChatIdentity, sessionId?: string) {
171+ const data = await unwrap(
172+ api.api.context.link.post({
173+ platform: identity.platform,
174+ userId: identity.userId,
175+ provider: "anonymous",
176+ sessionId,
177+ }),
178+ );
179+ return data.context;
180+}
181+
182+async function unlink(identity: ChatIdentity) {
183+ const data = await unwrap(
184+ api.api.context.unlink.post({
185+ platform: identity.platform,
186+ userId: identity.userId,
187+ }),
188+ );
189+ return data.removed;
190+}
191+
192+async function runPrompt(identity: ChatIdentity, prompt: string) {
193+ const data = await unwrap(
194+ api.api.chat.run.post({
195+ platform: identity.platform,
196+ userId: identity.userId,
197+ prompt,
198+ }),
199+ );
200+ return data;
201+}
202+
203+function contextLabel(context: {
204+ source: string;
205+ workspaceId: string;
206+ actorId?: string;
207+ sessionId?: string;
208+ hasAccessToken: boolean;
209+}) {
210+ return [
211+ `source=${context.source}`,
212+ `workspace=${context.workspaceId}`,
213+ context.actorId ? `actor=${context.actorId}` : undefined,
214+ context.sessionId ? `session=${context.sessionId}` : undefined,
215+ context.hasAccessToken ? "auth=token" : "auth=session",
216+ ]
217+ .filter(Boolean)
218+ .join(" | ");
219+}
220+
221+function createWorkosLinkModal() {
222+ const modal = new ModalBuilder()
223+ .setCustomId(LINK_MODAL_ID)
224+ .setTitle("Link WorkOS MCP Context");
225+
226+ const workspaceInput = new TextInputBuilder()
227+ .setCustomId(LINK_FIELD_WORKSPACE)
228+ .setLabel("Workspace ID")
229+ .setStyle(TextInputStyle.Short)
230+ .setRequired(true)
231+ .setPlaceholder("e.g. j57...workspace id");
232+
233+ const tokenInput = new TextInputBuilder()
234+ .setCustomId(LINK_FIELD_TOKEN)
235+ .setLabel("Access Token")
236+ .setStyle(TextInputStyle.Paragraph)
237+ .setRequired(true)
238+ .setPlaceholder("Paste MCP bearer token");
239+
240+ const actorInput = new TextInputBuilder()
241+ .setCustomId(LINK_FIELD_ACTOR)
242+ .setLabel("Actor ID (optional)")
243+ .setStyle(TextInputStyle.Short)
244+ .setRequired(false)
245+ .setPlaceholder("Optional override");
246+
247+ modal.addComponents(
248+ new ActionRowBuilder<TextInputBuilder>().addComponents(workspaceInput),
249+ new ActionRowBuilder<TextInputBuilder>().addComponents(tokenInput),
250+ new ActionRowBuilder<TextInputBuilder>().addComponents(actorInput),
251+ );
252+
253+ return modal;
254+}
67255
68−async function registerCommands() {
69− const rest = new REST().setToken(DISCORD_TOKEN!);
256+function buildExecutorLink(identity: ChatIdentity): string | null {
70257 try {
71− console.log("Registering slash commands...");
72− await rest.put(Routes.applicationCommands(client.user!.id), {
73− body: commands.map((c) => c.toJSON()),
74− });
75− console.log("Slash commands registered.");
76− } catch (error) {
77− console.error("Failed to register slash commands:", error);
258+ const url = new URL(EXECUTOR_WEB_URL);
259+ url.searchParams.set("source", "discord-bot-link");
260+ url.searchParams.set("discord_user", identity.userId);
261+ return url.toString();
262+ } catch {
263+ return null;
78264 }
79265 }
80266
81−// ---------------------------------------------------------------------------
82−// Command handler
83−// ---------------------------------------------------------------------------
267+function buildLinkWorkosPanel(identity: ChatIdentity) {
268+ const executorLink = buildExecutorLink(identity);
269+ const row = new ActionRowBuilder<ButtonBuilder>();
270+
271+ if (executorLink) {
272+ row.addComponents(
273+ new ButtonBuilder()
274+ .setLabel("Open Executor")
275+ .setStyle(ButtonStyle.Link)
276+ .setURL(executorLink),
277+ );
278+ }
279+
280+ row.addComponents(
281+ new ButtonBuilder()
282+ .setCustomId(LINK_OPEN_MODAL_BUTTON_ID)
283+ .setLabel("Paste Workspace + Token")
284+ .setStyle(ButtonStyle.Primary),
285+ );
286+
287+ const instructions = [
288+ "Link your WorkOS account in 2 quick steps:",
289+ "1) Open Executor and sign in. Find your MCP workspace/token there.",
290+ "2) Click 'Paste Workspace + Token' and submit the private modal.",
291+ executorLink ? `Executor: ${executorLink}` : "Set EXECUTOR_WEB_URL to include a direct Executor link.",
292+ ].join("\n");
293+
294+ return {
295+ content: instructions,
296+ components: [row],
297+ };
298+}
299+
300+async function registerSlashCommands() {
301+ const appId = bot.user?.id;
302+ if (!appId) return;
303+
304+ const rest = new REST({ version: "10" }).setToken(BOT_TOKEN);
84305
85−client.on("interactionCreate", async (interaction) => {
86− if (!interaction.isChatInputCommand()) return;
306+ if (DISCORD_GUILD_ID) {
307+ await rest.put(
308+ Routes.applicationGuildCommands(appId, DISCORD_GUILD_ID),
309+ { body: slashCommands },
310+ );
311+ console.log(`[assistant-bot] registered guild slash commands in ${DISCORD_GUILD_ID}`);
312+ return;
313+ }
87314
88− switch (interaction.commandName) {
89− case "ask":
90− await handleAskCommand(interaction, { api, convex, reacord });
91− break;
92− case "clear":
93− await interaction.reply({ content: `_${"\n".repeat(50)}_` });
94− break;
95− default:
96− await interaction.reply({ content: `Unknown command: ${interaction.commandName}` });
315+ await rest.put(
316+ Routes.applicationCommands(appId),
317+ { body: slashCommands },
318+ );
319+ console.log("[assistant-bot] registered global slash commands");
320+}
321+
322+async function handlePrefixCommand(message: Message<boolean>, rawText: string) {
323+ const { command, args } = parseCommand(rawText);
324+ const identity = identityFromUser(message.author.id);
325+
326+ if (!command || command === "help") {
327+ await reply(message, [
328+ "Assistant commands:",
329+ "- /ask <prompt>",
330+ "- /whoami",
331+ "- /link-workos (opens secure modal)",
332+ "- /link-anon [session_id]",
333+ "- /unlink",
334+ `Prefix fallback: ${COMMAND_PREFIX}whoami, ${COMMAND_PREFIX}link-anon [sessionId], ${COMMAND_PREFIX}unlink`,
335+ ].join("\n"));
336+ return;
337+ }
338+
339+ if (command === "whoami") {
340+ const context = await getContext(identity);
341+ await reply(message, `Current MCP context: ${contextLabel(context)}`);
342+ return;
343+ }
344+
345+ if (command === "unlink") {
346+ const removed = await unlink(identity);
347+ await reply(message, removed ? "Link removed. You are back to anonymous context." : "No saved link found.");
348+ return;
349+ }
350+
351+ if (command === "link-anon") {
352+ const [sessionId] = args;
353+ const context = await linkAnonymous(identity, sessionId);
354+ await reply(message, `Linked anonymous context: ${contextLabel(context)}`);
355+ return;
356+ }
357+
358+ if (command === "link-workos") {
359+ const panel = buildLinkWorkosPanel(identity);
360+ await reply(message, `${panel.content}\nUse /link-workos to open the button panel in an ephemeral response.`);
361+ return;
362+ }
363+
364+ await reply(message, `Unknown command '${command}'. Run /help or ${COMMAND_PREFIX}help.`);
365+}
366+
367+bot.once("clientReady", async () => {
368+ console.log(`[assistant-bot] discord bot online as ${bot.user?.tag ?? "unknown"} (server: ${SERVER_URL})`);
369+ try {
370+ await registerSlashCommands();
371+ } catch (error) {
372+ console.error("[assistant-bot] failed to register slash commands", error);
97373 }
98374 });
99375
100−// ---------------------------------------------------------------------------
101−// Start
102−// ---------------------------------------------------------------------------
376+bot.on("interactionCreate", async (interaction) => {
377+ try {
378+ if (interaction.isChatInputCommand()) {
379+ const identity = identityFromUser(interaction.user.id);
380+
381+ if (interaction.commandName === "ask") {
382+ const prompt = interaction.options.getString("prompt", true);
383+ await interaction.deferReply();
384+ const result = await runPrompt(identity, prompt);
385+ const chunks = chunkMessage(result.text || "Done.");
386+ await interaction.editReply(chunks[0] ?? "Done.");
387+ for (let i = 1; i < chunks.length; i += 1) {
388+ await interaction.followUp(chunks[i]!);
389+ }
390+ return;
391+ }
392+
393+ if (interaction.commandName === "whoami") {
394+ const context = await getContext(identity);
395+ await interaction.reply({
396+ content: `Current MCP context: ${contextLabel(context)}`,
397+ ephemeral: true,
398+ });
399+ return;
400+ }
401+
402+ if (interaction.commandName === "unlink") {
403+ const removed = await unlink(identity);
404+ await interaction.reply({
405+ content: removed ? "Link removed. You are back to anonymous context." : "No saved link found.",
406+ ephemeral: true,
407+ });
408+ return;
409+ }
410+
411+ if (interaction.commandName === "link-anon") {
412+ const sessionId = interaction.options.getString("session_id") ?? undefined;
413+ const context = await linkAnonymous(identity, sessionId);
414+ await interaction.reply({
415+ content: `Linked anonymous context: ${contextLabel(context)}`,
416+ ephemeral: true,
417+ });
418+ return;
419+ }
420+
421+ if (interaction.commandName === "link-workos") {
422+ const panel = buildLinkWorkosPanel(identity);
423+ await interaction.reply({
424+ content: panel.content,
425+ components: panel.components,
426+ ephemeral: true,
427+ });
428+ return;
429+ }
430+
431+ return;
432+ }
433+
434+ if (interaction.isModalSubmit() && interaction.customId === LINK_MODAL_ID) {
435+ const identity = identityFromUser(interaction.user.id);
436+ const workspaceId = interaction.fields.getTextInputValue(LINK_FIELD_WORKSPACE).trim();
437+ const accessToken = interaction.fields.getTextInputValue(LINK_FIELD_TOKEN).trim();
438+ const actorRaw = interaction.fields.getTextInputValue(LINK_FIELD_ACTOR).trim();
439+ const actorId = actorRaw.length > 0 ? actorRaw : undefined;
440+
441+ if (!workspaceId || !accessToken) {
442+ await interaction.reply({
443+ content: "Workspace ID and access token are required.",
444+ ephemeral: true,
445+ });
446+ return;
447+ }
448+
449+ await interaction.deferReply({ ephemeral: true });
450+ const context = await linkWorkos(identity, workspaceId, accessToken, actorId);
451+ await interaction.editReply(`Linked WorkOS context: ${contextLabel(context)}`);
452+ return;
453+ }
454+
455+ if (interaction.isButton() && interaction.customId === LINK_OPEN_MODAL_BUTTON_ID) {
456+ await interaction.showModal(createWorkosLinkModal());
457+ return;
458+ }
459+ } catch (error) {
460+ const text = `Warning: ${truncate(error instanceof Error ? error.message : String(error), 1200)}`;
461+
462+ if (interaction.isRepliable()) {
463+ if (interaction.replied || interaction.deferred) {
464+ await interaction.followUp({ content: text, ephemeral: true }).catch(() => {});
465+ } else {
466+ await interaction.reply({ content: text, ephemeral: true }).catch(() => {});
467+ }
468+ }
469+ }
470+});
471+
472+bot.on("messageCreate", async (message) => {
473+ if (!message.inGuild() && message.channel.type !== ChannelType.DM) return;
474+ if (message.author.bot) return;
475+
476+ const botUserId = bot.user?.id;
477+ if (!botUserId) return;
103478
104−client.once("clientReady", async () => {
105− console.log(`Logged in as ${client.user?.tag}`);
106− await registerCommands();
107− console.log(`Connected to server at ${SERVER_URL}`);
108− console.log(`Convex at ${CONVEX_URL}`);
479+ const rawText = message.content?.trim() ?? "";
480+ if (!rawText && !message.mentions.users.has(botUserId)) return;
481+
482+ const mentioned = message.mentions.users.has(botUserId);
483+ const subscribed = subscribedChannels.has(message.channelId);
484+ const command = isCommand(rawText);
485+
486+ if (!mentioned && !subscribed && !command && message.channel.type !== ChannelType.DM) {
487+ return;
488+ }
489+
490+ try {
491+ if (command) {
492+ await handlePrefixCommand(message, rawText);
493+ return;
494+ }
495+
496+ let prompt = rawText;
497+ if (mentioned) {
498+ subscribedChannels.add(message.channelId);
499+ prompt = stripBotMention(rawText, botUserId);
500+ if (!prompt) {
501+ await reply(message, "I am listening. Send a prompt here, or use `/ask`.");
502+ return;
503+ }
504+ }
505+
506+ await message.channel.sendTyping();
507+ const result = await runPrompt(identityFromUser(message.author.id), prompt);
508+ await reply(message, result.text || "Done.");
509+ } catch (error) {
510+ const messageText = error instanceof Error ? error.message : String(error);
511+ await reply(message, `Warning: ${truncate(messageText, 1400)}`);
512+ }
109513 });
110514
111−client.login(DISCORD_TOKEN);
515+await bot.login(BOT_TOKEN);
assistant/packages/bot/src/views/task-message.tsx+0−137
@@ -1,137 +0,0 @@
1−/**
2− * TaskMessage — live-updating Discord message powered by Convex reactivity.
3− *
4− * - Watches agentTask in Convex for status, result, error (reactive, no polling)
5− * - Watches pending approvals in Convex (reactive)
6− * - Approval buttons resolve via Convex mutation
7− */
8−
9−import { useState, useCallback } from "react";
10−import {
11− Container,
12− TextDisplay,
13− Separator,
14− ActionRow,
15− Button,
16− Loading,
17− useInstance,
18−} from "@openassistant/reacord";
19−import { useMutation, useQuery } from "convex/react";
20−import { api } from "@executor/convex/_generated/api";
21−import type { Id } from "@executor/convex/_generated/dataModel";
22−
23−// ---------------------------------------------------------------------------
24−// Component
25−// ---------------------------------------------------------------------------
26−
27−export interface TaskMessageProps {
28− readonly agentTaskId: string;
29− readonly prompt: string;
30− readonly workspaceId: Id<"workspaces">;
31−}
32−
33−export function TaskMessage({ agentTaskId, prompt, workspaceId }: TaskMessageProps) {
34− const instance = useInstance();
35− const resolveApproval = useMutation(api.executor.resolveApproval);
36−
37− // Reactive queries — no polling!
38− const agentTask = useQuery(api.database.getAgentTask, { agentTaskId });
39− const pendingApprovals = useQuery(api.workspace.listPendingApprovals, { workspaceId });
40−
41− const status = agentTask?.status ?? "running";
42− const isDone = status !== "running";
43−
44− // Deactivate instance after completion (give Discord a moment to render)
45− if (isDone) {
46− setTimeout(() => instance.deactivate(), 5000);
47− }
48−
49− const handleApproval = useCallback(async (approvalId: string, decision: "approved" | "denied") => {
50− try {
51− await resolveApproval({
52− workspaceId,
53− approvalId,
54− decision,
55− });
56− } catch (err) {
57− console.error(`[approval ${approvalId}]`, err);
58− }
59− }, [resolveApproval, workspaceId]);
60−
61− const accentColor = isDone
62− ? status === "completed" ? 0x57f287 : 0xed4245
63− : 0x5865f2;
64−
65− const statusEmoji = status === "running" ? "\u23f3" : status === "completed" ? "\u2705" : "\u274c";
66− const statusMessage = status === "running"
67− ? (pendingApprovals && pendingApprovals.length > 0 ? "Waiting for approval..." : "Thinking...")
68− : status === "completed" ? "Completed" : "Failed";
69−
70− return (
71− <Container accentColor={accentColor}>
72− <TextDisplay>{`${statusEmoji} **${statusMessage}**`}</TextDisplay>
73− <TextDisplay>{`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`}</TextDisplay>
74−
75− {pendingApprovals?.map((approval: any) => (
76− <ApprovalSection
77− key={approval.id}
78− approval={approval}
79− onDecision={(d) => handleApproval(approval.id, d)}
80− />
81− ))}
82−
83− {agentTask?.error && (
84− <>
85− <Separator />
86− <TextDisplay>{`\u274c **Error:** ${agentTask.error.slice(0, 500)}`}</TextDisplay>
87− </>
88− )}
89−
90− {agentTask?.resultText && (
91− <>
92− <Separator />
93− <TextDisplay>
94− {agentTask.resultText.length > 1800
95− ? agentTask.resultText.slice(0, 1800) + "..."
96− : agentTask.resultText}
97− </TextDisplay>
98− </>
99− )}
100−
101− {status === "running" && (!pendingApprovals || pendingApprovals.length === 0) && <Loading />}
102− </Container>
103− );
104−}
105−
106−// ---------------------------------------------------------------------------
107−// Approval section
108−// ---------------------------------------------------------------------------
109−
110−function ApprovalSection({
111− approval,
112− onDecision,
113−}: {
114− approval: { id: string; toolPath: string; input: unknown };
115− onDecision: (decision: "approved" | "denied") => void;
116−}) {
117− const [resolved, setResolved] = useState(false);
118− const toolName = approval.toolPath.split(".").pop() ?? approval.toolPath;
119−
120− const handle = (decision: "approved" | "denied") => {
121− setResolved(true);
122− onDecision(decision);
123− };
124−
125− return (
126− <>
127− <Separator />
128− <TextDisplay>{`\u{1f6e1}\ufe0f **Approval Required:** \`${toolName}\``}</TextDisplay>
129− {!resolved && (
130− <ActionRow>
131− <Button label="Approve" style="success" onClick={() => handle("approved")} />
132− <Button label="Deny" style="danger" onClick={() => handle("denied")} />
133− </ActionRow>
134− )}
135− </>
136− );
137−}
assistant/packages/core/src/agent.ts+20−4
@@ -77,8 +77,10 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str · 76 unmodified lines
7777 export interface AgentOptions {
7878 readonly executorUrl: string;
7979 readonly workspaceId: string;
80− readonly actorId: string;
80+ readonly actorId?: string;
8181 readonly clientId?: string;
82+ readonly sessionId?: string;
83+ readonly mcpAccessToken?: string;
8284 readonly apiKey?: string;
8385 readonly modelId?: string;
8486 readonly context?: string;
@@ -152,6 +154,8 @@ export function createAgent(options: AgentOptions) { · 67 unmodified lines
152154 workspaceId,
153155 actorId,
154156 clientId,
157+ sessionId,
158+ mcpAccessToken,
155159 apiKey,
156160 modelId = "claude-sonnet-4-5",
157161 context,
@@ -192,10 +196,22 @@ export function createAgent(options: AgentOptions) { · 34 unmodified lines
192196 emit({ type: "status", message: "Connecting..." });
193197 const mcpUrl = new URL(`${executorBaseUrl}/mcp`);
194198 mcpUrl.searchParams.set("workspaceId", workspaceId);
195− mcpUrl.searchParams.set("actorId", actorId);
199+ if (actorId) mcpUrl.searchParams.set("actorId", actorId);
196200 if (clientId) mcpUrl.searchParams.set("clientId", clientId);
197−
198− const transport = new StreamableHTTPClientTransport(mcpUrl);
201+ if (sessionId) mcpUrl.searchParams.set("sessionId", sessionId);
202+
203+ const transport = mcpAccessToken
204+ ? new StreamableHTTPClientTransport(mcpUrl, {
205+ fetch: async (input, init) => {
206+ const headers = new Headers(init?.headers);
207+ headers.set("authorization", `Bearer ${mcpAccessToken}`);
208+ return await fetch(input, {
209+ ...init,
210+ headers,
211+ });
212+ },
213+ })
214+ : new StreamableHTTPClientTransport(mcpUrl);
199215 const mcp = new Client({ name: "assistant-agent", version: "0.1.0" });
200216
201217 let mcpConnected = false;
assistant/packages/server/src/index.ts+10−55
@@ -1,81 +1,36 @@
1−/**
2− * Assistant Server entry point.
3− */
4−
51 import { createApp } from "./routes";
6−import { ConvexHttpClient } from "convex/browser";
7−import { api } from "@executor/convex/_generated/api";
8−import type { Id } from "@executor/convex/_generated/dataModel";
2+import { defaultLinksFilePath } from "./link-store";
93
104 const PORT = Number(Bun.env.ASSISTANT_PORT ?? Bun.env.PORT ?? 3002);
115 const CONVEX_URL = Bun.env.CONVEX_URL;
126 if (!CONVEX_URL) {
137 throw new Error("CONVEX_URL is required. Set it in your environment.");
148 }
9+
1510 const EXECUTOR_URL = Bun.env.EXECUTOR_URL
1611 ?? Bun.env.CONVEX_SITE_URL
1712 ?? (CONVEX_URL.includes(".convex.cloud")
1813 ? CONVEX_URL.replace(".convex.cloud", ".convex.site")
1914 : CONVEX_URL);
20−const PINNED_WORKSPACE_ID = Bun.env.EXECUTOR_WORKSPACE_ID?.trim();
21−const PINNED_ACTOR_ID = Bun.env.EXECUTOR_ACTOR_ID?.trim();
22−const PINNED_CLIENT_ID = Bun.env.EXECUTOR_CLIENT_ID?.trim();
23−const ANON_SESSION_ID = Bun.env.EXECUTOR_ANON_SESSION_ID?.trim();
24−
25−// Bootstrap anonymous context on executor Convex backend
26−const convex = new ConvexHttpClient(CONVEX_URL);
27−let ctx: { workspaceId: Id<"workspaces">; actorId: string; clientId?: string };
28−
29−if (PINNED_WORKSPACE_ID && PINNED_ACTOR_ID) {
30− ctx = {
31− workspaceId: PINNED_WORKSPACE_ID as Id<"workspaces">,
32− actorId: PINNED_ACTOR_ID,
33− clientId: PINNED_CLIENT_ID,
34− };
35−} else {
36− const MAX_RETRIES = 30;
37− const RETRY_DELAY_MS = 2000;
38− for (let attempt = 1; ; attempt++) {
39− try {
40− const bootstrap = await convex.mutation(api.workspace.bootstrapAnonymousSession, {
41− sessionId: ANON_SESSION_ID,
42− });
43− ctx = {
44− workspaceId: bootstrap.workspaceId,
45− actorId: bootstrap.actorId,
46− clientId: PINNED_CLIENT_ID ?? bootstrap.clientId,
47− };
48− break;
49− } catch (error) {
50− if (attempt >= MAX_RETRIES) {
51− console.error("Failed to bootstrap executor context from Convex at", CONVEX_URL, error);
52− process.exit(1);
53− }
54− console.log(`[assistant] Waiting for Convex functions... (attempt ${attempt}/${MAX_RETRIES})`);
55− await Bun.sleep(RETRY_DELAY_MS);
56− }
57− }
58−}
5915
60−console.log(`[assistant] executor context: workspace=${ctx.workspaceId} actor=${ctx.actorId}`);
61−if (ANON_SESSION_ID) {
62− console.log(`[assistant] executor anon session: ${ANON_SESSION_ID}`);
16+const contextLines: string[] = [];
17+if (Bun.env.POSTHOG_PROJECT_ID) {
18+ contextLines.push(`- PostHog project ID: ${Bun.env.POSTHOG_PROJECT_ID}`);
6319 }
6420
65−const contextLines: string[] = [];
66−if (Bun.env.POSTHOG_PROJECT_ID) contextLines.push(`- PostHog project ID: ${Bun.env.POSTHOG_PROJECT_ID}`);
21+const linksFile = Bun.env.ASSISTANT_LINKS_FILE ?? defaultLinksFilePath;
6722
6823 const app = createApp({
6924 executorUrl: EXECUTOR_URL,
70− workspaceId: ctx.workspaceId,
71− actorId: ctx.actorId,
72− clientId: ctx.clientId,
73− context: contextLines.length > 0 ? contextLines.join("\n") : undefined,
7425 convexUrl: CONVEX_URL,
26+ context: contextLines.length > 0 ? contextLines.join("\n") : undefined,
27+ defaultClientId: Bun.env.ASSISTANT_CLIENT_ID ?? "assistant-chat",
28+ linksFile,
7529 });
7630
7731 app.listen(PORT);
7832 console.log(`[assistant] server running at http://localhost:${PORT}`);
7933 console.log(`[assistant] executor at ${EXECUTOR_URL}`);
34+console.log(`[assistant] link store at ${linksFile}`);
8035
8136 export type { App } from "./routes";
assistant/packages/server/src/link-store.ts+76−0
@@ -0,0 +1,76 @@
1+import { mkdir } from "node:fs/promises";
2+import { dirname } from "node:path";
3+import type { Id } from "@executor/convex/_generated/dataModel";
4+
5+export type LinkedProvider = "anonymous" | "workos";
6+
7+export interface LinkedMcpContext {
8+ readonly provider: LinkedProvider;
9+ readonly workspaceId: Id<"workspaces">;
10+ readonly actorId?: string;
11+ readonly sessionId?: string;
12+ readonly accessToken?: string;
13+ readonly clientId?: string;
14+ readonly linkedAt: number;
15+}
16+
17+interface LinkStoreFile {
18+ readonly version: 1;
19+ readonly links: Record<string, LinkedMcpContext>;
20+}
21+
22+export const defaultLinksFilePath = new URL("../../../.chat-links.json", import.meta.url).pathname;
23+
24+export function createFileLinkStore(filePath = Bun.env.ASSISTANT_LINKS_FILE ?? defaultLinksFilePath) {
25+ let loaded = false;
26+ let links: Record<string, LinkedMcpContext> = {};
27+
28+ async function ensureLoaded() {
29+ if (loaded) return;
30+ loaded = true;
31+
32+ const file = Bun.file(filePath);
33+ if (!(await file.exists())) return;
34+
35+ const text = await file.text();
36+ if (!text.trim()) return;
37+
38+ try {
39+ const parsed = JSON.parse(text) as Partial<LinkStoreFile>;
40+ if (parsed && parsed.version === 1 && parsed.links && typeof parsed.links === "object") {
41+ links = parsed.links;
42+ }
43+ } catch (error) {
44+ console.error(`[assistant] Failed to parse link store '${filePath}':`, error);
45+ }
46+ }
47+
48+ async function persist() {
49+ await mkdir(dirname(filePath), { recursive: true });
50+ const payload: LinkStoreFile = { version: 1, links };
51+ await Bun.write(filePath, JSON.stringify(payload, null, 2));
52+ }
53+
54+ return {
55+ filePath,
56+
57+ async get(identityKey: string): Promise<LinkedMcpContext | undefined> {
58+ await ensureLoaded();
59+ return links[identityKey];
60+ },
61+
62+ async set(identityKey: string, value: LinkedMcpContext): Promise<void> {
63+ await ensureLoaded();
64+ links[identityKey] = value;
65+ await persist();
66+ },
67+
68+ async delete(identityKey: string): Promise<boolean> {
69+ await ensureLoaded();
70+ if (!links[identityKey]) return false;
71+ delete links[identityKey];
72+ await persist();
73+ return true;
74+ },
75+ };
76+}
assistant/packages/server/src/routes.ts+212−88
@@ -1,114 +1,238 @@
1−/**
2− * Elysia server routes.
3− *
4− * POST /api/tasks — Create an agent task (fires agent in background, writes state to Convex)
5− * GET /api/context — Returns executor workspace/actor context
6− */
7−
81 import { Elysia, t } from "elysia";
92 import { createAgent } from "@assistant/core";
10−import type { TaskEvent } from "@assistant/core";
113 import { ConvexHttpClient } from "convex/browser";
124 import { api } from "@executor/convex/_generated/api";
135 import type { Id } from "@executor/convex/_generated/dataModel";
14−
15−// ---------------------------------------------------------------------------
16−// Config
17−// ---------------------------------------------------------------------------
6+import { createFileLinkStore, type LinkedMcpContext } from "./link-store";
187
198 export interface ServerOptions {
209 readonly executorUrl: string;
10+ readonly convexUrl: string;
11+ readonly context?: string;
12+ readonly defaultClientId?: string;
13+ readonly linksFile?: string;
14+}
15+
16+interface ChatIdentity {
17+ readonly platform: string;
18+ readonly userId: string;
19+}
20+
21+interface ResolvedExecutorContext {
2122 readonly workspaceId: Id<"workspaces">;
22− readonly actorId: string;
23+ readonly actorId?: string;
24+ readonly sessionId?: string;
2325 readonly clientId?: string;
24− readonly context?: string;
25− readonly convexUrl: string;
26+ readonly mcpAccessToken?: string;
27+ readonly linked: boolean;
28+ readonly source: "anonymous" | "linked_anonymous" | "linked_workos";
2629 }
2730
28−// ---------------------------------------------------------------------------
29−// App
30−// ---------------------------------------------------------------------------
31+function normalizeIdentityPart(value: string): string {
32+ return value
33+ .toLowerCase()
34+ .trim()
35+ .replace(/[^a-z0-9]+/g, "_")
36+ .replace(/^_+|_+$/g, "")
37+ .slice(0, 64);
38+}
3139
32−export function createApp(options: ServerOptions) {
33− const agent = createAgent({
34− executorUrl: options.executorUrl,
35− workspaceId: options.workspaceId,
36− actorId: options.actorId,
37− clientId: options.clientId,
38− context: options.context,
39− });
40+function identityKey(identity: ChatIdentity): string {
41+ return `${normalizeIdentityPart(identity.platform)}:${normalizeIdentityPart(identity.userId)}`;
42+}
43+
44+function defaultSessionId(identity: ChatIdentity): string {
45+ const platform = normalizeIdentityPart(identity.platform) || "chat";
46+ const user = normalizeIdentityPart(identity.userId) || "user";
47+ return `mcp_assistant_${platform}_${user}`;
48+}
4049
50+export function createApp(options: ServerOptions) {
4151 const convex = new ConvexHttpClient(options.convexUrl);
52+ const linkStore = createFileLinkStore(options.linksFile);
53+ const defaultClientId = options.defaultClientId?.trim() || "assistant-chat";
54+
55+ async function resolveContext(identity: ChatIdentity): Promise<ResolvedExecutorContext> {
56+ const key = identityKey(identity);
57+ const linked = await linkStore.get(key);
58+
59+ if (linked?.provider === "workos" && linked.accessToken) {
60+ return {
61+ workspaceId: linked.workspaceId,
62+ actorId: linked.actorId,
63+ sessionId: linked.sessionId,
64+ clientId: linked.clientId ?? defaultClientId,
65+ mcpAccessToken: linked.accessToken,
66+ linked: true,
67+ source: "linked_workos",
68+ };
69+ }
70+
71+ const requestedSessionId = linked?.sessionId ?? defaultSessionId(identity);
72+ const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, {
73+ sessionId: requestedSessionId,
74+ });
4275
43− let counter = 0;
44− function generateAgentTaskId(): string {
45− return `atask_${Date.now()}_${++counter}`;
46− }
76+ const clientId = linked?.clientId ?? defaultClientId;
4777
48− const app = new Elysia()
49− .get("/api/context", () => ({
50− workspaceId: options.workspaceId,
51− actorId: options.actorId,
52− clientId: options.clientId,
53− }))
54−
55− .post("/api/tasks", async ({ body }) => {
56− const agentTaskId = generateAgentTaskId();
57−
58− await convex.mutation(api.database.createAgentTask, {
59− id: agentTaskId,
60− prompt: body.prompt,
61− requesterId: body.requesterId,
62− workspaceId: options.workspaceId,
63− actorId: options.actorId,
78+ if (linked?.provider === "anonymous") {
79+ await linkStore.set(key, {
80+ provider: "anonymous",
81+ workspaceId: anonymous.workspaceId,
82+ actorId: anonymous.actorId,
83+ sessionId: anonymous.sessionId,
84+ clientId,
85+ linkedAt: linked.linkedAt,
6486 });
87+ }
88+
89+ return {
90+ workspaceId: anonymous.workspaceId,
91+ actorId: anonymous.actorId,
92+ sessionId: anonymous.sessionId,
93+ clientId,
94+ linked: linked?.provider === "anonymous",
95+ source: linked?.provider === "anonymous" ? "linked_anonymous" : "anonymous",
96+ };
97+ }
6598
66− let toolCalls = 0;
67−
68− agent.run(body.prompt, (event: TaskEvent) => {
69− if (event.type === "code_result") toolCalls++;
70−
71− if (event.type === "agent_message") {
72− convex.mutation(api.database.updateAgentTask, {
73− agentTaskId,
74− resultText: event.text,
75− codeRuns: toolCalls,
76− }).catch((err) => console.error(`[${agentTaskId}]`, err));
77− }
99+ function toPublicContext(context: ResolvedExecutorContext) {
100+ return {
101+ workspaceId: context.workspaceId,
102+ actorId: context.actorId,
103+ sessionId: context.sessionId,
104+ clientId: context.clientId,
105+ linked: context.linked,
106+ source: context.source,
107+ hasAccessToken: Boolean(context.mcpAccessToken),
108+ };
109+ }
78110
79− if (event.type === "completed") {
80− convex.mutation(api.database.updateAgentTask, {
81− agentTaskId,
82− status: "completed",
83− codeRuns: toolCalls,
84− }).catch((err) => console.error(`[${agentTaskId}]`, err));
85− }
111+ const providerSchema = t.Union([t.Literal("anonymous"), t.Literal("workos")]);
86112
87− if (event.type === "error") {
88− convex.mutation(api.database.updateAgentTask, {
89− agentTaskId,
90− status: "failed",
91− error: event.error,
92− codeRuns: toolCalls,
93− }).catch((err) => console.error(`[${agentTaskId}]`, err));
113+ const app = new Elysia()
114+ .post(
115+ "/api/context/resolve",
116+ async ({ body }) => {
117+ const context = await resolveContext(body);
118+ return { context: toPublicContext(context) };
119+ },
120+ {
121+ body: t.Object({
122+ platform: t.String({ minLength: 1 }),
123+ userId: t.String({ minLength: 1 }),
124+ }),
125+ },
126+ )
127+
128+ .post(
129+ "/api/context/link",
130+ async ({ body }) => {
131+ const identity = { platform: body.platform, userId: body.userId } satisfies ChatIdentity;
132+ const key = identityKey(identity);
133+ const clientId = body.clientId?.trim() || defaultClientId;
134+ const linkedAt = Date.now();
135+
136+ if (body.provider === "workos") {
137+ if (!body.workspaceId?.trim()) {
138+ throw new Error("workspaceId is required for workos links");
139+ }
140+ if (!body.accessToken?.trim()) {
141+ throw new Error("accessToken is required for workos links");
142+ }
143+
144+ const link: LinkedMcpContext = {
145+ provider: "workos",
146+ workspaceId: body.workspaceId as Id<"workspaces">,
147+ actorId: body.actorId?.trim() || undefined,
148+ sessionId: body.sessionId?.trim() || undefined,
149+ accessToken: body.accessToken.trim(),
150+ clientId,
151+ linkedAt,
152+ };
153+ await linkStore.set(key, link);
154+ const context = await resolveContext(identity);
155+ return { linked: true, context: toPublicContext(context) };
94156 }
95− }).catch((err) => {
96− console.error(`[${agentTaskId}] agent error:`, err);
97− convex.mutation(api.database.updateAgentTask, {
98− agentTaskId,
99− status: "failed",
100− error: err instanceof Error ? err.message : String(err),
101− codeRuns: toolCalls,
102− }).catch(() => {});
103− });
104157
105− return { agentTaskId, workspaceId: options.workspaceId };
106− }, {
107− body: t.Object({
108− prompt: t.String({ minLength: 1 }),
109− requesterId: t.String({ minLength: 1 }),
110− }),
111− });
158+ const requestedSessionId = body.sessionId?.trim() || defaultSessionId(identity);
159+ const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, {
160+ sessionId: requestedSessionId,
161+ });
162+
163+ const link: LinkedMcpContext = {
164+ provider: "anonymous",
165+ workspaceId: anonymous.workspaceId,
166+ actorId: anonymous.actorId,
167+ sessionId: anonymous.sessionId,
168+ clientId,
169+ linkedAt,
170+ };
171+ await linkStore.set(key, link);
172+ const context = await resolveContext(identity);
173+ return { linked: true, context: toPublicContext(context) };
174+ },
175+ {
176+ body: t.Object({
177+ platform: t.String({ minLength: 1 }),
178+ userId: t.String({ minLength: 1 }),
179+ provider: providerSchema,
180+ workspaceId: t.Optional(t.String({ minLength: 1 })),
181+ actorId: t.Optional(t.String({ minLength: 1 })),
182+ sessionId: t.Optional(t.String({ minLength: 1 })),
183+ accessToken: t.Optional(t.String({ minLength: 1 })),
184+ clientId: t.Optional(t.String({ minLength: 1 })),
185+ }),
186+ },
187+ )
188+
189+ .post(
190+ "/api/context/unlink",
191+ async ({ body }) => {
192+ const removed = await linkStore.delete(identityKey(body));
193+ return { removed };
194+ },
195+ {
196+ body: t.Object({
197+ platform: t.String({ minLength: 1 }),
198+ userId: t.String({ minLength: 1 }),
199+ }),
200+ },
201+ )
202+
203+ .post(
204+ "/api/chat/run",
205+ async ({ body }) => {
206+ const context = await resolveContext({
207+ platform: body.platform,
208+ userId: body.userId,
209+ });
210+
211+ const agent = createAgent({
212+ executorUrl: options.executorUrl,
213+ workspaceId: context.workspaceId,
214+ actorId: context.actorId,
215+ sessionId: context.sessionId,
216+ clientId: context.clientId,
217+ mcpAccessToken: context.mcpAccessToken,
218+ context: options.context,
219+ });
220+
221+ const result = await agent.run(body.prompt);
222+ return {
223+ text: result.text,
224+ toolCalls: result.toolCalls,
225+ context: toPublicContext(context),
226+ };
227+ },
228+ {
229+ body: t.Object({
230+ platform: t.String({ minLength: 1 }),
231+ userId: t.String({ minLength: 1 }),
232+ prompt: t.String({ minLength: 1 }),
233+ }),
234+ },
235+ );
112236
113237 return app;
114238 }
bun.lock+5−11
@@ -33,18 +33,12 @@ · 32 unmodified lines
3333 "assistant/packages/bot": {
3434 "name": "@assistant/bot",
3535 "dependencies": {
36− "@assistant/core": "workspace:*",
3736 "@assistant/server": "workspace:*",
3837 "@elysiajs/eden": "latest",
39− "@openassistant/reacord": "workspace:*",
40− "convex": "^1.31.7",
41− "discord.js": "^14.17.3",
42− "effect": "latest",
43− "react": "^19.0.0",
38+ "discord.js": "^14.25.1",
4439 },
4540 "devDependencies": {
4641 "@types/bun": "latest",
47− "@types/react": "^19.0.0",
4842 },
4943 },
5044 "assistant/packages/core": {
@@ -1627,7 +1621,7 @@ · 1576 unmodified lines
16271621
16281622 "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
16291623
1630− "elysia": ["elysia@1.4.24", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-cgMUPXzZYZGafj2VePFnckVPZ8xJKbZNOgv4hlM2nW96cPSG1BL3y2KNRrKmmA2hKEuUw0tvHVwG//pMqSZ5Jw=="],
1624+ "elysia": ["elysia@1.4.25", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-liKjavH99Gpzrv9cDil6uYWmPuqESfPFV1FIaFSd3iNqo3y7e29sN43VxFIK8tWWnyi6eDAmi2SZk8hNAMQMyg=="],
16311625
16321626 "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
16331627
@@ -2541,7 +2535,7 @@ · 907 unmodified lines
25412535
25422536 "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
25432537
2544− "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
2538+ "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
25452539
25462540 "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
25472541
@@ -3071,8 +3065,6 @@ · 523 unmodified lines
30713065
30723066 "@eslint/eslintrc/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
30733067
3074− "@executor/web/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
3075−
30763068 "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
30773069
30783070 "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
@@ -3109,6 +3101,8 @@ · 30 unmodified lines
31093101
31103102 "@npmcli/run-script/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
31113103
3104+ "@openassistant/reacord/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
3105+
31123106 "@opentelemetry/instrumentation-grpc/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.27.0", "", {}, "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg=="],
31133107
31143108 "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
executor/apps/web/src/components/app-shell.tsx+7−629
@@ -1,45 +1,16 @@
11 "use client";
22
3−import { Suspense, useEffect, useMemo, useState, type FormEvent } from "react";
4−import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
5−import { useMutation } from "convex/react";
6−import {
7− LayoutDashboard,
8− Play,
9− ShieldCheck,
10− Wrench,
11− Menu,
12− X,
13− ChevronsUpDown,
14− Plus,
15− Check,
16− Settings,
17− LogIn,
18−} from "lucide-react";
3+import { Suspense, useEffect, useState } from "react";
4+import { Link, useLocation, useNavigate } from "react-router";
5+import { LayoutDashboard, Play, ShieldCheck, Wrench, Menu, X } from "lucide-react";
196 import { Button } from "@/components/ui/button";
207 import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
21−import {
22− DropdownMenu,
23− DropdownMenuContent,
24− DropdownMenuItem,
25− DropdownMenuLabel,
26− DropdownMenuSeparator,
27− DropdownMenuTrigger,
28−} from "@/components/ui/dropdown-menu";
29−import {
30− Dialog,
31− DialogContent,
32− DialogDescription,
33− DialogFooter,
34− DialogHeader,
35− DialogTitle,
36−} from "@/components/ui/dialog";
37−import { Input } from "@/components/ui/input";
388 import { useSession } from "@/lib/session-context";
39−import { convexApi } from "@/lib/convex-api";
40−import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";
41−import { ThemeSwitcher } from "@/components/theme-switcher";
429 import { ApprovalNotifier } from "@/components/approval-notifier";
10+import { ThemeSwitcher } from "@/components/theme-switcher";
11+import { WorkspaceSelector } from "@/components/workspace-selector";
12+import { SessionInfo } from "@/components/session-info";
13+import { NoOrganizationModal } from "@/components/no-organization-modal";
4314 import { cn } from "@/lib/utils";
4415
4516 const NAV_ITEMS = [
@@ -81,530 +52,6 @@ function NavLinks({ onClick }: { onClick?: () => void }) { · 35 unmodified lines
8152 );
8253 }
8354
84−function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) {
85− const navigate = useNavigate();
86− const createOrganizationMutation = useMutation(convexApi.organizations.create);
87− const {
88− context,
89− mode,
90− workspaces,
91− switchWorkspace,
92− creatingWorkspace,
93− createWorkspace,
94− } = useSession();
95− const [createOpen, setCreateOpen] = useState(false);
96− const [newWorkspaceName, setNewWorkspaceName] = useState("");
97− const [newWorkspaceIcon, setNewWorkspaceIcon] = useState<File | null>(null);
98− const [createScope, setCreateScope] = useState<"current_org" | "new_org">("current_org");
99− const [createTargetOrganizationId, setCreateTargetOrganizationId] = useState<(typeof workspaces)[number]["organizationId"]>(null);
100− const [createTargetOrganizationName, setCreateTargetOrganizationName] = useState<string | null>(null);
101− const [creatingOrganization, setCreatingOrganization] = useState(false);
102− const [createError, setCreateError] = useState<string | null>(null);
103−
104− const activeWorkspace = context
105− ? workspaces.find((workspace) => workspace.id === context.workspaceId)
106− : null;
107− const supportsOrganizationManagement = mode !== "guest";
108− const activeOrganizationLabel = activeWorkspace?.organizationName ?? "Organization";
109− const activeWorkspaceLabel = activeWorkspace?.name ?? (mode === "guest" ? "Anonymous Workspace" : "Select workspace");
110− const activeWorkspaceInitial = (activeWorkspaceLabel[0] ?? "W").toUpperCase();
111−
112− const workspaceGroups = useMemo(() => {
113− if (!supportsOrganizationManagement) {
114− return [];
115− }
116−
117− const groups: Array<{
118− key: string;
119− organizationId: (typeof workspaces)[number]["organizationId"];
120− organizationName: string;
121− workspaces: Array<(typeof workspaces)[number]>;
122− }> = [];
123− const byKey = new Map<string, number>();
124−
125− for (const workspace of workspaces) {
126− const key = workspace.organizationId ? `org:${workspace.organizationId}` : `workspace:${workspace.id}`;
127− const existingIndex = byKey.get(key);
128− if (existingIndex === undefined) {
129− byKey.set(key, groups.length);
130− groups.push({
131− key,
132− organizationId: workspace.organizationId,
133− organizationName: workspace.organizationName,
134− workspaces: [workspace],
135− });
136− continue;
137− }
138−
139− groups[existingIndex].workspaces.push(workspace);
140− }
141−
142− return groups;
143− }, [supportsOrganizationManagement, workspaces]);
144−
145− const openCreateWorkspaceForOrganization = (
146− organizationId: (typeof workspaces)[number]["organizationId"],
147− organizationName: string,
148− ) => {
149− setCreateError(null);
150− setNewWorkspaceName("");
151− setNewWorkspaceIcon(null);
152− setCreateScope("current_org");
153− setCreateTargetOrganizationId(organizationId);
154− setCreateTargetOrganizationName(organizationName);
155− setCreateOpen(true);
156− };
157−
158− const openCreateOrganization = () => {
159− setCreateError(null);
160− setNewWorkspaceName("");
161− setNewWorkspaceIcon(null);
162− setCreateScope("new_org");
163− setCreateTargetOrganizationId(null);
164− setCreateTargetOrganizationName(null);
165− setCreateOpen(true);
166− };
167−
168− const handleCreateWorkspace = async (event?: FormEvent<HTMLFormElement>) => {
169− event?.preventDefault();
170−
171− const trimmed = newWorkspaceName.trim();
172− if (trimmed.length < 2) {
173− setCreateError("Workspace name must be at least 2 characters.");
174− return;
175− }
176−
177− try {
178− if (createScope === "new_org") {
179− setCreatingOrganization(true);
180− const created = await createOrganizationMutation({ name: trimmed });
181− switchWorkspace(created.workspace.id);
182− } else {
183− if (!createTargetOrganizationId) {
184− throw new Error("Select an organization first");
185− }
186−
187− await createWorkspace(trimmed, newWorkspaceIcon, createTargetOrganizationId);
188− }
189−
190− setCreateError(null);
191− setNewWorkspaceName("");
192− setNewWorkspaceIcon(null);
193− setCreateOpen(false);
194− } catch (cause) {
195− const fallback = createScope === "new_org" ? "Failed to create organization" : "Failed to create workspace";
196− setCreateError(cause instanceof Error ? cause.message : fallback);
197− } finally {
198− setCreatingOrganization(false);
199− }
200− };
201−
202− const triggerClassName = inHeader
203− ? "h-full w-full justify-between rounded-none border-0 bg-transparent px-3 text-[12px] font-medium shadow-none hover:bg-accent/40"
204− : "h-8 w-full justify-between text-[11px]";
205− const isCreating = creatingWorkspace || creatingOrganization;
206−
207− return (
208− <>
209− <DropdownMenu>
210− <DropdownMenuTrigger asChild>
211− <Button
212− variant={inHeader ? "ghost" : "outline"}
213− size="sm"
214− className={triggerClassName}
215− >
216− <span className="flex items-center gap-2 min-w-0">
217− {activeWorkspace?.iconUrl ? (
218− <img
219− src={activeWorkspace.iconUrl}
220− alt={activeWorkspaceLabel}
221− className="h-4 w-4 rounded-sm border border-border object-cover"
222− />
223− ) : (
224− <span className="h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground">
225− {activeWorkspaceInitial}
226− </span>
227− )}
228− <span className="min-w-0">
229− {supportsOrganizationManagement ? (
230− <>
231− <span className="truncate block text-[10px] text-muted-foreground">{activeOrganizationLabel}</span>
232− <span className="truncate block">{activeWorkspaceLabel}</span>
233− </>
234− ) : (
235− <span className="truncate block">{activeWorkspaceLabel}</span>
236− )}
237− </span>
238− </span>
239− <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
240− </Button>
241− </DropdownMenuTrigger>
242− <DropdownMenuContent align="end" className="w-72">
243− {supportsOrganizationManagement
244− ? (
245− <>
246− {workspaceGroups.map((group, groupIndex) => {
247− const settingsWorkspace = group.workspaces.find((workspace) => workspace.id === context?.workspaceId)
248− ?? group.workspaces[0]
249− ?? null;
250−
251− return (
252− <div key={group.key}>
253− {groupIndex > 0 ? <DropdownMenuSeparator /> : null}
254− <div className="px-2 py-1.5 flex items-center justify-between gap-2">
255− <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground truncate">
256− {group.organizationName}
257− </span>
258− {settingsWorkspace ? (
259− <button
260− type="button"
261− className="inline-flex h-5 w-5 items-center justify-center rounded-sm border border-border text-muted-foreground hover:bg-accent hover:text-foreground"
262− onClick={() => {
263− switchWorkspace(settingsWorkspace.id);
264− navigate("/organization?tab=members");
265− }}
266− aria-label={`Open ${group.organizationName} settings`}
267− title="Organization settings"
268− >
269− <Settings className="h-3 w-3" />
270− </button>
271− ) : null}
272− </div>
273− {group.workspaces.map((workspace) => {
274− const isActive = workspace.id === context?.workspaceId;
275− return (
276− <DropdownMenuItem
277− key={workspace.id}
278− onSelect={() => switchWorkspace(workspace.id)}
279− className="text-xs"
280− >
281− <Check className={cn("mr-2 h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} />
282− {workspace.iconUrl ? (
283− <img
284− src={workspace.iconUrl}
285− alt={workspace.name}
286− className="mr-2 h-4 w-4 rounded-sm border border-border object-cover"
287− />
288− ) : (
289− <span className="mr-2 h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground">
290− {(workspace.name[0] ?? "W").toUpperCase()}
291− </span>
292− )}
293− <span className="min-w-0 truncate">{workspace.name}</span>
294− </DropdownMenuItem>
295− );
296− })}
297−
298− {group.organizationId ? (
299− <DropdownMenuItem
300− onSelect={() => openCreateWorkspaceForOrganization(group.organizationId, group.organizationName)}
301− className="text-xs"
302− >
303− <Plus className="mr-2 h-3.5 w-3.5" />
304− New workspace
305− </DropdownMenuItem>
306− ) : null}
307− </div>
308− );
309− })}
310−
311− {workspaces.length === 0 ? (
312− <DropdownMenuItem disabled className="text-xs">
313− No workspaces
314− </DropdownMenuItem>
315− ) : null}
316− </>
317− )
318− : (
319− <DropdownMenuItem disabled className="text-xs">
320− Guest workspace
321− </DropdownMenuItem>
322− )}
323− {supportsOrganizationManagement ? (
324− <>
325− <DropdownMenuSeparator />
326− <DropdownMenuItem onSelect={openCreateOrganization} className="text-xs">
327− <Plus className="mr-2 h-3.5 w-3.5" />
328− New organization
329− </DropdownMenuItem>
330−
331− </>
332− ) : null}
333− </DropdownMenuContent>
334− </DropdownMenu>
335−
336− <Dialog open={createOpen} onOpenChange={setCreateOpen}>
337− <DialogContent>
338− <form className="space-y-4" onSubmit={handleCreateWorkspace}>
339− <DialogHeader>
340− <DialogTitle>{createScope === "new_org" ? "Create organization" : "Create workspace"}</DialogTitle>
341− <DialogDescription>
342− {createScope === "new_org"
343− ? "Create a new organization with a default workspace."
344− : `Create a workspace inside ${createTargetOrganizationName ?? "this organization"}.`}
345− </DialogDescription>
346− </DialogHeader>
347− <div className="space-y-2">
348− <Input
349− value={newWorkspaceName}
350− onChange={(event) => {
351− setCreateError(null);
352− setNewWorkspaceName(event.target.value);
353− }}
354− placeholder={createScope === "new_org" ? "Acme" : "Marketing"}
355− maxLength={64}
356− />
357− {createScope === "current_org" ? (
358− <>
359− <Input
360− type="file"
361− accept="image/*"
362− onChange={(event) => {
363− setCreateError(null);
364− setNewWorkspaceIcon(event.target.files?.[0] ?? null);
365− }}
366− />
367− {newWorkspaceIcon ? (
368− <p className="text-[11px] text-muted-foreground truncate">
369− Icon: {newWorkspaceIcon.name}
370− </p>
371− ) : null}
372− </>
373− ) : null}
374− {createError ? (
375− <p className="text-xs text-destructive">{createError}</p>
376− ) : null}
377− </div>
378− <DialogFooter>
379− <Button
380− type="button"
381− variant="outline"
382− onClick={() => setCreateOpen(false)}
383− disabled={isCreating}
384− >
385− Cancel
386− </Button>
387− <Button type="submit" disabled={isCreating}>
388− {isCreating
389− ? "Creating..."
390− : createScope === "new_org"
391− ? "Create organization"
392− : "Create workspace"}
393− </Button>
394− </DialogFooter>
395− </form>
396− </DialogContent>
397− </Dialog>
398− </>
399− );
400−}
401−
402−function SessionInfo() {
403− const {
404− loading,
405− isSignedInToWorkos,
406− workosProfile,
407− context,
408− workspaces,
409− resetWorkspace,
410− } = useSession();
411− const [searchParams] = useSearchParams();
412− const deleteCurrentAccountMutation = useMutation(convexApi.accounts.deleteCurrentAccount);
413− const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
414− const [deleteConfirmText, setDeleteConfirmText] = useState("");
415− const [deleteError, setDeleteError] = useState<string | null>(null);
416− const [deletingAccount, setDeletingAccount] = useState(false);
417− const avatarUrl = workosProfile?.avatarUrl ?? null;
418− const avatarLabel = workosProfile?.name || workosProfile?.email || "User";
419− const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase();
420− const canDeleteAccount = deleteConfirmText === "DELETE";
421−
422− const activeWorkspace = context
423− ? workspaces.find((workspace) => workspace.id === context.workspaceId)
424− : null;
425− const inferredOrganizationId = activeWorkspace?.organizationId ?? undefined;
426− const hintedOrganizationId =
427− searchParams.get("organization_id")
428− ?? searchParams.get("organizationId")
429− ?? searchParams.get("org_id")
430− ?? searchParams.get("orgId")
431− ?? inferredOrganizationId
432− ?? undefined;
433− const hintedLogin =
434− searchParams.get("login_hint")
435− ?? searchParams.get("loginHint")
436− ?? searchParams.get("email")
437− ?? undefined;
438− const signInParams = new URLSearchParams();
439− if (hintedOrganizationId) {
440− signInParams.set("organization_id", hintedOrganizationId);
441− }
442− if (hintedLogin) {
443− signInParams.set("login_hint", hintedLogin);
444− }
445− const signInHref = signInParams.size > 0 ? `/sign-in?${signInParams.toString()}` : "/sign-in";
446−
447− const handleDeleteAccount = async (event: FormEvent<HTMLFormElement>) => {
448− event.preventDefault();
449− if (!canDeleteAccount || deletingAccount) {
450− return;
451− }
452−
453− setDeletingAccount(true);
454− setDeleteError(null);
455− try {
456− await deleteCurrentAccountMutation({
457− sessionId: context?.sessionId ?? undefined,
458− });
459− await resetWorkspace();
460− window.location.assign(workosEnabled ? "/sign-out" : "/");
461− } catch (cause) {
462− const message = cause instanceof Error ? cause.message : "Failed to delete account";
463− setDeleteError(message);
464− setDeletingAccount(false);
465− }
466− };
467−
468− if (loading) {
469− return (
470− <div className="border-t border-border px-3 py-2">
471− <span className="text-[11px] font-mono text-muted-foreground">Loading session...</span>
472− </div>
473− );
474− }
475−
476− return (
477− <div className="border-t border-border">
478− {isSignedInToWorkos ? (
479− <>
480− <DropdownMenu>
481− <DropdownMenuTrigger asChild>
482− <Button
483− variant="ghost"
484− className="h-14 w-full justify-between rounded-none border-0 bg-transparent px-3 py-0 text-left shadow-none hover:bg-accent/40"
485− >
486− <span className="flex items-center gap-2 min-w-0">
487− {avatarUrl ? (
488− <img
489− src={avatarUrl}
490− alt={avatarLabel}
491− className="h-6 w-6 rounded-full border border-border object-cover"
492− />
493− ) : (
494− <span className="h-6 w-6 rounded-full border border-border bg-muted text-[10px] font-mono text-muted-foreground flex items-center justify-center">
495− {avatarInitial}
496− </span>
497− )}
498− <span className="min-w-0">
499− <span className="text-[11px] font-medium truncate block">{avatarLabel}</span>
500− {workosProfile?.email ? (
501− <span className="text-[10px] text-muted-foreground truncate block">{workosProfile.email}</span>
502− ) : null}
503− </span>
504− </span>
505− <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
506− </Button>
507− </DropdownMenuTrigger>
508− <DropdownMenuContent align="end" className="w-64">
509− <DropdownMenuLabel className="text-xs">
510− Account
511− </DropdownMenuLabel>
512− <DropdownMenuSeparator />
513− <DropdownMenuItem
514− className="text-xs"
515− onSelect={(event) => {
516− event.preventDefault();
517− setDeleteConfirmText("");
518− setDeleteError(null);
519− setAccountSettingsOpen(true);
520− }}
521− >
522− Account Settings
523− </DropdownMenuItem>
524− <DropdownMenuItem asChild className="text-xs">
525− <Link to="/sign-out" reloadDocument>Sign out</Link>
526− </DropdownMenuItem>
527− </DropdownMenuContent>
528− </DropdownMenu>
529−
530− <Dialog
531− open={accountSettingsOpen}
532− onOpenChange={(open) => {
533− setAccountSettingsOpen(open);
534− if (!open) {
535− setDeleteConfirmText("");
536− setDeleteError(null);
537− setDeletingAccount(false);
538− }
539− }}
540− >
541− <DialogContent className="sm:max-w-md">
542− <form className="space-y-4" onSubmit={handleDeleteAccount}>
543− <DialogHeader>
544− <DialogTitle>Account Settings</DialogTitle>
545− <DialogDescription>
546− Deleting your account will remove organizations, workspaces, and data you created.
547− This cannot be undone.
548− </DialogDescription>
549− </DialogHeader>
550− <div className="space-y-2">
551− <label htmlFor="delete-account-confirm" className="text-xs font-medium text-foreground">
552− Type DELETE to confirm
553− </label>
554− <Input
555− id="delete-account-confirm"
556− value={deleteConfirmText}
557− onChange={(event) => setDeleteConfirmText(event.target.value)}
558− autoComplete="off"
559− spellCheck={false}
560− />
561− {deleteError ? (
562− <p className="text-xs text-destructive">{deleteError}</p>
563− ) : null}
564− </div>
565− <DialogFooter>
566− <Button
567− type="button"
568− variant="outline"
569− onClick={() => setAccountSettingsOpen(false)}
570− disabled={deletingAccount}
571− >
572− Cancel
573− </Button>
574− <Button
575− type="submit"
576− variant="destructive"
577− disabled={!canDeleteAccount || deletingAccount}
578− >
579− {deletingAccount ? "Deleting..." : "Delete account"}
580− </Button>
581− </DialogFooter>
582− </form>
583− </DialogContent>
584− </Dialog>
585− </>
586− ) : (
587− <div className="px-3 py-3 space-y-2">
588− {workosEnabled ? (
589− <Link to={signInHref} reloadDocument className="block">
590− <Button
591− variant="outline"
592− className="w-full h-9 justify-center gap-2 text-xs font-medium"
593− >
594− <LogIn className="h-3.5 w-3.5" />
595− Sign in
596− </Button>
597− </Link>
598− ) : (
599− <p className="text-[11px] text-muted-foreground text-center">Guest mode</p>
600− )}
601− </div>
602− )}
603−
604− </div>
605− );
606−}
607−
60855 function Sidebar() {
60956 return (
61057 <aside className="hidden md:flex md:w-56 lg:w-60 flex-col border-r border-border bg-sidebar h-screen sticky top-0">
@@ -662,75 +109,6 @@ function MobileHeader() { · 51 unmodified lines
662109 );
663110 }
664111
665−function NoOrganizationModal({ enabled }: { enabled: boolean }) {
666− const {
667− loading,
668− organizations,
669− organizationsLoading,
670− context,
671− isSignedInToWorkos,
672− createAnonymousOrganization,
673− creatingAnonymousOrganization,
674− } = useSession();
675− const [error, setError] = useState<string | null>(null);
676−
677− const shouldShow = enabled
678− && !loading
679− && !organizationsLoading
680− && !context
681− && !isSignedInToWorkos
682− && organizations.length === 0;
683−
684− const handleCreateAnonymousOrganization = async () => {
685− setError(null);
686− try {
687− await createAnonymousOrganization();
688− } catch (cause) {
689− const message = cause instanceof Error ? cause.message : "Failed to create anonymous organization";
690− setError(message);
691− }
692− };
693−
694− return (
695− <Dialog open={shouldShow}>
696− <DialogContent
697− showCloseButton={false}
698− onEscapeKeyDown={(event) => event.preventDefault()}
699− onInteractOutside={(event) => event.preventDefault()}
700− >
701− <DialogHeader>
702− <DialogTitle>Choose how to continue</DialogTitle>
703− <DialogDescription>
704− Sign in to access your organizations, or create an anonymous organization with a default workspace.
705− </DialogDescription>
706− </DialogHeader>
707− <div className="space-y-2">
708− {workosEnabled ? (
709− <Button asChild className="w-full" disabled={creatingAnonymousOrganization}>
710− <Link to="/sign-in" reloadDocument className="gap-2">
711− <LogIn className="h-4 w-4" />
712− Sign in
713− </Link>
714− </Button>
715− ) : null}
716− <Button
717− variant="outline"
718− className="w-full"
719− onClick={handleCreateAnonymousOrganization}
720− disabled={creatingAnonymousOrganization || !anonymousDemoEnabled}
721− >
722− {creatingAnonymousOrganization ? "Creating anonymous organization..." : "Create anonymous organization"}
723− </Button>
724− {!anonymousDemoEnabled ? (
725− <p className="text-xs text-muted-foreground">Anonymous organization creation is disabled.</p>
726− ) : null}
727− {error ? <p className="text-xs text-destructive">{error}</p> : null}
728− </div>
729− </DialogContent>
730− </Dialog>
731− );
732−}
733−
734112 export function AppShell({ children }: { children: React.ReactNode }) {
735113 const location = useLocation();
736114 const pathname = location.pathname;
executor/apps/web/src/components/approval-notifier.tsx+2−1
@@ -7,6 +7,7 @@ import { toast } from "sonner"; · 6 unmodified lines
77 import { useSession } from "@/lib/session-context";
88 import { convexApi } from "@/lib/convex-api";
99 import type { PendingApprovalRecord } from "@/lib/types";
10+import { workspaceQueryArgs } from "@/lib/workspace-query-args";
1011
1112 export function ApprovalNotifier() {
1213 const navigate = useNavigate();
@@ -16,7 +17,7 @@ export function ApprovalNotifier() { · 3 unmodified lines
1617
1718 const approvals = useQuery(
1819 convexApi.workspace.listPendingApprovals,
19− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
20+ workspaceQueryArgs(context),
2021 );
2122
2223 useEffect(() => {
executor/apps/web/src/components/approvals-view.tsx+9−59
@@ -21,62 +21,8 @@ import type { PendingApprovalRecord } from "@/lib/types"; · 20 unmodified lines
2121 import { toast } from "sonner";
2222 import { formatTimeAgo } from "@/lib/format";
2323 import { FormattedCodeBlock } from "@/components/formatted-code-block";
24−
25−function formatApprovalInput(
26− input: unknown,
27−): {
28− content: string;
29− language: "json" | "text";
30−} | null {
31− if (input === null || input === undefined) {
32− return null;
33− }
34−
35− if (typeof input === "string") {
36− const trimmed = input.trim();
37− if (!trimmed) {
38− return null;
39− }
40−
41− try {
42− const parsed = JSON.parse(trimmed);
43− const serialized = JSON.stringify(parsed, null, 2);
44− if (serialized === "null" || serialized === "{}") {
45− return null;
46− }
47− return {
48− content: serialized,
49− language: "json",
50− };
51− } catch {
52− return {
53− content: trimmed,
54− language: "text",
55− };
56− }
57− }
58−
59− try {
60− const serialized = JSON.stringify(input, null, 2);
61− if (serialized === "null" || serialized === "{}") {
62− return null;
63− }
64−
65− return {
66− content: serialized,
67− language: "json",
68− };
69− } catch {
70− const fallback = String(input).trim();
71− if (!fallback) {
72− return null;
73− }
74− return {
75− content: fallback,
76− language: "text",
77− };
78− }
79−}
24+import { formatApprovalInput } from "@/lib/approval-input-format";
25+import { workspaceQueryArgs } from "@/lib/workspace-query-args";
8026
8127 function ApprovalCard({
8228 approval,
@@ -113,7 +59,11 @@ function ApprovalCard({ · 30 unmodified lines
11359 };
11460
11561 const inputDisplay = useMemo(
116− () => formatApprovalInput(approval.input),
62+ () =>
63+ formatApprovalInput(approval.input, {
64+ hideSerializedNull: true,
65+ hideSerializedEmptyObject: true,
66+ }),
11767 [approval.input],
11868 );
11969
@@ -203,7 +153,7 @@ export function ApprovalsView() { · 83 unmodified lines
203153
204154 const approvals = useQuery(
205155 convexApi.workspace.listPendingApprovals,
206− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
156+ workspaceQueryArgs(context),
207157 );
208158 const approvalsLoading = !!context && approvals === undefined;
209159
@@ -255,7 +205,7 @@ export function ApprovalsView() { · 45 unmodified lines
255205 <Clock className="h-3.5 w-3.5" />
256206 {count} pending approval{count !== 1 ? "s" : ""}
257207 </div>
258− {approvals!.map((a: PendingApprovalRecord) => (
208+ {(approvals ?? []).map((a: PendingApprovalRecord) => (
259209 <ApprovalCard key={a.id} approval={a} />
260210 ))}
261211 </div>
executor/apps/web/src/components/code-editor-monaco.ts+193−0
@@ -0,0 +1,193 @@
1+import type { EditorProps, Monaco } from "@monaco-editor/react";
2+
3+const DIAGNOSTIC_CODES_TO_IGNORE = [
4+ 1375,
5+ 1378,
6+ 2307,
7+ 80005,
8+];
9+
10+export function setDiagnosticsOptions(monaco: Monaco, suppressSemantic: boolean) {
11+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12+ const ts = (monaco.languages as any).typescript;
13+ ts.javascriptDefaults.setDiagnosticsOptions({
14+ noSemanticValidation: suppressSemantic,
15+ noSyntaxValidation: false,
16+ diagnosticCodesToIgnore: DIAGNOSTIC_CODES_TO_IGNORE,
17+ });
18+}
19+
20+export function configureJavascriptDefaults(monaco: Monaco, suppressSemantic: boolean) {
21+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22+ const ts = (monaco.languages as any).typescript;
23+
24+ setDiagnosticsOptions(monaco, suppressSemantic);
25+
26+ ts.javascriptDefaults.setCompilerOptions({
27+ target: ts.ScriptTarget.ESNext,
28+ module: ts.ModuleKind.ESNext,
29+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
30+ allowNonTsExtensions: true,
31+ checkJs: true,
32+ strict: false,
33+ noEmit: true,
34+ allowJs: true,
35+ lib: ["esnext"],
36+ });
37+
38+ ts.javascriptDefaults.setEagerModelSync(true);
39+
40+ return ts;
41+}
42+
43+export function defineExecutorThemes(monaco: Monaco) {
44+ monaco.editor.defineTheme("executor-light", {
45+ base: "vs",
46+ inherit: true,
47+ rules: [
48+ { token: "comment", foreground: "7f8692", fontStyle: "italic" },
49+ { token: "keyword", foreground: "0f8a6a" },
50+ { token: "string", foreground: "a46822" },
51+ { token: "number", foreground: "a46822" },
52+ { token: "type", foreground: "0f8a6a" },
53+ { token: "function", foreground: "5c470f" },
54+ { token: "variable", foreground: "1f2430" },
55+ { token: "operator", foreground: "6f7785" },
56+ ],
57+ colors: {
58+ "editor.background": "#ffffff",
59+ "editor.foreground": "#1f2430",
60+ "editor.lineHighlightBackground": "#f4f7fb",
61+ "editor.selectionBackground": "#c7def5",
62+ "editor.inactiveSelectionBackground": "#dbe8f7",
63+ "editorCursor.foreground": "#0f8a6a",
64+ "editorLineNumber.foreground": "#9aa3b2",
65+ "editorLineNumber.activeForeground": "#6f7785",
66+ "editorIndentGuide.background": "#e3e8ef",
67+ "editorIndentGuide.activeBackground": "#ccd5e2",
68+ "editor.selectionHighlightBackground": "#c7def540",
69+ "editorWidget.background": "#ffffff",
70+ "editorWidget.border": "#d6dde8",
71+ "editorSuggestWidget.background": "#ffffff",
72+ "editorSuggestWidget.border": "#d6dde8",
73+ "editorSuggestWidget.selectedBackground": "#cfe1f6",
74+ "editorSuggestWidget.selectedForeground": "#111827",
75+ "editorSuggestWidget.selectedIconForeground": "#0f8a6a",
76+ "editorSuggestWidget.highlightForeground": "#0f8a6a",
77+ "editorHoverWidget.background": "#ffffff",
78+ "editorHoverWidget.border": "#d6dde8",
79+ "list.focusBackground": "#cfe1f6",
80+ "list.focusForeground": "#111827",
81+ "list.highlightForeground": "#0f8a6a",
82+ "input.background": "#ffffff",
83+ "input.border": "#d6dde8",
84+ "scrollbar.shadow": "#00000000",
85+ "scrollbarSlider.background": "#c2cad880",
86+ "scrollbarSlider.hoverBackground": "#a6afbe",
87+ "scrollbarSlider.activeBackground": "#8f98a8",
88+ "focusBorder": "#0f8a6a30",
89+ },
90+ });
91+
92+ monaco.editor.defineTheme("executor-dark", {
93+ base: "vs-dark",
94+ inherit: true,
95+ rules: [
96+ { token: "comment", foreground: "5a6370", fontStyle: "italic" },
97+ { token: "keyword", foreground: "6bdfb8" },
98+ { token: "string", foreground: "c4a46c" },
99+ { token: "number", foreground: "c4a46c" },
100+ { token: "type", foreground: "6bdfb8" },
101+ { token: "function", foreground: "dcdcaa" },
102+ { token: "variable", foreground: "c8ccd4" },
103+ { token: "operator", foreground: "8a93a5" },
104+ ],
105+ colors: {
106+ "editor.background": "#0f1117",
107+ "editor.foreground": "#c8ccd4",
108+ "editor.lineHighlightBackground": "#161922",
109+ "editor.selectionBackground": "#264f78",
110+ "editor.inactiveSelectionBackground": "#1d2536",
111+ "editorCursor.foreground": "#6bdfb8",
112+ "editorLineNumber.foreground": "#3a3f4b",
113+ "editorLineNumber.activeForeground": "#5a6370",
114+ "editorIndentGuide.background": "#1e2230",
115+ "editorIndentGuide.activeBackground": "#2a3040",
116+ "editor.selectionHighlightBackground": "#264f7830",
117+ "editorWidget.background": "#161922",
118+ "editorWidget.border": "#2a3040",
119+ "editorSuggestWidget.background": "#161922",
120+ "editorSuggestWidget.border": "#2a3040",
121+ "editorSuggestWidget.selectedBackground": "#1d2a3a",
122+ "editorSuggestWidget.highlightForeground": "#6bdfb8",
123+ "editorHoverWidget.background": "#161922",
124+ "editorHoverWidget.border": "#2a3040",
125+ "input.background": "#0f1117",
126+ "input.border": "#2a3040",
127+ "scrollbar.shadow": "#00000000",
128+ "scrollbarSlider.background": "#2a304080",
129+ "scrollbarSlider.hoverBackground": "#3a4050",
130+ "scrollbarSlider.activeBackground": "#4a5060",
131+ "focusBorder": "#6bdfb830",
132+ },
133+ });
134+}
135+
136+export const CODE_EDITOR_OPTIONS = {
137+ minimap: { enabled: false },
138+ fixedOverflowWidgets: true,
139+ fontSize: 13,
140+ lineHeight: 22,
141+ fontFamily: "var(--font-geist-mono), 'JetBrains Mono', monospace",
142+ fontLigatures: true,
143+ tabSize: 2,
144+ padding: { top: 12, bottom: 12 },
145+ scrollBeyondLastLine: false,
146+ renderLineHighlight: "gutter",
147+ guides: {
148+ indentation: true,
149+ bracketPairs: true,
150+ },
151+ bracketPairColorization: {
152+ enabled: true,
153+ },
154+ suggest: {
155+ showMethods: true,
156+ showFunctions: true,
157+ showFields: true,
158+ showVariables: true,
159+ showModules: true,
160+ showProperties: true,
161+ showKeywords: true,
162+ preview: true,
163+ shareSuggestSelections: true,
164+ },
165+ quickSuggestions: {
166+ other: true,
167+ comments: false,
168+ strings: true,
169+ },
170+ acceptSuggestionOnCommitCharacter: true,
171+ parameterHints: {
172+ enabled: true,
173+ cycle: true,
174+ },
175+ inlineSuggest: {
176+ enabled: true,
177+ },
178+ wordWrap: "on",
179+ automaticLayout: true,
180+ overviewRulerLanes: 0,
181+ hideCursorInOverviewRuler: true,
182+ overviewRulerBorder: false,
183+ scrollbar: {
184+ vertical: "auto",
185+ horizontal: "auto",
186+ verticalScrollbarSize: 6,
187+ horizontalScrollbarSize: 6,
188+ },
189+ roundedSelection: true,
190+ cursorBlinking: "smooth",
191+ cursorSmoothCaretAnimation: "on",
192+ smoothScrolling: true,
193+} satisfies NonNullable<EditorProps["options"]>;
executor/apps/web/src/components/code-editor-types.ts+204−0
@@ -0,0 +1,204 @@
1+import type { ToolDescriptor } from "@/lib/types";
2+
3+interface NamespaceNode {
4+ children: Map<string, NamespaceNode>;
5+ tools: ToolDescriptor[];
6+}
7+
8+function buildTree(tools: ToolDescriptor[]): NamespaceNode {
9+ const root: NamespaceNode = { children: new Map(), tools: [] };
10+ for (const tool of tools) {
11+ const parts = tool.path.split(".");
12+ if (parts.length === 1) {
13+ root.tools.push(tool);
14+ } else {
15+ let node = root;
16+ for (let i = 0; i < parts.length - 1; i++) {
17+ if (!node.children.has(parts[i])) {
18+ node.children.set(parts[i], { children: new Map(), tools: [] });
19+ }
20+ node = node.children.get(parts[i])!;
21+ }
22+ node.tools.push(tool);
23+ }
24+ }
25+ return root;
26+}
27+
28+function countAllTools(node: NamespaceNode): number {
29+ let count = node.tools.length;
30+ for (const child of node.children.values()) {
31+ count += countAllTools(child);
32+ }
33+ return count;
34+}
35+
36+function emitToolMethod(tool: ToolDescriptor, dtsSources: Set<string>): string {
37+ const funcName = tool.path.split(".").pop()!;
38+ const approvalNote =
39+ tool.approval === "required"
40+ ? " **Requires approval** - execution will pause until approved."
41+ : "";
42+ const desc = tool.description
43+ ? `${tool.description}${approvalNote}`
44+ : approvalNote || "Call this tool.";
45+
46+ const hasSourceDts = Boolean(tool.source && dtsSources.has(tool.source));
47+ if (tool.operationId && hasSourceDts) {
48+ const opKey = JSON.stringify(tool.operationId);
49+ return ` /**
50+ * ${desc}
51+ *${tool.source ? ` @source ${tool.source}` : ""}
52+ */
53+ ${funcName}(input: ToolInput<operations[${opKey}]>): Promise<ToolOutput<operations[${opKey}]>>;`;
54+ }
55+
56+ const strictArgsType = tool.strictArgsType?.trim();
57+ const strictReturnsType = tool.strictReturnsType?.trim();
58+ const fallbackArgsType = tool.argsType?.trim();
59+ const fallbackReturnsType = tool.returnsType?.trim();
60+ const hasArgsType = Boolean(strictArgsType || fallbackArgsType);
61+ const argsType = strictArgsType || fallbackArgsType || "Record<string, unknown>";
62+ const returnsType = strictReturnsType || fallbackReturnsType || "unknown";
63+ const inputParam = !hasArgsType || argsType === "{}" ? `input?: ${argsType}` : `input: ${argsType}`;
64+
65+ return ` /**
66+ * ${desc}
67+ *${tool.source ? ` @source ${tool.source}` : ""}
68+ */
69+ ${funcName}(${inputParam}): Promise<${returnsType}>;`;
70+}
71+
72+function emitNamespaceInterface(
73+ name: string,
74+ node: NamespaceNode,
75+ dtsSources: Set<string>,
76+ out: string[],
77+): void {
78+ for (const [childName, childNode] of node.children) {
79+ emitNamespaceInterface(`${name}_${childName}`, childNode, dtsSources, out);
80+ }
81+
82+ const members: string[] = [];
83+
84+ for (const [childName, childNode] of node.children) {
85+ const toolCount = childNode.tools.length + countAllTools(childNode);
86+ members.push(` /** ${toolCount} tool${toolCount !== 1 ? "s" : ""} in the \`${childName}\` namespace */
87+ readonly ${childName}: ToolNS_${name}_${childName};`);
88+ }
89+
90+ for (const tool of node.tools) {
91+ members.push(emitToolMethod(tool, dtsSources));
92+ }
93+
94+ out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`);
95+}
96+
97+export function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string {
98+ const root = buildTree(tools);
99+
100+ const interfaces: string[] = [];
101+ for (const [name, node] of root.children) {
102+ emitNamespaceInterface(name, node, dtsSources, interfaces);
103+ }
104+
105+ const rootMembers: string[] = [];
106+ for (const [name] of root.children) {
107+ rootMembers.push(` readonly ${name}: ToolNS_${name};`);
108+ }
109+ for (const tool of root.tools) {
110+ rootMembers.push(emitToolMethod(tool, dtsSources));
111+ }
112+
113+ let dts = `
114+/**
115+ * The \`tools\` object is a proxy that lets you call registered executor tools.
116+ * Each call returns a Promise with the tool's result.
117+ * Tools marked with "approval: required" will pause execution until approved.
118+ */
119+`;
120+
121+ dts += interfaces.join("\n\n") + "\n\n";
122+ dts += `interface ToolsProxy {\n${rootMembers.join("\n\n")}\n}\n\n`;
123+ dts += "declare const tools: ToolsProxy;\n";
124+
125+ return dts;
126+}
127+
128+export const OPENAPI_HELPER_TYPES = `
129+type _Normalize<T> = Exclude<T, undefined | null>;
130+type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;
131+type _Simplify<T> = { [K in keyof T]: T[K] } & {};
132+type _ParamsOf<Op> =
133+ Op extends { parameters: infer P } ? P :
134+ Op extends { parameters?: infer P } ? P :
135+ never;
136+type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> =
137+ _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never;
138+type _BodyOf<Op> =
139+ Op extends { requestBody?: infer B } ? B :
140+ Op extends { requestBody: infer B } ? B :
141+ never;
142+type _BodyContent<B> =
143+ B extends { content: infer C }
144+ ? C extends Record<string, infer V> ? V : never
145+ : never;
146+type ToolInput<Op> = _Simplify<
147+ _OrEmpty<_ParamAt<Op, "query">> &
148+ _OrEmpty<_ParamAt<Op, "path">> &
149+ _OrEmpty<_ParamAt<Op, "header">> &
150+ _OrEmpty<_ParamAt<Op, "cookie">> &
151+ _OrEmpty<_BodyContent<_BodyOf<Op>>>
152+>;
153+type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never;
154+type _RespAt<Op, Code extends PropertyKey> =
155+ _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never;
156+type _ResponsePayload<R> =
157+ [R] extends [never] ? never :
158+ R extends { content: infer C }
159+ ? C extends Record<string, infer V> ? V : unknown
160+ : R extends { schema: infer S } ? S : unknown;
161+type _HasStatus<Op, Code extends PropertyKey> =
162+ [_RespAt<Op, Code>] extends [never] ? false : true;
163+type _PayloadAt<Op, Code extends PropertyKey> =
164+ Code extends 204 | 205
165+ ? (_HasStatus<Op, Code> extends true ? void : never)
166+ : _ResponsePayload<_RespAt<Op, Code>>;
167+type _FirstKnown<T extends readonly unknown[]> =
168+ T extends readonly [infer H, ...infer Rest]
169+ ? [H] extends [never] ? _FirstKnown<Rest> : H
170+ : unknown;
171+type ToolOutput<Op> = _FirstKnown<[
172+ _PayloadAt<Op, 200>,
173+ _PayloadAt<Op, 201>,
174+ _PayloadAt<Op, 202>,
175+ _PayloadAt<Op, 203>,
176+ _PayloadAt<Op, 204>,
177+ _PayloadAt<Op, 205>,
178+ _PayloadAt<Op, 206>,
179+ _PayloadAt<Op, 207>,
180+ _PayloadAt<Op, 208>,
181+ _PayloadAt<Op, 226>,
182+ _PayloadAt<Op, "default">,
183+ unknown
184+]>;
185+`;
186+
187+export const BASE_ENVIRONMENT_DTS = `
188+interface Console {
189+ /** Console output is discarded; use explicit return values for results. */
190+ log(...args: any[]): void;
191+ /** Console output is discarded; use explicit return values for results. */
192+ error(...args: any[]): void;
193+ /** Console output is discarded; use explicit return values for results. */
194+ warn(...args: any[]): void;
195+ info(...args: any[]): void;
196+ debug(...args: any[]): void;
197+}
198+declare var console: Console;
199+
200+declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
201+declare function clearTimeout(id: number): void;
202+declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
203+declare function clearInterval(id: number): void;
204+`;
executor/apps/web/src/components/code-editor.tsx+42−445
@@ -1,260 +1,22 @@
11 "use client";
22
3−import { useRef, useEffect, useState } from "react";
3+import { useRef, useEffect, useMemo, useState } from "react";
44 import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";
55 import { Loader2 } from "lucide-react";
66 import { useTheme } from "next-themes";
77 import type { ToolDescriptor } from "@/lib/types";
88 import { cn } from "@/lib/utils";
9−
10−// ── Generate TypeScript declarations for the tools proxy ──
11−
12−interface NamespaceNode {
13− children: Map<string, NamespaceNode>;
14− tools: ToolDescriptor[];
15−}
16−
17−function buildTree(tools: ToolDescriptor[]): NamespaceNode {
18− const root: NamespaceNode = { children: new Map(), tools: [] };
19− for (const tool of tools) {
20− const parts = tool.path.split(".");
21− if (parts.length === 1) {
22− root.tools.push(tool);
23− } else {
24− // Navigate/create namespace nodes for all segments except the last (which is the method name)
25− let node = root;
26− for (let i = 0; i < parts.length - 1; i++) {
27− if (!node.children.has(parts[i])) {
28− node.children.set(parts[i], { children: new Map(), tools: [] });
29− }
30− node = node.children.get(parts[i])!;
31− }
32− node.tools.push(tool);
33− }
34− }
35− return root;
36−}
37−
38−function emitToolMethod(tool: ToolDescriptor, dtsSources: Set<string>): string {
39− const funcName = tool.path.split(".").pop()!;
40− const approvalNote =
41− tool.approval === "required"
42− ? " **Requires approval** - execution will pause until approved."
43− : "";
44− const desc = tool.description
45− ? `${tool.description}${approvalNote}`
46− : approvalNote || "Call this tool.";
47−
48− // For OpenAPI tools with operationId, use indexed access types only when
49− // this source has a loaded .d.ts block.
50− const hasSourceDts = Boolean(tool.source && dtsSources.has(tool.source));
51− if (tool.operationId && hasSourceDts) {
52− const opKey = JSON.stringify(tool.operationId);
53− return ` /**
54− * ${desc}
55− *${tool.source ? ` @source ${tool.source}` : ""}
56− */
57− ${funcName}(input: ToolInput<operations[${opKey}]>): Promise<ToolOutput<operations[${opKey}]>>;`;
58− }
59−
60− // Fallback for MCP, GraphQL, builtins — use argsType/returnsType strings
61− const strictArgsType = tool.strictArgsType?.trim();
62− const strictReturnsType = tool.strictReturnsType?.trim();
63− const fallbackArgsType = tool.argsType?.trim();
64− const fallbackReturnsType = tool.returnsType?.trim();
65− const hasArgsType = Boolean(strictArgsType || fallbackArgsType);
66− const argsType = strictArgsType || fallbackArgsType || "Record<string, unknown>";
67− const returnsType = strictReturnsType || fallbackReturnsType || "unknown";
68− const inputParam = !hasArgsType || argsType === "{}"
69− ? `input?: ${argsType}`
70− : `input: ${argsType}`;
71−
72− return ` /**
73− * ${desc}
74− *${tool.source ? ` @source ${tool.source}` : ""}
75− */
76− ${funcName}(${inputParam}): Promise<${returnsType}>;`;
77−}
78−
79−function emitNamespaceInterface(
80− name: string,
81− node: NamespaceNode,
82− dtsSources: Set<string>,
83− out: string[],
84−): void {
85− // First, recursively emit child namespace interfaces
86− for (const [childName, childNode] of node.children) {
87− emitNamespaceInterface(`${name}_${childName}`, childNode, dtsSources, out);
88− }
89−
90− // Build this namespace's interface
91− const members: string[] = [];
92−
93− // Add child namespace accessors
94− for (const [childName, childNode] of node.children) {
95− const toolCount =
96− childNode.tools.length + countAllTools(childNode);
97− members.push(` /** ${toolCount} tool${toolCount !== 1 ? "s" : ""} in the \`${childName}\` namespace */
98− readonly ${childName}: ToolNS_${name}_${childName};`);
99− }
100−
101− // Add tool methods
102− for (const tool of node.tools) {
103− members.push(emitToolMethod(tool, dtsSources));
104− }
105−
106− out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`);
107−}
108−
109−function countAllTools(node: NamespaceNode): number {
110− let count = node.tools.length;
111− for (const child of node.children.values()) {
112− count += countAllTools(child);
113− }
114− return count;
115−}
116−
117−/** TS helper types for OpenAPI indexed access (same as typechecker.ts) */
118−const OPENAPI_HELPER_TYPES = `
119−type _Normalize<T> = Exclude<T, undefined | null>;
120−type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;
121−type _Simplify<T> = { [K in keyof T]: T[K] } & {};
122−type _ParamsOf<Op> =
123− Op extends { parameters: infer P } ? P :
124− Op extends { parameters?: infer P } ? P :
125− never;
126−type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> =
127− _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never;
128−type _BodyOf<Op> =
129− Op extends { requestBody?: infer B } ? B :
130− Op extends { requestBody: infer B } ? B :
131− never;
132−type _BodyContent<B> =
133− B extends { content: infer C }
134− ? C extends Record<string, infer V> ? V : never
135− : never;
136−type ToolInput<Op> = _Simplify<
137− _OrEmpty<_ParamAt<Op, "query">> &
138− _OrEmpty<_ParamAt<Op, "path">> &
139− _OrEmpty<_ParamAt<Op, "header">> &
140− _OrEmpty<_ParamAt<Op, "cookie">> &
141− _OrEmpty<_BodyContent<_BodyOf<Op>>>
142−>;
143−type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never;
144−type _RespAt<Op, Code extends PropertyKey> =
145− _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never;
146−type _ResponsePayload<R> =
147− [R] extends [never] ? never :
148− R extends { content: infer C }
149− ? C extends Record<string, infer V> ? V : unknown
150− : R extends { schema: infer S } ? S : unknown;
151−type _HasStatus<Op, Code extends PropertyKey> =
152− [_RespAt<Op, Code>] extends [never] ? false : true;
153−type _PayloadAt<Op, Code extends PropertyKey> =
154− Code extends 204 | 205
155− ? (_HasStatus<Op, Code> extends true ? void : never)
156− : _ResponsePayload<_RespAt<Op, Code>>;
157−type _FirstKnown<T extends readonly unknown[]> =
158− T extends readonly [infer H, ...infer Rest]
159− ? [H] extends [never] ? _FirstKnown<Rest> : H
160− : unknown;
161−type ToolOutput<Op> = _FirstKnown<[
162− _PayloadAt<Op, 200>,
163− _PayloadAt<Op, 201>,
164− _PayloadAt<Op, 202>,
165− _PayloadAt<Op, 203>,
166− _PayloadAt<Op, 204>,
167− _PayloadAt<Op, 205>,
168− _PayloadAt<Op, 206>,
169− _PayloadAt<Op, 207>,
170− _PayloadAt<Op, 208>,
171− _PayloadAt<Op, 226>,
172− _PayloadAt<Op, "default">,
173− unknown
174−]>;
175−`;
176−
177−function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string {
178− const root = buildTree(tools);
179−
180− const interfaces: string[] = [];
181−
182− // Emit all namespace interfaces recursively
183− for (const [name, node] of root.children) {
184− emitNamespaceInterface(name, node, dtsSources, interfaces);
185− }
186−
187− // Build root ToolsProxy interface
188− const rootMembers: string[] = [];
189−
190− // Root-level namespace accessors
191− for (const [name] of root.children) {
192− rootMembers.push(` readonly ${name}: ToolNS_${name};`);
193− }
194−
195− // Root-level tools (rare but possible)
196− for (const tool of root.tools) {
197− rootMembers.push(emitToolMethod(tool, dtsSources));
198− }
199−
200− let dts = `
201−/**
202− * The \`tools\` object is a proxy that lets you call registered executor tools.
203− * Each call returns a Promise with the tool's result.
204− * Tools marked with "approval: required" will pause execution until approved.
205− */
206−`;
207−
208− // Note: .d.ts blocks from OpenAPI sources are fetched separately and registered
209− // as a distinct Monaco extra lib (see dtsUrls effect). This keeps generateToolsDts
210− // fast and avoids bundling multi-MB .d.ts content into the tool declarations string.
211−
212− dts += interfaces.join("\n\n") + "\n\n";
213− dts += `interface ToolsProxy {\n${rootMembers.join("\n\n")}\n}\n\n`;
214− dts += `declare const tools: ToolsProxy;\n`;
215−
216− return dts;
217−}
218−
219−// ── Base environment declarations ──
220−// These provide types for the executor runtime environment
221−// (console, setTimeout, etc.) since we don't include the full DOM lib.
222−
223−const BASE_ENVIRONMENT_DTS = `
224−interface Console {
225− /** Console output is discarded; use explicit return values for results. */
226− log(...args: any[]): void;
227− /** Console output is discarded; use explicit return values for results. */
228− error(...args: any[]): void;
229− /** Console output is discarded; use explicit return values for results. */
230− warn(...args: any[]): void;
231− info(...args: any[]): void;
232− debug(...args: any[]): void;
233−}
234−declare var console: Console;
235−
236−declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
237−declare function clearTimeout(id: number): void;
238−declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
239−declare function clearInterval(id: number): void;
240−`;
241−
242−const DIAGNOSTIC_CODES_TO_IGNORE = [
243− 1375, // 'await' expressions are only allowed at the top level of a file when that file is a module
244− 1378, // Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022'...
245− 2307, // Cannot find module
246− 80005, // 'require' call may be converted to an import
247−];
248−
249−function setDiagnosticsOptions(monaco: Monaco, suppressSemantic: boolean) {
250− // eslint-disable-next-line @typescript-eslint/no-explicit-any
251− const ts = (monaco.languages as any).typescript;
252− ts.javascriptDefaults.setDiagnosticsOptions({
253− noSemanticValidation: suppressSemantic,
254− noSyntaxValidation: false,
255− diagnosticCodesToIgnore: DIAGNOSTIC_CODES_TO_IGNORE,
256− });
257−}
9+import {
10+ BASE_ENVIRONMENT_DTS,
11+ OPENAPI_HELPER_TYPES,
12+ generateToolsDts,
13+} from "./code-editor-types";
14+import {
15+ CODE_EDITOR_OPTIONS,
16+ configureJavascriptDefaults,
17+ defineExecutorThemes,
18+ setDiagnosticsOptions,
19+} from "./code-editor-monaco";
25820
25921 interface CodeEditorProps {
26022 value: string;
@@ -285,20 +47,24 @@ export function CodeEditor({ · 24 unmodified lines
28547 const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]);
28648 const toolsLibVersion = useRef(0);
28749 const fetchedDtsUrls = useRef<string>("");
288− const dtsSources = new Set(Object.keys(dtsUrls ?? {}));
50+ const dtsSources = useMemo(() => new Set(Object.keys(dtsUrls ?? {})), [dtsUrls]);
28951 const toolTypesLoading = typesLoading || dtsHydrating;
29052
29153 // Fetch and register .d.ts blobs from OpenAPI sources
29254 useEffect(() => {
29355 if (!dtsUrls || Object.keys(dtsUrls).length === 0) {
294− setDtsHydrating(false);
56+ fetchedDtsUrls.current = "";
57+ for (const d of dtsLibDisposables.current) d.dispose();
58+ dtsLibDisposables.current = [];
59+ void Promise.resolve().then(() => {
60+ setDtsHydrating(false);
61+ });
29562 return;
29663 }
29764 const m = monacoRef.current;
29865 if (!m) return;
29966
300− // eslint-disable-next-line @typescript-eslint/no-explicit-any
301− const jsDefaults = (m.languages as any).typescript.javascriptDefaults;
67+ const jsDefaults = m.languages.typescript.javascriptDefaults;
30268
30369 // Skip if URLs haven't changed
30470 const urlsKey = JSON.stringify(dtsUrls);
@@ -310,23 +76,24 @@ export function CodeEditor({ · 5 unmodified lines
31076 dtsLibDisposables.current = [];
31177
31278 let cancelled = false;
313− setDtsHydrating(true);
314−
315− // Fetch each .d.ts blob and register with Monaco
31679 const entries = Object.entries(dtsUrls);
317− Promise.all(
318− entries.map(async ([sourceKey, url]) => {
319− try {
320− const resp = await fetch(url);
321− if (!resp.ok || cancelled) return null;
322− const content = await resp.text();
323− return { sourceKey, content };
324− } catch {
325− return null;
326− }
327− }),
328− )
329− .then((results) => {
80+
81+ void Promise.resolve()
82+ .then(async () => {
83+ setDtsHydrating(true);
84+ const results = await Promise.all(
85+ entries.map(async ([sourceKey, url]) => {
86+ try {
87+ const resp = await fetch(url);
88+ if (!resp.ok || cancelled) return null;
89+ const content = await resp.text();
90+ return { sourceKey, content };
91+ } catch {
92+ return null;
93+ }
94+ }),
95+ );
96+
33097 if (cancelled) return;
33198
33299 // Build the helper types + .d.ts declarations
@@ -361,8 +128,7 @@ export function CodeEditor({ · 28 unmodified lines
361128 const m = monacoRef.current;
362129 if (!m) return;
363130
364− // eslint-disable-next-line @typescript-eslint/no-explicit-any
365− const jsDefaults = (m.languages as any).typescript.javascriptDefaults;
131+ const jsDefaults = m.languages.typescript.javascriptDefaults;
366132
367133 // Dispose previous tool type declarations
368134 toolsLibDisposable.current?.dispose();
@@ -376,7 +142,7 @@ export function CodeEditor({ · 7 unmodified lines
376142 dts,
377143 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
378144 );
379− }, [tools, dtsUrls]);
145+ }, [tools, dtsSources]);
380146
381147 // Avoid transient semantic errors while tool metadata is still loading.
382148 useEffect(() => {
@@ -397,29 +163,7 @@ export function CodeEditor({ · 14 unmodified lines
397163 const handleBeforeMount: BeforeMount = (monaco) => {
398164 monacoRef.current = monaco;
399165
400− // eslint-disable-next-line @typescript-eslint/no-explicit-any
401− const ts = (monaco.languages as any).typescript;
402−
403− // Configure JavaScript/TypeScript defaults for our execution environment
404− // Code runs inside an AsyncFunction body so top-level await is valid
405− // and there are no imports/exports.
406− setDiagnosticsOptions(monaco, toolTypesLoading);
407−
408− ts.javascriptDefaults.setCompilerOptions({
409− target: ts.ScriptTarget.ESNext,
410− module: ts.ModuleKind.ESNext,
411− moduleResolution: ts.ModuleResolutionKind.NodeJs,
412− allowNonTsExtensions: true,
413− checkJs: true,
414− strict: false,
415− noEmit: true,
416− allowJs: true,
417− lib: ["esnext"],
418− });
419−
420− // Ensure the worker eagerly syncs models so that completions from
421− // addExtraLib declarations are available immediately.
422− ts.javascriptDefaults.setEagerModelSync(true);
166+ const ts = configureJavascriptDefaults(monaco, toolTypesLoading);
423167
424168 // Add stable environment declarations (once)
425169 envLibDisposable.current?.dispose();
@@ -438,97 +182,7 @@ export function CodeEditor({ · 12 unmodified lines
438182 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
439183 );
440184
441− // Define themes that track the app's light/dark mode.
442− monaco.editor.defineTheme("executor-light", {
443− base: "vs",
444− inherit: true,
445− rules: [
446− { token: "comment", foreground: "7f8692", fontStyle: "italic" },
447− { token: "keyword", foreground: "0f8a6a" },
448− { token: "string", foreground: "a46822" },
449− { token: "number", foreground: "a46822" },
450− { token: "type", foreground: "0f8a6a" },
451− { token: "function", foreground: "5c470f" },
452− { token: "variable", foreground: "1f2430" },
453− { token: "operator", foreground: "6f7785" },
454− ],
455− colors: {
456− "editor.background": "#ffffff",
457− "editor.foreground": "#1f2430",
458− "editor.lineHighlightBackground": "#f4f7fb",
459− "editor.selectionBackground": "#c7def5",
460− "editor.inactiveSelectionBackground": "#dbe8f7",
461− "editorCursor.foreground": "#0f8a6a",
462− "editorLineNumber.foreground": "#9aa3b2",
463− "editorLineNumber.activeForeground": "#6f7785",
464− "editorIndentGuide.background": "#e3e8ef",
465− "editorIndentGuide.activeBackground": "#ccd5e2",
466− "editor.selectionHighlightBackground": "#c7def540",
467− "editorWidget.background": "#ffffff",
468− "editorWidget.border": "#d6dde8",
469− "editorSuggestWidget.background": "#ffffff",
470− "editorSuggestWidget.border": "#d6dde8",
471− "editorSuggestWidget.selectedBackground": "#cfe1f6",
472− "editorSuggestWidget.selectedForeground": "#111827",
473− "editorSuggestWidget.selectedIconForeground": "#0f8a6a",
474− "editorSuggestWidget.highlightForeground": "#0f8a6a",
475− "editorHoverWidget.background": "#ffffff",
476− "editorHoverWidget.border": "#d6dde8",
477− "list.focusBackground": "#cfe1f6",
478− "list.focusForeground": "#111827",
479− "list.highlightForeground": "#0f8a6a",
480− "input.background": "#ffffff",
481− "input.border": "#d6dde8",
482− "scrollbar.shadow": "#00000000",
483− "scrollbarSlider.background": "#c2cad880",
484− "scrollbarSlider.hoverBackground": "#a6afbe",
485− "scrollbarSlider.activeBackground": "#8f98a8",
486− "focusBorder": "#0f8a6a30",
487− },
488− });
489−
490− monaco.editor.defineTheme("executor-dark", {
491− base: "vs-dark",
492− inherit: true,
493− rules: [
494− { token: "comment", foreground: "5a6370", fontStyle: "italic" },
495− { token: "keyword", foreground: "6bdfb8" },
496− { token: "string", foreground: "c4a46c" },
497− { token: "number", foreground: "c4a46c" },
498− { token: "type", foreground: "6bdfb8" },
499− { token: "function", foreground: "dcdcaa" },
500− { token: "variable", foreground: "c8ccd4" },
501− { token: "operator", foreground: "8a93a5" },
502− ],
503− colors: {
504− "editor.background": "#0f1117",
505− "editor.foreground": "#c8ccd4",
506− "editor.lineHighlightBackground": "#161922",
507− "editor.selectionBackground": "#264f78",
508− "editor.inactiveSelectionBackground": "#1d2536",
509− "editorCursor.foreground": "#6bdfb8",
510− "editorLineNumber.foreground": "#3a3f4b",
511− "editorLineNumber.activeForeground": "#5a6370",
512− "editorIndentGuide.background": "#1e2230",
513− "editorIndentGuide.activeBackground": "#2a3040",
514− "editor.selectionHighlightBackground": "#264f7830",
515− "editorWidget.background": "#161922",
516− "editorWidget.border": "#2a3040",
517− "editorSuggestWidget.background": "#161922",
518− "editorSuggestWidget.border": "#2a3040",
519− "editorSuggestWidget.selectedBackground": "#1d2a3a",
520− "editorSuggestWidget.highlightForeground": "#6bdfb8",
521− "editorHoverWidget.background": "#161922",
522− "editorHoverWidget.border": "#2a3040",
523− "input.background": "#0f1117",
524− "input.border": "#2a3040",
525− "scrollbar.shadow": "#00000000",
526− "scrollbarSlider.background": "#2a304080",
527− "scrollbarSlider.hoverBackground": "#3a4050",
528− "scrollbarSlider.activeBackground": "#4a5060",
529− "focusBorder": "#6bdfb830",
530− },
531− });
185+ defineExecutorThemes(monaco);
532186 };
533187
534188 const monacoTheme = resolvedTheme === "light" ? "executor-light" : "executor-dark";
@@ -566,64 +220,7 @@ export function CodeEditor({ · 31 unmodified lines
566220 onChange={(v) => onChange(v ?? "")}
567221 beforeMount={handleBeforeMount}
568222 onMount={handleMount}
569− options={{
570− minimap: { enabled: false },
571− fixedOverflowWidgets: true,
572− fontSize: 13,
573− lineHeight: 22,
574− fontFamily: "var(--font-geist-mono), 'JetBrains Mono', monospace",
575− fontLigatures: true,
576− tabSize: 2,
577− padding: { top: 12, bottom: 12 },
578− scrollBeyondLastLine: false,
579− renderLineHighlight: "gutter",
580− guides: {
581− indentation: true,
582− bracketPairs: true,
583− },
584− bracketPairColorization: {
585− enabled: true,
586− },
587− suggest: {
588− showMethods: true,
589− showFunctions: true,
590− showFields: true,
591− showVariables: true,
592− showModules: true,
593− showProperties: true,
594− showKeywords: true,
595− preview: true,
596− shareSuggestSelections: true,
597− },
598− quickSuggestions: {
599− other: true,
600− comments: false,
601− strings: true,
602− },
603− acceptSuggestionOnCommitCharacter: true,
604− parameterHints: {
605− enabled: true,
606− cycle: true,
607− },
608− inlineSuggest: {
609− enabled: true,
610− },
611− wordWrap: "on",
612− automaticLayout: true,
613− overviewRulerLanes: 0,
614− hideCursorInOverviewRuler: true,
615− overviewRulerBorder: false,
616− scrollbar: {
617− vertical: "auto",
618− horizontal: "auto",
619− verticalScrollbarSize: 6,
620− horizontalScrollbarSize: 6,
621− },
622− roundedSelection: true,
623− cursorBlinking: "smooth",
624− cursorSmoothCaretAnimation: "on",
625− smoothScrolling: true,
626− }}
223+ options={CODE_EDITOR_OPTIONS}
627224 loading={
628225 <div className="flex h-full items-center justify-center bg-background text-xs font-mono text-muted-foreground">
629226 Loading editor...
executor/apps/web/src/components/dashboard-pending-approvals-card.tsx+67−0
@@ -0,0 +1,67 @@
1+import { useNavigate } from "react-router";
2+import { AlertTriangle, ArrowRight, CheckCircle2 } from "lucide-react";
3+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4+import { Button } from "@/components/ui/button";
5+import type { PendingApprovalRecord } from "@/lib/types";
6+import { formatTimeAgo } from "@/lib/format";
7+
8+function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) {
9+ const navigate = useNavigate();
10+
11+ return (
12+ <button
13+ onClick={() => navigate(`/tasks?selected=${approval.taskId}`)}
14+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"
15+ >
16+ <div className="h-2 w-2 rounded-full bg-terminal-amber pulse-dot shrink-0" />
17+ <div className="flex-1 min-w-0">
18+ <span className="text-sm font-mono text-foreground">{approval.toolPath}</span>
19+ <span className="text-[11px] text-muted-foreground ml-2">{formatTimeAgo(approval.createdAt)}</span>
20+ </div>
21+ <ArrowRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
22+ </button>
23+ );
24+}
25+
26+export function DashboardPendingApprovalsCard({
27+ pendingCount,
28+ approvals,
29+}: {
30+ pendingCount: number;
31+ approvals: PendingApprovalRecord[];
32+}) {
33+ return (
34+ <Card className="bg-card border-border">
35+ <CardHeader className="pb-3">
36+ <div className="flex items-center justify-between">
37+ <CardTitle className="text-sm font-medium flex items-center gap-2">
38+ <AlertTriangle className="h-4 w-4 text-terminal-amber" />
39+ Pending Approvals
40+ {pendingCount > 0 && (
41+ <span className="text-[10px] font-mono bg-terminal-amber/15 text-terminal-amber px-1.5 py-0.5 rounded">
42+ {pendingCount}
43+ </span>
44+ )}
45+ </CardTitle>
46+ <Button variant="ghost" size="sm" className="text-xs h-7" asChild>
47+ <a href="/approvals">View all</a>
48+ </Button>
49+ </div>
50+ </CardHeader>
51+ <CardContent className="pt-0">
52+ {pendingCount === 0 ? (
53+ <div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
54+ <CheckCircle2 className="h-4 w-4 mr-2 text-terminal-green/50" />
55+ No pending approvals
56+ </div>
57+ ) : (
58+ <div className="space-y-0.5">
59+ {approvals.map((approval) => (
60+ <PendingApprovalRow key={approval.id} approval={approval} />
61+ ))}
62+ </div>
63+ )}
64+ </CardContent>
65+ </Card>
66+ );
67+}
executor/apps/web/src/components/dashboard-recent-tasks-card.tsx+57−0
@@ -0,0 +1,57 @@
1+import { useNavigate } from "react-router";
2+import { Clock } from "lucide-react";
3+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4+import { Button } from "@/components/ui/button";
5+import { TaskStatusBadge } from "@/components/status-badge";
6+import type { TaskRecord } from "@/lib/types";
7+import { formatTime } from "@/lib/format";
8+
9+function RecentTaskRow({ task }: { task: TaskRecord }) {
10+ const navigate = useNavigate();
11+
12+ return (
13+ <button
14+ onClick={() => navigate(`/tasks?selected=${task.id}`)}
15+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"
16+ >
17+ <div className="flex-1 min-w-0">
18+ <span className="text-sm font-mono text-foreground truncate block">{task.id}</span>
19+ <span className="text-[11px] text-muted-foreground">
20+ {task.runtimeId} &middot; {formatTime(task.createdAt)}
21+ </span>
22+ </div>
23+ <TaskStatusBadge status={task.status} />
24+ </button>
25+ );
26+}
27+
28+export function DashboardRecentTasksCard({ recentTasks }: { recentTasks: TaskRecord[] }) {
29+ return (
30+ <Card className="bg-card border-border">
31+ <CardHeader className="pb-3">
32+ <div className="flex items-center justify-between">
33+ <CardTitle className="text-sm font-medium flex items-center gap-2">
34+ <Clock className="h-4 w-4 text-muted-foreground" />
35+ Recent Tasks
36+ </CardTitle>
37+ <Button variant="ghost" size="sm" className="text-xs h-7" asChild>
38+ <a href="/tasks">View all</a>
39+ </Button>
40+ </div>
41+ </CardHeader>
42+ <CardContent className="pt-0">
43+ {recentTasks.length === 0 ? (
44+ <div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
45+ No tasks yet
46+ </div>
47+ ) : (
48+ <div className="space-y-0.5">
49+ {recentTasks.map((task) => (
50+ <RecentTaskRow key={task.id} task={task} />
51+ ))}
52+ </div>
53+ )}
54+ </CardContent>
55+ </Card>
56+ );
57+}
executor/apps/web/src/components/dashboard-setup-card.tsx+70−0
@@ -0,0 +1,70 @@
1+import { ArrowRight, CheckCircle2 } from "lucide-react";
2+import { Button } from "@/components/ui/button";
3+import { Card, CardContent } from "@/components/ui/card";
4+
5+type SetupStep = {
6+ label: string;
7+ done: boolean;
8+ href: string;
9+ pendingText: string;
10+ doneText: string;
11+};
12+
13+export function DashboardSetupCard({
14+ taskCount,
15+ setupSteps,
16+}: {
17+ taskCount: number;
18+ setupSteps: SetupStep[];
19+}) {
20+ return (
21+ <Card className="border-border bg-gradient-to-br from-card to-muted/25">
22+ <CardContent className="p-5 md:p-6 space-y-5">
23+ <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
24+ <div className="space-y-1.5 max-w-2xl">
25+ <p className="text-[11px] uppercase tracking-widest text-muted-foreground">Landing View</p>
26+ <h2 className="text-lg font-semibold tracking-tight">
27+ {taskCount === 0
28+ ? "Connect tools and run your first task"
29+ : "Your task activity and approval queue are live"}
30+ </h2>
31+ <p className="text-sm text-muted-foreground">
32+ Tasks is the primary workflow. Use the advanced runner only when you want direct code
33+ execution.
34+ </p>
35+ </div>
36+ <div className="flex flex-wrap items-center gap-2">
37+ <Button asChild size="sm" className="h-8 text-xs">
38+ <a href="/tasks">Open task activity</a>
39+ </Button>
40+ <Button asChild variant="outline" size="sm" className="h-8 text-xs">
41+ <a href="/tools">Manage tools</a>
42+ </Button>
43+ </div>
44+ </div>
45+
46+ <div className="grid gap-2 md:grid-cols-3">
47+ {setupSteps.map((step) => (
48+ <a
49+ key={step.label}
50+ href={step.href}
51+ className="rounded-md border border-border/70 bg-background/70 px-3 py-2.5 hover:bg-accent/25 transition-colors"
52+ >
53+ <div className="flex items-center justify-between gap-2">
54+ <span className="text-xs font-medium text-foreground">{step.label}</span>
55+ {step.done ? (
56+ <CheckCircle2 className="h-3.5 w-3.5 text-terminal-green" />
57+ ) : (
58+ <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
59+ )}
60+ </div>
61+ <p className="mt-1 text-[11px] text-muted-foreground">
62+ {step.done ? step.doneText : step.pendingText}
63+ </p>
64+ </a>
65+ ))}
66+ </div>
67+ </CardContent>
68+ </Card>
69+ );
70+}
executor/apps/web/src/components/dashboard-stat-card.tsx+36−0
@@ -0,0 +1,36 @@
1+import { Card, CardContent } from "@/components/ui/card";
2+
3+export function DashboardStatCard({
4+ label,
5+ value,
6+ icon: Icon,
7+ accent,
8+}: {
9+ label: string;
10+ value: number | string;
11+ icon: React.ComponentType<{ className?: string }>;
12+ accent?: "green" | "amber" | "red" | "default";
13+}) {
14+ const accentClass = {
15+ green: "text-terminal-green",
16+ amber: "text-terminal-amber",
17+ red: "text-terminal-red",
18+ default: "text-muted-foreground",
19+ }[accent ?? "default"];
20+
21+ return (
22+ <Card className="bg-card border-border">
23+ <CardContent className="p-4">
24+ <div className="flex items-center justify-between">
25+ <div>
26+ <p className="text-[11px] font-mono uppercase tracking-widest text-muted-foreground">{label}</p>
27+ <p className={`text-2xl font-semibold mt-1 font-mono ${accentClass}`}>{value}</p>
28+ </div>
29+ <div className={`${accentClass} opacity-40`}>
30+ <Icon className="h-8 w-8" />
31+ </div>
32+ </div>
33+ </CardContent>
34+ </Card>
35+ );
36+}
executor/apps/web/src/components/dashboard-tools-summary-card.tsx+83−0
@@ -0,0 +1,83 @@
1+import { useMemo } from "react";
2+import { useNavigate } from "react-router";
3+import { ArrowRight, ChevronRight, Globe, Server, Wrench } from "lucide-react";
4+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5+import { Button } from "@/components/ui/button";
6+import type { ToolDescriptor } from "@/lib/types";
7+import { groupToolsBySource } from "@/components/dashboard-view-helpers";
8+
9+export function DashboardToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) {
10+ const navigate = useNavigate();
11+ const groups = useMemo(() => groupToolsBySource(tools), [tools]);
12+ const totalApprovals = tools.filter((tool) => tool.approval === "required").length;
13+
14+ return (
15+ <Card className="bg-card border-border">
16+ <CardHeader className="pb-3">
17+ <div className="flex items-center justify-between">
18+ <CardTitle className="text-sm font-medium flex items-center gap-2">
19+ <Wrench className="h-4 w-4 text-muted-foreground" />
20+ Tool Sources
21+ <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">
22+ {tools.length} tools
23+ </span>
24+ {totalApprovals > 0 && (
25+ <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded">
26+ {totalApprovals} gated
27+ </span>
28+ )}
29+ </CardTitle>
30+ <Button
31+ variant="ghost"
32+ size="sm"
33+ className="text-xs h-7"
34+ onClick={() => navigate("/tools")}
35+ >
36+ Manage
37+ <ArrowRight className="h-3 w-3 ml-1" />
38+ </Button>
39+ </div>
40+ </CardHeader>
41+ <CardContent className="pt-0">
42+ <div className="grid gap-1">
43+ {groups.map((group) => {
44+ const SourceIcon = group.type === "mcp" ? Server : Globe;
45+
46+ return (
47+ <button
48+ key={group.name}
49+ onClick={() => navigate(`/tools?source=${encodeURIComponent(group.name)}`)}
50+ className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent/40 transition-colors text-left group/row w-full"
51+ >
52+ <div className="h-7 w-7 rounded bg-muted flex items-center justify-center shrink-0">
53+ <SourceIcon className="h-3.5 w-3.5 text-muted-foreground" />
54+ </div>
55+ <div className="flex-1 min-w-0">
56+ <div className="flex items-center gap-2">
57+ <span className="text-sm font-mono font-medium text-foreground">{group.name}</span>
58+ <span className="text-[10px] font-mono text-muted-foreground/70 uppercase tracking-wider">
59+ {group.type}
60+ </span>
61+ </div>
62+ <span className="text-[11px] text-muted-foreground">
63+ {group.tools.length} tool{group.tools.length !== 1 ? "s" : ""}
64+ {group.namespaces.size > 0 && (
65+ <> · {group.namespaces.size} namespace{group.namespaces.size !== 1 ? "s" : ""}</>
66+ )}
67+ {group.approvalCount > 0 && (
68+ <>
69+ {" "}
70+ · <span className="text-terminal-amber">{group.approvalCount} gated</span>
71+ </>
72+ )}
73+ </span>
74+ </div>
75+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40 opacity-0 group-hover/row:opacity-100 transition-opacity shrink-0" />
76+ </button>
77+ );
78+ })}
79+ </div>
80+ </CardContent>
81+ </Card>
82+ );
83+}
executor/apps/web/src/components/dashboard-view-helpers.ts+101−0
@@ -0,0 +1,101 @@
1+import type { PendingApprovalRecord, TaskRecord, ToolDescriptor } from "@/lib/types";
2+import { sourceLabel, sourceType } from "@/lib/tool-source-utils";
3+
4+type SetupStep = {
5+ label: string;
6+ done: boolean;
7+ href: string;
8+ pendingText: string;
9+ doneText: string;
10+};
11+
12+export type DashboardTaskStats = {
13+ runningCount: number;
14+ completedCount: number;
15+ failedCount: number;
16+};
17+
18+export type ToolSourceGroup = {
19+ name: string;
20+ type: string;
21+ tools: ToolDescriptor[];
22+ namespaces: Set<string>;
23+ approvalCount: number;
24+};
25+
26+export function getTaskStats(tasks: TaskRecord[]): DashboardTaskStats {
27+ return {
28+ runningCount: tasks.filter((task) => task.status === "running").length,
29+ completedCount: tasks.filter((task) => task.status === "completed").length,
30+ failedCount: tasks.filter((task) => ["failed", "timed_out", "denied"].includes(task.status)).length,
31+ };
32+}
33+
34+export function getSetupSteps({
35+ sourceCount,
36+ taskCount,
37+ pendingCount,
38+}: {
39+ sourceCount: number;
40+ taskCount: number;
41+ pendingCount: number;
42+}): SetupStep[] {
43+ return [
44+ {
45+ label: "Connect a tool source",
46+ done: sourceCount > 0,
47+ href: "/tools",
48+ pendingText: "Add MCP, OpenAPI, or GraphQL",
49+ doneText: `${sourceCount} source${sourceCount === 1 ? "" : "s"} connected`,
50+ },
51+ {
52+ label: "Run a first task",
53+ done: taskCount > 0,
54+ href: "/tasks",
55+ pendingText: "Use the advanced runner to execute code",
56+ doneText: `${taskCount} task${taskCount === 1 ? "" : "s"} recorded`,
57+ },
58+ {
59+ label: "Review gated calls",
60+ done: pendingCount === 0,
61+ href: "/approvals",
62+ pendingText: `${pendingCount} approval${pendingCount === 1 ? "" : "s"} waiting`,
63+ doneText: "Approval queue is clear",
64+ },
65+ ];
66+}
67+
68+export function getRecentTasks(tasks: TaskRecord[]): TaskRecord[] {
69+ return tasks.slice(0, 8);
70+}
71+
72+export function getPendingApprovals(approvals: PendingApprovalRecord[]): PendingApprovalRecord[] {
73+ return approvals.slice(0, 5);
74+}
75+
76+export function groupToolsBySource(tools: ToolDescriptor[]): ToolSourceGroup[] {
77+ const map = new Map<string, ToolSourceGroup>();
78+
79+ for (const tool of tools) {
80+ const name = sourceLabel(tool.source);
81+ const type = sourceType(tool.source);
82+ let group = map.get(name);
83+ if (!group) {
84+ group = { name, type, tools: [], namespaces: new Set(), approvalCount: 0 };
85+ map.set(name, group);
86+ }
87+
88+ group.tools.push(tool);
89+
90+ const parts = tool.path.split(".");
91+ if (parts.length >= 2) {
92+ group.namespaces.add(`${parts[0]}.${parts[1]}`);
93+ }
94+
95+ if (tool.approval === "required") {
96+ group.approvalCount++;
97+ }
98+ }
99+
100+ return Array.from(map.values()).sort((a, b) => b.tools.length - a.tools.length);
101+}
executor/apps/web/src/components/dashboard-view.tsx+28−383
@@ -1,250 +1,41 @@
11 "use client";
22
3−import { useMemo } from "react";
4−import { useNavigate } from "react-router";
5−import {
6− Play,
7− ShieldCheck,
8− Clock,
9− CheckCircle2,
10− XCircle,
11− AlertTriangle,
12− ArrowRight,
13− Wrench,
14− ChevronRight,
15− Server,
16− Globe,
17−} from "lucide-react";
18−import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
19−import { Button } from "@/components/ui/button";
3+import { CheckCircle2, Play, ShieldCheck, XCircle } from "lucide-react";
204 import { Skeleton } from "@/components/ui/skeleton";
215 import { PageHeader } from "@/components/page-header";
22−import { TaskStatusBadge } from "@/components/status-badge";
6+import { DashboardSetupCard } from "@/components/dashboard-setup-card";
7+import { DashboardStatCard } from "@/components/dashboard-stat-card";
8+import { DashboardPendingApprovalsCard } from "@/components/dashboard-pending-approvals-card";
9+import { DashboardRecentTasksCard } from "@/components/dashboard-recent-tasks-card";
10+import { DashboardToolsSummaryCard } from "@/components/dashboard-tools-summary-card";
2311 import { useSession } from "@/lib/session-context";
2412 import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
2513 import { useQuery } from "convex/react";
2614 import { convexApi } from "@/lib/convex-api";
27−import type { TaskRecord, PendingApprovalRecord, ToolDescriptor } from "@/lib/types";
28−import { formatTime, formatTimeAgo } from "@/lib/format";
29−
30−function StatCard({
31− label,
32− value,
33− icon: Icon,
34− accent,
35−}: {
36− label: string;
37− value: number | string;
38− icon: React.ComponentType<{ className?: string }>;
39− accent?: "green" | "amber" | "red" | "default";
40−}) {
41− const accentClass = {
42− green: "text-terminal-green",
43− amber: "text-terminal-amber",
44− red: "text-terminal-red",
45− default: "text-muted-foreground",
46− }[accent ?? "default"];
47−
48− return (
49− <Card className="bg-card border-border">
50− <CardContent className="p-4">
51− <div className="flex items-center justify-between">
52− <div>
53− <p className="text-[11px] font-mono uppercase tracking-widest text-muted-foreground">
54− {label}
55− </p>
56− <p className={`text-2xl font-semibold mt-1 font-mono ${accentClass}`}>
57− {value}
58− </p>
59− </div>
60− <div className={`${accentClass} opacity-40`}>
61− <Icon className="h-8 w-8" />
62− </div>
63− </div>
64− </CardContent>
65− </Card>
66− );
67−}
68−
69−function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) {
70− const navigate = useNavigate();
71− return (
72− <button
73− onClick={() => navigate(`/tasks?selected=${approval.taskId}`)}
74− className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"
75− >
76− <div className="h-2 w-2 rounded-full bg-terminal-amber pulse-dot shrink-0" />
77− <div className="flex-1 min-w-0">
78− <span className="text-sm font-mono text-foreground">
79− {approval.toolPath}
80− </span>
81− <span className="text-[11px] text-muted-foreground ml-2">
82− {formatTimeAgo(approval.createdAt)}
83− </span>
84− </div>
85− <ArrowRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
86− </button>
87− );
88−}
89−
90−function RecentTaskRow({ task }: { task: TaskRecord }) {
91− const navigate = useNavigate();
92− return (
93− <button
94− onClick={() => navigate(`/tasks?selected=${task.id}`)}
95− className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"
96− >
97− <div className="flex-1 min-w-0">
98− <span className="text-sm font-mono text-foreground truncate block">
99− {task.id}
100− </span>
101− <span className="text-[11px] text-muted-foreground">
102− {task.runtimeId} &middot; {formatTime(task.createdAt)}
103− </span>
104− </div>
105− <TaskStatusBadge status={task.status} />
106− </button>
107− );
108−}
109−
110−/** Derive the "source name" from a tool's source field (e.g. "openapi:github" → "github"). */
111−function sourceLabel(source?: string): string {
112− if (!source) return "built-in";
113− const colonIdx = source.indexOf(":");
114− return colonIdx >= 0 ? source.slice(colonIdx + 1) : source;
115−}
116−
117−/** Derive the source type prefix (e.g. "openapi", "mcp", "graphql"). */
118−function sourceType(source?: string): string {
119− if (!source) return "local";
120− const colonIdx = source.indexOf(":");
121− return colonIdx >= 0 ? source.slice(0, colonIdx) : "local";
122−}
123−
124−function ToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) {
125− const navigate = useNavigate();
126−
127− const groups = useMemo(() => {
128− const map = new Map<
129− string,
130− {
131− name: string;
132− type: string;
133− tools: ToolDescriptor[];
134− namespaces: Set<string>;
135− approvalCount: number;
136− }
137− >();
138−
139− for (const tool of tools) {
140− const name = sourceLabel(tool.source);
141− const type = sourceType(tool.source);
142− let group = map.get(name);
143− if (!group) {
144− group = { name, type, tools: [], namespaces: new Set(), approvalCount: 0 };
145− map.set(name, group);
146− }
147− group.tools.push(tool);
148− // Extract namespace: first two segments of the path (e.g. "github.repos" from "github.repos.list")
149− const parts = tool.path.split(".");
150− if (parts.length >= 2) {
151− group.namespaces.add(`${parts[0]}.${parts[1]}`);
152− }
153− if (tool.approval === "required") {
154− group.approvalCount++;
155− }
156− }
157−
158− return Array.from(map.values()).sort((a, b) => b.tools.length - a.tools.length);
159− }, [tools]);
160−
161− const totalApprovals = tools.filter((t) => t.approval === "required").length;
162−
163− return (
164− <Card className="bg-card border-border">
165− <CardHeader className="pb-3">
166− <div className="flex items-center justify-between">
167− <CardTitle className="text-sm font-medium flex items-center gap-2">
168− <Wrench className="h-4 w-4 text-muted-foreground" />
169− Tool Sources
170− <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">
171− {tools.length} tools
172− </span>
173− {totalApprovals > 0 && (
174− <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded">
175− {totalApprovals} gated
176− </span>
177− )}
178− </CardTitle>
179− <Button
180− variant="ghost"
181− size="sm"
182− className="text-xs h-7"
183− onClick={() => navigate("/tools")}
184− >
185− Manage
186− <ArrowRight className="h-3 w-3 ml-1" />
187− </Button>
188− </div>
189− </CardHeader>
190− <CardContent className="pt-0">
191− <div className="grid gap-1">
192− {groups.map((group) => {
193− const SourceIcon = group.type === "mcp" ? Server : Globe;
194− return (
195− <button
196− key={group.name}
197− onClick={() => navigate(`/tools?source=${encodeURIComponent(group.name)}`)}
198− className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent/40 transition-colors text-left group/row w-full"
199− >
200− <div className="h-7 w-7 rounded bg-muted flex items-center justify-center shrink-0">
201− <SourceIcon className="h-3.5 w-3.5 text-muted-foreground" />
202− </div>
203− <div className="flex-1 min-w-0">
204− <div className="flex items-center gap-2">
205− <span className="text-sm font-mono font-medium text-foreground">
206− {group.name}
207− </span>
208− <span className="text-[10px] font-mono text-muted-foreground/70 uppercase tracking-wider">
209− {group.type}
210− </span>
211− </div>
212− <span className="text-[11px] text-muted-foreground">
213− {group.tools.length} tool{group.tools.length !== 1 ? "s" : ""}
214− {group.namespaces.size > 0 && (
215− <> · {group.namespaces.size} namespace{group.namespaces.size !== 1 ? "s" : ""}</>
216− )}
217− {group.approvalCount > 0 && (
218− <> · <span className="text-terminal-amber">{group.approvalCount} gated</span></>
219− )}
220− </span>
221− </div>
222− <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40 opacity-0 group-hover/row:opacity-100 transition-opacity shrink-0" />
223− </button>
224− );
225− })}
226− </div>
227− </CardContent>
228− </Card>
229− );
230−}
15+import {
16+ getPendingApprovals,
17+ getRecentTasks,
18+ getSetupSteps,
19+ getTaskStats,
20+} from "@/components/dashboard-view-helpers";
21+import { workspaceQueryArgs } from "@/lib/workspace-query-args";
23122
23223 export function DashboardView() {
23324 const { context, loading: sessionLoading } = useSession();
23425
23526 const tasks = useQuery(
23627 convexApi.workspace.listTasks,
237− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
28+ workspaceQueryArgs(context),
23829 );
23930
24031 const approvals = useQuery(
24132 convexApi.workspace.listPendingApprovals,
242− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
33+ workspaceQueryArgs(context),
24334 );
24435
24536 const sources = useQuery(
24637 convexApi.workspace.listToolSources,
247− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
38+ workspaceQueryArgs(context),
24839 );
24940
25041 const { tools } = useWorkspaceTools(context ?? null);
@@ -265,38 +56,10 @@ export function DashboardView() { · 14 unmodified lines
26556 const pendingCount = approvals?.length ?? 0;
26657 const sourceCount = sources?.length ?? 0;
26758 const taskItems = tasks ?? [];
268− const runningCount =
269− tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0;
270− const completedCount =
271− tasks?.filter((t: TaskRecord) => t.status === "completed").length ?? 0;
272− const failedCount =
273− tasks?.filter((t: TaskRecord) => ["failed", "timed_out", "denied"].includes(t.status))
274− .length ?? 0;
275− const recentTasks = taskItems.slice(0, 8);
276−
277− const setupSteps = [
278− {
279− label: "Connect a tool source",
280− done: sourceCount > 0,
281− href: "/tools",
282− pendingText: "Add MCP, OpenAPI, or GraphQL",
283− doneText: `${sourceCount} source${sourceCount === 1 ? "" : "s"} connected`,
284− },
285− {
286− label: "Run a first task",
287− done: taskItems.length > 0,
288− href: "/tasks",
289− pendingText: "Use the advanced runner to execute code",
290− doneText: `${taskItems.length} task${taskItems.length === 1 ? "" : "s"} recorded`,
291− },
292− {
293− label: "Review gated calls",
294− done: pendingCount === 0,
295− href: "/approvals",
296− pendingText: `${pendingCount} approval${pendingCount === 1 ? "" : "s"} waiting`,
297− doneText: "Approval queue is clear",
298− },
299− ];
59+ const { runningCount, completedCount, failedCount } = getTaskStats(taskItems);
60+ const recentTasks = getRecentTasks(taskItems);
61+ const pendingApprovals = getPendingApprovals(approvals ?? []);
62+ const setupSteps = getSetupSteps({ sourceCount, taskCount: taskItems.length, pendingCount });
30063
30164 return (
30265 <div className="space-y-6">
@@ -305,75 +68,28 @@ export function DashboardView() { · 2 unmodified lines
30568 description="Start from the current status, then jump straight to execution"
30669 />
30770
308− <Card className="border-border bg-gradient-to-br from-card to-muted/25">
309− <CardContent className="p-5 md:p-6 space-y-5">
310− <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
311− <div className="space-y-1.5 max-w-2xl">
312− <p className="text-[11px] uppercase tracking-widest text-muted-foreground">Landing View</p>
313− <h2 className="text-lg font-semibold tracking-tight">
314− {taskItems.length === 0
315− ? "Connect tools and run your first task"
316− : "Your task activity and approval queue are live"}
317− </h2>
318− <p className="text-sm text-muted-foreground">
319− Tasks is the primary workflow. Use the advanced runner only when you want direct code execution.
320− </p>
321− </div>
322− <div className="flex flex-wrap items-center gap-2">
323− <Button asChild size="sm" className="h-8 text-xs">
324− <a href="/tasks">Open task activity</a>
325− </Button>
326− <Button asChild variant="outline" size="sm" className="h-8 text-xs">
327− <a href="/tools">Manage tools</a>
328− </Button>
329− </div>
330− </div>
71+ <DashboardSetupCard taskCount={taskItems.length} setupSteps={setupSteps} />
33172
332− <div className="grid gap-2 md:grid-cols-3">
333− {setupSteps.map((step) => (
334− <a
335− key={step.label}
336− href={step.href}
337− className="rounded-md border border-border/70 bg-background/70 px-3 py-2.5 hover:bg-accent/25 transition-colors"
338− >
339− <div className="flex items-center justify-between gap-2">
340− <span className="text-xs font-medium text-foreground">{step.label}</span>
341− {step.done ? (
342− <CheckCircle2 className="h-3.5 w-3.5 text-terminal-green" />
343− ) : (
344− <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
345− )}
346− </div>
347− <p className="mt-1 text-[11px] text-muted-foreground">
348− {step.done ? step.doneText : step.pendingText}
349− </p>
350− </a>
351− ))}
352− </div>
353− </CardContent>
354− </Card>
355−
356− {/* Stats row */}
35773 <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
358− <StatCard
74+ <DashboardStatCard
35975 label="Pending Approvals"
36076 value={pendingCount}
36177 icon={ShieldCheck}
36278 accent={pendingCount > 0 ? "amber" : "default"}
36379 />
364− <StatCard
80+ <DashboardStatCard
36581 label="Running"
36682 value={runningCount}
36783 icon={Play}
36884 accent={runningCount > 0 ? "green" : "default"}
36985 />
370− <StatCard
86+ <DashboardStatCard
37187 label="Completed"
37288 value={completedCount}
37389 icon={CheckCircle2}
37490 accent="green"
37591 />
376− <StatCard
92+ <DashboardStatCard
37793 label="Failed"
37894 value={failedCount}
37995 icon={XCircle}
@@ -381,83 +97,12 @@ export function DashboardView() { · 1 unmodified line
38197 />
38298 </div>
38399
384− {/* Two column layout */}
385100 <div className="grid lg:grid-cols-2 gap-6">
386− {/* Pending approvals */}
387− <Card className="bg-card border-border">
388− <CardHeader className="pb-3">
389− <div className="flex items-center justify-between">
390− <CardTitle className="text-sm font-medium flex items-center gap-2">
391− <AlertTriangle className="h-4 w-4 text-terminal-amber" />
392− Pending Approvals
393− {pendingCount > 0 && (
394− <span className="text-[10px] font-mono bg-terminal-amber/15 text-terminal-amber px-1.5 py-0.5 rounded">
395− {pendingCount}
396− </span>
397− )}
398− </CardTitle>
399− <Button
400− variant="ghost"
401− size="sm"
402− className="text-xs h-7"
403− asChild
404− >
405− <a href="/approvals">View all</a>
406− </Button>
407− </div>
408− </CardHeader>
409− <CardContent className="pt-0">
410− {pendingCount === 0 ? (
411− <div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
412− <CheckCircle2 className="h-4 w-4 mr-2 text-terminal-green/50" />
413− No pending approvals
414− </div>
415− ) : (
416− <div className="space-y-0.5">
417− {(approvals ?? []).slice(0, 5).map((a: PendingApprovalRecord) => (
418− <PendingApprovalRow key={a.id} approval={a} />
419− ))}
420− </div>
421− )}
422− </CardContent>
423− </Card>
424−
425− {/* Recent tasks */}
426− <Card className="bg-card border-border">
427− <CardHeader className="pb-3">
428− <div className="flex items-center justify-between">
429− <CardTitle className="text-sm font-medium flex items-center gap-2">
430− <Clock className="h-4 w-4 text-muted-foreground" />
431− Recent Tasks
432− </CardTitle>
433− <Button
434− variant="ghost"
435− size="sm"
436− className="text-xs h-7"
437− asChild
438− >
439− <a href="/tasks">View all</a>
440− </Button>
441− </div>
442− </CardHeader>
443− <CardContent className="pt-0">
444− {recentTasks.length === 0 ? (
445− <div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
446− No tasks yet
447− </div>
448− ) : (
449− <div className="space-y-0.5">
450− {recentTasks.map((t: TaskRecord) => (
451− <RecentTaskRow key={t.id} task={t} />
452− ))}
453− </div>
454− )}
455− </CardContent>
456− </Card>
101+ <DashboardPendingApprovalsCard pendingCount={pendingCount} approvals={pendingApprovals} />
102+ <DashboardRecentTasksCard recentTasks={recentTasks} />
457103 </div>
458104
459− {/* Tools summary — grouped by source */}
460− {tools.length > 0 && <ToolsSummaryCard tools={tools} />}
105+ {tools.length > 0 && <DashboardToolsSummaryCard tools={tools} />}
461106 </div>
462107 );
463108 }
executor/apps/web/src/components/mcp-setup-card.tsx+9−1
@@ -2,6 +2,7 @@ · 1 unmodified line
22
33 import { useMemo, useState } from "react";
44 import { Check, Copy } from "lucide-react";
5+import Image from "next/image";
56 import { Button } from "@/components/ui/button";
67 import { Input } from "@/components/ui/input";
78 import { Label } from "@/components/ui/label";
@@ -118,7 +119,14 @@ export function McpSetupCard({ · 110 unmodified lines
118119 {MCP_PROVIDERS.map((item) => (
119120 <SelectItem key={item.id} value={item.id} className="text-xs">
120121 <div className="flex items-center gap-2">
121− <img src={item.icon} alt="" className="h-3.5 w-3.5 rounded-sm" />
122+ <Image
123+ src={item.icon}
124+ alt=""
125+ width={14}
126+ height={14}
127+ className="h-3.5 w-3.5 rounded-sm"
128+ unoptimized
129+ />
122130 <span>{item.name}</span>
123131 </div>
124132 </SelectItem>
executor/apps/web/src/components/no-organization-modal.tsx+84−0
@@ -0,0 +1,84 @@
1+"use client";
2+
3+import { useState } from "react";
4+import { Link } from "react-router";
5+import { LogIn } from "lucide-react";
6+import { Button } from "@/components/ui/button";
7+import {
8+ Dialog,
9+ DialogContent,
10+ DialogDescription,
11+ DialogHeader,
12+ DialogTitle,
13+} from "@/components/ui/dialog";
14+import { useSession } from "@/lib/session-context";
15+import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";
16+
17+export function NoOrganizationModal({ enabled }: { enabled: boolean }) {
18+ const {
19+ loading,
20+ organizations,
21+ organizationsLoading,
22+ context,
23+ isSignedInToWorkos,
24+ createAnonymousOrganization,
25+ creatingAnonymousOrganization,
26+ } = useSession();
27+ const [error, setError] = useState<string | null>(null);
28+
29+ const shouldShow = enabled
30+ && !loading
31+ && !organizationsLoading
32+ && !context
33+ && !isSignedInToWorkos
34+ && organizations.length === 0;
35+
36+ const handleCreateAnonymousOrganization = async () => {
37+ setError(null);
38+ try {
39+ await createAnonymousOrganization();
40+ } catch (cause) {
41+ const message = cause instanceof Error ? cause.message : "Failed to create anonymous organization";
42+ setError(message);
43+ }
44+ };
45+
46+ return (
47+ <Dialog open={shouldShow}>
48+ <DialogContent
49+ showCloseButton={false}
50+ onEscapeKeyDown={(event) => event.preventDefault()}
51+ onInteractOutside={(event) => event.preventDefault()}
52+ >
53+ <DialogHeader>
54+ <DialogTitle>Choose how to continue</DialogTitle>
55+ <DialogDescription>
56+ Sign in to access your organizations, or create an anonymous organization with a default workspace.
57+ </DialogDescription>
58+ </DialogHeader>
59+ <div className="space-y-2">
60+ {workosEnabled ? (
61+ <Button asChild className="w-full" disabled={creatingAnonymousOrganization}>
62+ <Link to="/sign-in" reloadDocument className="gap-2">
63+ <LogIn className="h-4 w-4" />
64+ Sign in
65+ </Link>
66+ </Button>
67+ ) : null}
68+ <Button
69+ variant="outline"
70+ className="w-full"
71+ onClick={handleCreateAnonymousOrganization}
72+ disabled={creatingAnonymousOrganization || !anonymousDemoEnabled}
73+ >
74+ {creatingAnonymousOrganization ? "Creating anonymous organization..." : "Create anonymous organization"}
75+ </Button>
76+ {!anonymousDemoEnabled ? (
77+ <p className="text-xs text-muted-foreground">Anonymous organization creation is disabled.</p>
78+ ) : null}
79+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
80+ </div>
81+ </DialogContent>
82+ </Dialog>
83+ );
84+}
executor/apps/web/src/components/session-info.tsx+237−0
@@ -0,0 +1,237 @@
1+"use client";
2+
3+import { useState, type FormEvent } from "react";
4+import { Link, useSearchParams } from "react-router";
5+import { useMutation } from "convex/react";
6+import { ChevronsUpDown, LogIn } from "lucide-react";
7+import Image from "next/image";
8+import { Button } from "@/components/ui/button";
9+import {
10+ DropdownMenu,
11+ DropdownMenuContent,
12+ DropdownMenuItem,
13+ DropdownMenuLabel,
14+ DropdownMenuSeparator,
15+ DropdownMenuTrigger,
16+} from "@/components/ui/dropdown-menu";
17+import {
18+ Dialog,
19+ DialogContent,
20+ DialogDescription,
21+ DialogFooter,
22+ DialogHeader,
23+ DialogTitle,
24+} from "@/components/ui/dialog";
25+import { Input } from "@/components/ui/input";
26+import { useSession } from "@/lib/session-context";
27+import { convexApi } from "@/lib/convex-api";
28+import { workosEnabled } from "@/lib/auth-capabilities";
29+
30+export function SessionInfo() {
31+ const {
32+ loading,
33+ isSignedInToWorkos,
34+ workosProfile,
35+ context,
36+ workspaces,
37+ resetWorkspace,
38+ } = useSession();
39+ const [searchParams] = useSearchParams();
40+ const deleteCurrentAccountMutation = useMutation(convexApi.accounts.deleteCurrentAccount);
41+ const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
42+ const [deleteConfirmText, setDeleteConfirmText] = useState("");
43+ const [deleteError, setDeleteError] = useState<string | null>(null);
44+ const [deletingAccount, setDeletingAccount] = useState(false);
45+ const avatarUrl = workosProfile?.avatarUrl ?? null;
46+ const avatarLabel = workosProfile?.name || workosProfile?.email || "User";
47+ const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase();
48+ const canDeleteAccount = deleteConfirmText === "DELETE";
49+
50+ const activeWorkspace = context
51+ ? workspaces.find((workspace) => workspace.id === context.workspaceId)
52+ : null;
53+ const inferredOrganizationId = activeWorkspace?.organizationId ?? undefined;
54+ const hintedOrganizationId =
55+ searchParams.get("organization_id")
56+ ?? searchParams.get("organizationId")
57+ ?? searchParams.get("org_id")
58+ ?? searchParams.get("orgId")
59+ ?? inferredOrganizationId
60+ ?? undefined;
61+ const hintedLogin =
62+ searchParams.get("login_hint")
63+ ?? searchParams.get("loginHint")
64+ ?? searchParams.get("email")
65+ ?? undefined;
66+ const signInParams = new URLSearchParams();
67+ if (hintedOrganizationId) {
68+ signInParams.set("organization_id", hintedOrganizationId);
69+ }
70+ if (hintedLogin) {
71+ signInParams.set("login_hint", hintedLogin);
72+ }
73+ const signInHref = signInParams.size > 0 ? `/sign-in?${signInParams.toString()}` : "/sign-in";
74+
75+ const handleDeleteAccount = async (event: FormEvent<HTMLFormElement>) => {
76+ event.preventDefault();
77+ if (!canDeleteAccount || deletingAccount) {
78+ return;
79+ }
80+
81+ setDeletingAccount(true);
82+ setDeleteError(null);
83+ try {
84+ await deleteCurrentAccountMutation({
85+ sessionId: context?.sessionId ?? undefined,
86+ });
87+ await resetWorkspace();
88+ window.location.assign(workosEnabled ? "/sign-out" : "/");
89+ } catch (cause) {
90+ const message = cause instanceof Error ? cause.message : "Failed to delete account";
91+ setDeleteError(message);
92+ setDeletingAccount(false);
93+ }
94+ };
95+
96+ if (loading) {
97+ return (
98+ <div className="border-t border-border px-3 py-2">
99+ <span className="text-[11px] font-mono text-muted-foreground">Loading session...</span>
100+ </div>
101+ );
102+ }
103+
104+ return (
105+ <div className="border-t border-border">
106+ {isSignedInToWorkos ? (
107+ <>
108+ <DropdownMenu>
109+ <DropdownMenuTrigger asChild>
110+ <Button
111+ variant="ghost"
112+ className="h-14 w-full justify-between rounded-none border-0 bg-transparent px-3 py-0 text-left shadow-none hover:bg-accent/40"
113+ >
114+ <span className="flex items-center gap-2 min-w-0">
115+ {avatarUrl ? (
116+ <Image
117+ src={avatarUrl}
118+ alt={avatarLabel}
119+ width={24}
120+ height={24}
121+ className="h-6 w-6 rounded-full border border-border object-cover"
122+ unoptimized
123+ />
124+ ) : (
125+ <span className="h-6 w-6 rounded-full border border-border bg-muted text-[10px] font-mono text-muted-foreground flex items-center justify-center">
126+ {avatarInitial}
127+ </span>
128+ )}
129+ <span className="min-w-0">
130+ <span className="text-[11px] font-medium truncate block">{avatarLabel}</span>
131+ {workosProfile?.email ? (
132+ <span className="text-[10px] text-muted-foreground truncate block">{workosProfile.email}</span>
133+ ) : null}
134+ </span>
135+ </span>
136+ <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
137+ </Button>
138+ </DropdownMenuTrigger>
139+ <DropdownMenuContent align="end" className="w-64">
140+ <DropdownMenuLabel className="text-xs">
141+ Account
142+ </DropdownMenuLabel>
143+ <DropdownMenuSeparator />
144+ <DropdownMenuItem
145+ className="text-xs"
146+ onSelect={(event) => {
147+ event.preventDefault();
148+ setDeleteConfirmText("");
149+ setDeleteError(null);
150+ setAccountSettingsOpen(true);
151+ }}
152+ >
153+ Account Settings
154+ </DropdownMenuItem>
155+ <DropdownMenuItem asChild className="text-xs">
156+ <Link to="/sign-out" reloadDocument>Sign out</Link>
157+ </DropdownMenuItem>
158+ </DropdownMenuContent>
159+ </DropdownMenu>
160+
161+ <Dialog
162+ open={accountSettingsOpen}
163+ onOpenChange={(open) => {
164+ setAccountSettingsOpen(open);
165+ if (!open) {
166+ setDeleteConfirmText("");
167+ setDeleteError(null);
168+ setDeletingAccount(false);
169+ }
170+ }}
171+ >
172+ <DialogContent className="sm:max-w-md">
173+ <form className="space-y-4" onSubmit={handleDeleteAccount}>
174+ <DialogHeader>
175+ <DialogTitle>Account Settings</DialogTitle>
176+ <DialogDescription>
177+ Deleting your account will remove organizations, workspaces, and data you created.
178+ This cannot be undone.
179+ </DialogDescription>
180+ </DialogHeader>
181+ <div className="space-y-2">
182+ <label htmlFor="delete-account-confirm" className="text-xs font-medium text-foreground">
183+ Type DELETE to confirm
184+ </label>
185+ <Input
186+ id="delete-account-confirm"
187+ value={deleteConfirmText}
188+ onChange={(event) => setDeleteConfirmText(event.target.value)}
189+ autoComplete="off"
190+ spellCheck={false}
191+ />
192+ {deleteError ? (
193+ <p className="text-xs text-destructive">{deleteError}</p>
194+ ) : null}
195+ </div>
196+ <DialogFooter>
197+ <Button
198+ type="button"
199+ variant="outline"
200+ onClick={() => setAccountSettingsOpen(false)}
201+ disabled={deletingAccount}
202+ >
203+ Cancel
204+ </Button>
205+ <Button
206+ type="submit"
207+ variant="destructive"
208+ disabled={!canDeleteAccount || deletingAccount}
209+ >
210+ {deletingAccount ? "Deleting..." : "Delete account"}
211+ </Button>
212+ </DialogFooter>
213+ </form>
214+ </DialogContent>
215+ </Dialog>
216+ </>
217+ ) : (
218+ <div className="px-3 py-3 space-y-2">
219+ {workosEnabled ? (
220+ <Link to={signInHref} reloadDocument className="block">
221+ <Button
222+ variant="outline"
223+ className="w-full h-9 justify-center gap-2 text-xs font-medium"
224+ >
225+ <LogIn className="h-3.5 w-3.5" />
226+ Sign in
227+ </Button>
228+ </Link>
229+ ) : (
230+ <p className="text-[11px] text-muted-foreground text-center">Guest mode</p>
231+ )}
232+ </div>
233+ )}
234+
235+ </div>
236+ );
237+}
executor/apps/web/src/components/task-composer.tsx+161−0
@@ -0,0 +1,161 @@
1+"use client";
2+
3+import { useState } from "react";
4+import { Play, Send } from "lucide-react";
5+import { useMutation, useQuery } from "convex/react";
6+import { toast } from "sonner";
7+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8+import { Button } from "@/components/ui/button";
9+import { Input } from "@/components/ui/input";
10+import { Label } from "@/components/ui/label";
11+import {
12+ Select,
13+ SelectContent,
14+ SelectItem,
15+ SelectTrigger,
16+ SelectValue,
17+} from "@/components/ui/select";
18+import { CodeEditor } from "@/components/code-editor";
19+import { convexApi } from "@/lib/convex-api";
20+import { useSession } from "@/lib/session-context";
21+import type { RuntimeTargetDescriptor } from "@/lib/types";
22+import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
23+
24+const DEFAULT_CODE = `// Example: call some tools and return a compact result
25+const time = await tools.utils.get_time();
26+const sum = await tools.math.add({ a: 7, b: 35 });
27+
28+// This will require approval:
29+await tools.admin.send_announcement({
30+ channel: "general",
31+ message: "Hello from executor!"
32+});
33+
34+return {
35+ isoTime: time.iso,
36+ total: sum.result,
37+};`;
38+const DEFAULT_TIMEOUT_MS = 300_000;
39+
40+export function TaskComposer() {
41+ const { context } = useSession();
42+ const [code, setCode] = useState(DEFAULT_CODE);
43+ const [runtimeId, setRuntimeId] = useState("local-bun");
44+ const [timeoutMs, setTimeoutMs] = useState(String(DEFAULT_TIMEOUT_MS));
45+ const [submitting, setSubmitting] = useState(false);
46+
47+ const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {});
48+ const createTask = useMutation(convexApi.executor.createTask);
49+ const { tools, dtsUrls, loadingTools, loadingTypes } = useWorkspaceTools(context ?? null);
50+
51+ const handleSubmit = async () => {
52+ if (!context || !code.trim()) return;
53+ setSubmitting(true);
54+ try {
55+ const data = await createTask({
56+ code,
57+ runtimeId,
58+ timeoutMs: Number.parseInt(timeoutMs, 10) || DEFAULT_TIMEOUT_MS,
59+ workspaceId: context.workspaceId,
60+ sessionId: context.sessionId,
61+ clientId: context.clientId,
62+ });
63+ toast.success(`Task created: ${data.task.id}`);
64+ } catch (err) {
65+ toast.error(
66+ err instanceof Error ? err.message : "Failed to create task",
67+ );
68+ } finally {
69+ setSubmitting(false);
70+ }
71+ };
72+
73+ return (
74+ <Card className="bg-card border-border">
75+ <CardHeader className="pb-3">
76+ <CardTitle className="text-sm font-medium flex items-center gap-2">
77+ <Play className="h-4 w-4 text-terminal-green" />
78+ New Task
79+ </CardTitle>
80+ </CardHeader>
81+ <CardContent className="space-y-4">
82+ <div className="grid grid-cols-2 gap-3">
83+ <div className="space-y-1.5">
84+ <Label className="text-xs text-muted-foreground">Runtime</Label>
85+ <Select value={runtimeId} onValueChange={setRuntimeId}>
86+ <SelectTrigger className="h-8 text-xs font-mono bg-background">
87+ <SelectValue />
88+ </SelectTrigger>
89+ <SelectContent>
90+ {(runtimes ?? []).map((r: RuntimeTargetDescriptor) => (
91+ <SelectItem key={r.id} value={r.id} className="text-xs">
92+ {r.label}
93+ </SelectItem>
94+ ))}
95+ </SelectContent>
96+ </Select>
97+ </div>
98+ <div className="space-y-1.5">
99+ <Label className="text-xs text-muted-foreground">
100+ Timeout (ms)
101+ </Label>
102+ <Input
103+ type="number"
104+ value={timeoutMs}
105+ onChange={(e) => setTimeoutMs(e.target.value)}
106+ className="h-8 text-xs font-mono bg-background"
107+ />
108+ </div>
109+ </div>
110+ <div className="space-y-1.5">
111+ <div className="flex items-center justify-between gap-2">
112+ <Label className="text-xs text-muted-foreground">Code</Label>
113+ <span className="text-[10px] font-mono text-muted-foreground">
114+ {loadingTools
115+ ? "Loading tool inventory..."
116+ : loadingTypes
117+ ? `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded, type defs warming...`
118+ : `${tools.length} tool${tools.length === 1 ? "" : "s"} ready`}
119+ </span>
120+ </div>
121+ {!loadingTools && loadingTypes && tools.length > 0 && (
122+ <div className="flex flex-wrap gap-1.5 rounded-md border border-border/50 bg-muted/20 px-2 py-1.5">
123+ {tools.slice(0, 8).map((tool) => (
124+ <span
125+ key={tool.path}
126+ className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground"
127+ >
128+ {tool.path}
129+ </span>
130+ ))}
131+ {tools.length > 8 && (
132+ <span className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
133+ +{tools.length - 8} more
134+ </span>
135+ )}
136+ </div>
137+ )}
138+ <div className="rounded-md border border-border">
139+ <CodeEditor
140+ value={code}
141+ onChange={setCode}
142+ tools={tools}
143+ dtsUrls={dtsUrls}
144+ typesLoading={loadingTypes}
145+ height="400px"
146+ />
147+ </div>
148+ </div>
149+ <Button
150+ onClick={handleSubmit}
151+ disabled={submitting || !code.trim()}
152+ className="w-full bg-primary text-primary-foreground hover:bg-primary/90 h-9"
153+ size="sm"
154+ >
155+ <Send className="h-3.5 w-3.5 mr-2" />
156+ {submitting ? "Creating..." : "Execute Task"}
157+ </Button>
158+ </CardContent>
159+ </Card>
160+ );
161+}
executor/apps/web/src/components/task-detail.tsx+255−0
@@ -0,0 +1,255 @@
1+"use client";
2+
3+import { useState } from "react";
4+import { X, ShieldX, CheckCircle2 } from "lucide-react";
5+import { useMutation, useQuery } from "convex/react";
6+import type { Id } from "@executor/convex/_generated/dataModel";
7+import { toast } from "sonner";
8+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9+import { Button } from "@/components/ui/button";
10+import { Separator } from "@/components/ui/separator";
11+import { TaskStatusBadge } from "@/components/status-badge";
12+import { FormattedCodeBlock } from "@/components/formatted-code-block";
13+import { convexApi } from "@/lib/convex-api";
14+import { formatApprovalInput } from "@/lib/approval-input-format";
15+import { cn } from "@/lib/utils";
16+import type { TaskRecord, PendingApprovalRecord } from "@/lib/types";
17+
18+function formatDate(ts: number) {
19+ return new Date(ts).toLocaleDateString([], {
20+ month: "short",
21+ day: "numeric",
22+ hour: "2-digit",
23+ minute: "2-digit",
24+ });
25+}
26+
27+function formatResult(result: unknown): string {
28+ if (result === undefined) {
29+ return "";
30+ }
31+
32+ try {
33+ return JSON.stringify(result, null, 2);
34+ } catch {
35+ return String(result);
36+ }
37+}
38+
39+export function TaskDetail({
40+ task,
41+ workspaceId,
42+ sessionId,
43+ pendingApprovals,
44+ onClose,
45+}: {
46+ task: TaskRecord;
47+ workspaceId: Id<"workspaces">;
48+ sessionId?: string;
49+ pendingApprovals: PendingApprovalRecord[];
50+ onClose: () => void;
51+}) {
52+ const resolveApproval = useMutation(convexApi.executor.resolveApproval);
53+ const [resolvingApprovalId, setResolvingApprovalId] = useState<string | null>(null);
54+ const liveTaskData = useQuery(
55+ convexApi.workspace.getTaskInWorkspace,
56+ workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip",
57+ );
58+
59+ const liveTask = liveTaskData ?? task;
60+ const liveResult = formatResult(liveTask.result);
61+
62+ const duration =
63+ liveTask.completedAt && liveTask.startedAt
64+ ? `${((liveTask.completedAt - liveTask.startedAt) / 1000).toFixed(2)}s`
65+ : liveTask.startedAt
66+ ? "running..."
67+ : "—";
68+
69+ const handleResolveApproval = async (
70+ approvalId: string,
71+ decision: "approved" | "denied",
72+ toolPath: string,
73+ ) => {
74+ setResolvingApprovalId(approvalId);
75+ try {
76+ await resolveApproval({
77+ workspaceId,
78+ sessionId,
79+ approvalId,
80+ decision,
81+ });
82+ toast.success(
83+ decision === "approved"
84+ ? `Approved: ${toolPath}`
85+ : `Denied: ${toolPath}`,
86+ );
87+ } catch (error) {
88+ toast.error(error instanceof Error ? error.message : "Failed to resolve approval");
89+ } finally {
90+ setResolvingApprovalId(null);
91+ }
92+ };
93+
94+ return (
95+ <Card className="bg-card border-border">
96+ <CardHeader className="pb-3">
97+ <div className="flex items-center justify-between">
98+ <CardTitle className="text-sm font-medium font-mono truncate pr-4">
99+ {liveTask.id}
100+ </CardTitle>
101+ <Button
102+ variant="ghost"
103+ size="icon"
104+ className="h-7 w-7 shrink-0"
105+ onClick={onClose}
106+ >
107+ <X className="h-4 w-4" />
108+ </Button>
109+ </div>
110+ </CardHeader>
111+ <CardContent className="space-y-4">
112+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
113+ {[
114+ { label: "Status", value: <TaskStatusBadge status={liveTask.status} /> },
115+ { label: "Runtime", value: <span className="font-mono text-xs">{liveTask.runtimeId}</span> },
116+ { label: "Duration", value: <span className="font-mono text-xs">{duration}</span> },
117+ {
118+ label: "Exit Code",
119+ value: (
120+ <span className={cn("font-mono text-xs", liveTask.exitCode === 0 ? "text-terminal-green" : liveTask.exitCode ? "text-terminal-red" : "text-muted-foreground")}>
121+ {liveTask.exitCode ?? "—"}
122+ </span>
123+ ),
124+ },
125+ ].map((item) => (
126+ <div key={item.label}>
127+ <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1">
128+ {item.label}
129+ </span>
130+ {item.value}
131+ </div>
132+ ))}
133+ </div>
134+
135+ {pendingApprovals.length > 0 ? (
136+ <>
137+ <Separator />
138+ <div className="space-y-2.5">
139+ <div className="flex items-center justify-between">
140+ <span className="text-[10px] uppercase tracking-widest text-terminal-amber">
141+ Pending approvals
142+ </span>
143+ <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded">
144+ {pendingApprovals.length}
145+ </span>
146+ </div>
147+ {pendingApprovals.map((approval) => {
148+ const input = formatApprovalInput(approval.input);
149+ const resolving = resolvingApprovalId === approval.id;
150+ return (
151+ <div
152+ key={approval.id}
153+ className="rounded-md border border-terminal-amber/30 bg-terminal-amber/6 p-3 space-y-2"
154+ >
155+ <div className="flex items-center justify-between gap-3">
156+ <div className="min-w-0">
157+ <p className="text-xs font-mono text-foreground truncate">
158+ {approval.toolPath}
159+ </p>
160+ <p className="text-[10px] text-muted-foreground">
161+ Requested {formatDate(approval.createdAt)}
162+ </p>
163+ </div>
164+ <div className="flex items-center gap-1.5 shrink-0">
165+ <Button
166+ variant="outline"
167+ size="sm"
168+ className="h-7 text-[11px] border-terminal-green/30 text-terminal-green hover:bg-terminal-green/10"
169+ disabled={resolvingApprovalId !== null}
170+ onClick={() =>
171+ void handleResolveApproval(
172+ approval.id,
173+ "approved",
174+ approval.toolPath,
175+ )
176+ }
177+ >
178+ <CheckCircle2 className="h-3 w-3 mr-1" />
179+ {resolving ? "Approving..." : "Approve"}
180+ </Button>
181+ <Button
182+ variant="outline"
183+ size="sm"
184+ className="h-7 text-[11px] border-terminal-red/30 text-terminal-red hover:bg-terminal-red/10"
185+ disabled={resolvingApprovalId !== null}
186+ onClick={() =>
187+ void handleResolveApproval(
188+ approval.id,
189+ "denied",
190+ approval.toolPath,
191+ )
192+ }
193+ >
194+ <ShieldX className="h-3 w-3 mr-1" />
195+ {resolving ? "Denying..." : "Deny"}
196+ </Button>
197+ </div>
198+ </div>
199+ {input ? (
200+ <FormattedCodeBlock
201+ content={input.content}
202+ language={input.language}
203+ className="max-h-40 overflow-y-auto"
204+ />
205+ ) : null}
206+ </div>
207+ );
208+ })}
209+ </div>
210+ </>
211+ ) : null}
212+
213+ <Separator />
214+
215+ <div>
216+ <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2">
217+ Code
218+ </span>
219+ <FormattedCodeBlock
220+ content={liveTask.code}
221+ language="typescript"
222+ className="max-h-48 overflow-y-auto"
223+ />
224+ </div>
225+
226+ {liveResult && (
227+ <div>
228+ <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
229+ Result
230+ </span>
231+ <FormattedCodeBlock
232+ content={liveResult}
233+ language="json"
234+ className="max-h-48 overflow-y-auto"
235+ />
236+ </div>
237+ )}
238+
239+ {liveTask.error && (
240+ <div>
241+ <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">
242+ Error
243+ </span>
244+ <FormattedCodeBlock
245+ content={liveTask.error}
246+ language="text"
247+ tone="red"
248+ className="max-h-48 overflow-y-auto"
249+ />
250+ </div>
251+ )}
252+ </CardContent>
253+ </Card>
254+ );
255+}
executor/apps/web/src/components/task-list-item.tsx+53−0
@@ -0,0 +1,53 @@
1+"use client";
2+
3+import { ChevronRight } from "lucide-react";
4+import { TaskStatusBadge } from "@/components/status-badge";
5+import type { TaskRecord } from "@/lib/types";
6+import { cn } from "@/lib/utils";
7+
8+function formatDate(ts: number) {
9+ return new Date(ts).toLocaleDateString([], {
10+ month: "short",
11+ day: "numeric",
12+ hour: "2-digit",
13+ minute: "2-digit",
14+ });
15+}
16+
17+type TaskListItemProps = {
18+ task: TaskRecord;
19+ selected: boolean;
20+ onClick: () => void;
21+};
22+
23+export function TaskListItem({ task, selected, onClick }: TaskListItemProps) {
24+ return (
25+ <button
26+ onClick={onClick}
27+ className={cn(
28+ "w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-left group",
29+ selected
30+ ? "bg-primary/10 border border-primary/20"
31+ : "hover:bg-accent/50 border border-transparent",
32+ )}
33+ >
34+ <div className="flex-1 min-w-0">
35+ <div className="flex items-center gap-2">
36+ <span className="text-xs font-mono text-foreground truncate">
37+ {task.id}
38+ </span>
39+ </div>
40+ <div className="flex items-center gap-2 mt-0.5">
41+ <span className="text-[10px] font-mono text-muted-foreground">
42+ {task.runtimeId}
43+ </span>
44+ <span className="text-[10px] text-muted-foreground">
45+ {formatDate(task.createdAt)}
46+ </span>
47+ </div>
48+ </div>
49+ <TaskStatusBadge status={task.status} />
50+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
51+ </button>
52+ );
53+}
executor/apps/web/src/components/tasks-view.tsx+10−508
@@ -2,521 +2,23 @@ · 1 unmodified line
22
33 import { useState, useCallback } from "react";
44 import { useNavigate, useSearchParams } from "react-router";
5−import {
6− Play,
7− ChevronRight,
8− X,
9− Send,
10− ShieldCheck,
11− ShieldX,
12− CheckCircle2,
13−} from "lucide-react";
5+import { Play, ShieldCheck } from "lucide-react";
146 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
157 import { Button } from "@/components/ui/button";
16−import { Input } from "@/components/ui/input";
17−import { Label } from "@/components/ui/label";
18−import { CodeEditor } from "@/components/code-editor";
19−import {
20− Select,
21− SelectContent,
22− SelectItem,
23− SelectTrigger,
24− SelectValue,
25−} from "@/components/ui/select";
268 import { Skeleton } from "@/components/ui/skeleton";
27−import { Separator } from "@/components/ui/separator";
289 import { PageHeader } from "@/components/page-header";
2910 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
30−import { TaskStatusBadge } from "@/components/status-badge";
31−import { FormattedCodeBlock } from "@/components/formatted-code-block";
11+import { TaskDetail } from "@/components/task-detail";
12+import { TaskComposer } from "@/components/task-composer";
13+import { TaskListItem } from "@/components/task-list-item";
3214 import { useSession } from "@/lib/session-context";
33−import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
34−import { useMutation, useQuery } from "convex/react";
15+import { useQuery } from "convex/react";
3516 import { convexApi } from "@/lib/convex-api";
17+import { workspaceQueryArgs } from "@/lib/workspace-query-args";
3618 import type {
37− RuntimeTargetDescriptor,
3819 TaskRecord,
3920 PendingApprovalRecord,
4021 } from "@/lib/types";
41−import type { Id } from "@executor/convex/_generated/dataModel";
42−import { toast } from "sonner";
43−import { cn } from "@/lib/utils";
44−
45−const DEFAULT_CODE = `// Example: call some tools and return a compact result
46−const time = await tools.utils.get_time();
47−const sum = await tools.math.add({ a: 7, b: 35 });
48−
49−// This will require approval:
50−await tools.admin.send_announcement({
51− channel: "general",
52− message: "Hello from executor!"
53−});
54−
55−return {
56− isoTime: time.iso,
57− total: sum.result,
58−};`;
59−const DEFAULT_TIMEOUT_MS = 300_000;
60−
61−function formatDate(ts: number) {
62− return new Date(ts).toLocaleDateString([], {
63− month: "short",
64− day: "numeric",
65− hour: "2-digit",
66− minute: "2-digit",
67− });
68−}
69−
70−function formatResult(result: unknown): string {
71− if (result === undefined) {
72− return "";
73− }
74−
75− try {
76− return JSON.stringify(result, null, 2);
77− } catch {
78− return String(result);
79− }
80−}
81−
82−function formatApprovalInput(
83− input: unknown,
84−): {
85− content: string;
86− language: "json" | "text";
87−} | null {
88− if (input === null || input === undefined) {
89− return null;
90− }
91−
92− if (typeof input === "string") {
93− const trimmed = input.trim();
94− if (!trimmed) {
95− return null;
96− }
97−
98− try {
99− return {
100− content: JSON.stringify(JSON.parse(trimmed), null, 2),
101− language: "json",
102− };
103− } catch {
104− return {
105− content: trimmed,
106− language: "text",
107− };
108− }
109− }
110−
111− try {
112− return {
113− content: JSON.stringify(input, null, 2),
114− language: "json",
115− };
116− } catch {
117− const fallback = String(input).trim();
118− if (!fallback) {
119− return null;
120− }
121−
122− return {
123− content: fallback,
124− language: "text",
125− };
126− }
127−}
128−
129−// ── Task Composer ──
130−
131−function TaskComposer() {
132− const { context } = useSession();
133− const [code, setCode] = useState(DEFAULT_CODE);
134− const [runtimeId, setRuntimeId] = useState("local-bun");
135− const [timeoutMs, setTimeoutMs] = useState(String(DEFAULT_TIMEOUT_MS));
136− const [submitting, setSubmitting] = useState(false);
137−
138− const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {});
139− const createTask = useMutation(convexApi.executor.createTask);
140− const { tools, dtsUrls, loadingTools, loadingTypes } = useWorkspaceTools(context ?? null);
141−
142− const handleSubmit = async () => {
143− if (!context || !code.trim()) return;
144− setSubmitting(true);
145− try {
146− const data = await createTask({
147− code,
148− runtimeId,
149− timeoutMs: Number.parseInt(timeoutMs, 10) || DEFAULT_TIMEOUT_MS,
150− workspaceId: context.workspaceId,
151− sessionId: context.sessionId,
152− clientId: context.clientId,
153− });
154− toast.success(`Task created: ${data.task.id}`);
155− } catch (err) {
156− toast.error(
157− err instanceof Error ? err.message : "Failed to create task",
158− );
159− } finally {
160− setSubmitting(false);
161− }
162− };
163−
164− return (
165− <Card className="bg-card border-border">
166− <CardHeader className="pb-3">
167− <CardTitle className="text-sm font-medium flex items-center gap-2">
168− <Play className="h-4 w-4 text-terminal-green" />
169− New Task
170− </CardTitle>
171− </CardHeader>
172− <CardContent className="space-y-4">
173− <div className="grid grid-cols-2 gap-3">
174− <div className="space-y-1.5">
175− <Label className="text-xs text-muted-foreground">Runtime</Label>
176− <Select value={runtimeId} onValueChange={setRuntimeId}>
177− <SelectTrigger className="h-8 text-xs font-mono bg-background">
178− <SelectValue />
179− </SelectTrigger>
180− <SelectContent>
181− {(runtimes ?? []).map((r: RuntimeTargetDescriptor) => (
182− <SelectItem key={r.id} value={r.id} className="text-xs">
183− {r.label}
184− </SelectItem>
185− ))}
186− </SelectContent>
187− </Select>
188− </div>
189− <div className="space-y-1.5">
190− <Label className="text-xs text-muted-foreground">
191− Timeout (ms)
192− </Label>
193− <Input
194− type="number"
195− value={timeoutMs}
196− onChange={(e) => setTimeoutMs(e.target.value)}
197− className="h-8 text-xs font-mono bg-background"
198− />
199− </div>
200− </div>
201− <div className="space-y-1.5">
202− <div className="flex items-center justify-between gap-2">
203− <Label className="text-xs text-muted-foreground">Code</Label>
204− <span className="text-[10px] font-mono text-muted-foreground">
205− {loadingTools
206− ? "Loading tool inventory..."
207− : loadingTypes
208− ? `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded, type defs warming...`
209− : `${tools.length} tool${tools.length === 1 ? "" : "s"} ready`}
210− </span>
211− </div>
212− {!loadingTools && loadingTypes && tools.length > 0 && (
213− <div className="flex flex-wrap gap-1.5 rounded-md border border-border/50 bg-muted/20 px-2 py-1.5">
214− {tools.slice(0, 8).map((tool) => (
215− <span
216− key={tool.path}
217− className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground"
218− >
219− {tool.path}
220− </span>
221− ))}
222− {tools.length > 8 && (
223− <span className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
224− +{tools.length - 8} more
225− </span>
226− )}
227− </div>
228− )}
229− <div className="rounded-md border border-border">
230− <CodeEditor
231− value={code}
232− onChange={setCode}
233− tools={tools}
234− dtsUrls={dtsUrls}
235− typesLoading={loadingTypes}
236− height="400px"
237− />
238− </div>
239− </div>
240− <Button
241− onClick={handleSubmit}
242− disabled={submitting || !code.trim()}
243− className="w-full bg-primary text-primary-foreground hover:bg-primary/90 h-9"
244− size="sm"
245− >
246− <Send className="h-3.5 w-3.5 mr-2" />
247− {submitting ? "Creating..." : "Execute Task"}
248− </Button>
249− </CardContent>
250− </Card>
251− );
252−}
253−
254−// ── Task List ──
255−
256−function TaskListItem({
257− task,
258− selected,
259− onClick,
260−}: {
261− task: TaskRecord;
262− selected: boolean;
263− onClick: () => void;
264−}) {
265− return (
266− <button
267− onClick={onClick}
268− className={cn(
269− "w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-left group",
270− selected
271− ? "bg-primary/10 border border-primary/20"
272− : "hover:bg-accent/50 border border-transparent",
273− )}
274− >
275− <div className="flex-1 min-w-0">
276− <div className="flex items-center gap-2">
277− <span className="text-xs font-mono text-foreground truncate">
278− {task.id}
279− </span>
280− </div>
281− <div className="flex items-center gap-2 mt-0.5">
282− <span className="text-[10px] font-mono text-muted-foreground">
283− {task.runtimeId}
284− </span>
285− <span className="text-[10px] text-muted-foreground">
286− {formatDate(task.createdAt)}
287− </span>
288− </div>
289− </div>
290− <TaskStatusBadge status={task.status} />
291− <ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
292− </button>
293− );
294−}
295−
296−// ── Task Detail ──
297−
298−function TaskDetail({
299− task,
300− workspaceId,
301− sessionId,
302− pendingApprovals,
303− onClose,
304−}: {
305− task: TaskRecord;
306− workspaceId: Id<"workspaces">;
307− sessionId?: string;
308− pendingApprovals: PendingApprovalRecord[];
309− onClose: () => void;
310−}) {
311− const resolveApproval = useMutation(convexApi.executor.resolveApproval);
312− const [resolvingApprovalId, setResolvingApprovalId] = useState<string | null>(null);
313− const liveTaskData = useQuery(
314− convexApi.workspace.getTaskInWorkspace,
315− workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip",
316− );
317−
318− const liveTask = liveTaskData ?? task;
319− const liveResult = formatResult(liveTask.result);
320−
321− const duration =
322− liveTask.completedAt && liveTask.startedAt
323− ? `${((liveTask.completedAt - liveTask.startedAt) / 1000).toFixed(2)}s`
324− : liveTask.startedAt
325− ? "running..."
326− : "—";
327−
328− const handleResolveApproval = async (
329− approvalId: string,
330− decision: "approved" | "denied",
331− toolPath: string,
332− ) => {
333− setResolvingApprovalId(approvalId);
334− try {
335− await resolveApproval({
336− workspaceId,
337− sessionId,
338− approvalId,
339− decision,
340− });
341− toast.success(
342− decision === "approved"
343− ? `Approved: ${toolPath}`
344− : `Denied: ${toolPath}`,
345− );
346− } catch (error) {
347− toast.error(error instanceof Error ? error.message : "Failed to resolve approval");
348− } finally {
349− setResolvingApprovalId(null);
350− }
351− };
352−
353− return (
354− <Card className="bg-card border-border">
355− <CardHeader className="pb-3">
356− <div className="flex items-center justify-between">
357− <CardTitle className="text-sm font-medium font-mono truncate pr-4">
358− {liveTask.id}
359− </CardTitle>
360− <Button
361− variant="ghost"
362− size="icon"
363− className="h-7 w-7 shrink-0"
364− onClick={onClose}
365− >
366− <X className="h-4 w-4" />
367− </Button>
368− </div>
369− </CardHeader>
370− <CardContent className="space-y-4">
371− {/* Metadata grid */}
372− <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
373− {[
374− { label: "Status", value: <TaskStatusBadge status={liveTask.status} /> },
375− { label: "Runtime", value: <span className="font-mono text-xs">{liveTask.runtimeId}</span> },
376− { label: "Duration", value: <span className="font-mono text-xs">{duration}</span> },
377− {
378− label: "Exit Code",
379− value: (
380− <span className={cn("font-mono text-xs", liveTask.exitCode === 0 ? "text-terminal-green" : liveTask.exitCode ? "text-terminal-red" : "text-muted-foreground")}>
381− {liveTask.exitCode ?? "—"}
382− </span>
383− ),
384− },
385− ].map((item) => (
386− <div key={item.label}>
387− <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1">
388− {item.label}
389− </span>
390− {item.value}
391− </div>
392− ))}
393− </div>
394−
395− {pendingApprovals.length > 0 ? (
396− <>
397− <Separator />
398− <div className="space-y-2.5">
399− <div className="flex items-center justify-between">
400− <span className="text-[10px] uppercase tracking-widest text-terminal-amber">
401− Pending approvals
402− </span>
403− <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded">
404− {pendingApprovals.length}
405− </span>
406− </div>
407− {pendingApprovals.map((approval) => {
408− const input = formatApprovalInput(approval.input);
409− const resolving = resolvingApprovalId === approval.id;
410− return (
411− <div
412− key={approval.id}
413− className="rounded-md border border-terminal-amber/30 bg-terminal-amber/6 p-3 space-y-2"
414− >
415− <div className="flex items-center justify-between gap-3">
416− <div className="min-w-0">
417− <p className="text-xs font-mono text-foreground truncate">
418− {approval.toolPath}
419− </p>
420− <p className="text-[10px] text-muted-foreground">
421− Requested {formatDate(approval.createdAt)}
422− </p>
423− </div>
424− <div className="flex items-center gap-1.5 shrink-0">
425− <Button
426− variant="outline"
427− size="sm"
428− className="h-7 text-[11px] border-terminal-green/30 text-terminal-green hover:bg-terminal-green/10"
429− disabled={resolvingApprovalId !== null}
430− onClick={() =>
431− void handleResolveApproval(
432− approval.id,
433− "approved",
434− approval.toolPath,
435− )
436− }
437− >
438− <CheckCircle2 className="h-3 w-3 mr-1" />
439− {resolving ? "Approving..." : "Approve"}
440− </Button>
441− <Button
442− variant="outline"
443− size="sm"
444− className="h-7 text-[11px] border-terminal-red/30 text-terminal-red hover:bg-terminal-red/10"
445− disabled={resolvingApprovalId !== null}
446− onClick={() =>
447− void handleResolveApproval(
448− approval.id,
449− "denied",
450− approval.toolPath,
451− )
452− }
453− >
454− <ShieldX className="h-3 w-3 mr-1" />
455− {resolving ? "Denying..." : "Deny"}
456− </Button>
457− </div>
458− </div>
459− {input ? (
460− <FormattedCodeBlock
461− content={input.content}
462− language={input.language}
463− className="max-h-40 overflow-y-auto"
464− />
465− ) : null}
466− </div>
467− );
468− })}
469− </div>
470− </>
471− ) : null}
472−
473− <Separator />
474−
475− {/* Code */}
476− <div>
477− <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2">
478− Code
479− </span>
480− <FormattedCodeBlock
481− content={liveTask.code}
482− language="typescript"
483− className="max-h-48 overflow-y-auto"
484− />
485− </div>
486−
487− {/* Result */}
488− {liveResult && (
489− <div>
490− <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
491− Result
492− </span>
493− <FormattedCodeBlock
494− content={liveResult}
495− language="json"
496− className="max-h-48 overflow-y-auto"
497− />
498− </div>
499− )}
500−
501− {/* Error */}
502− {liveTask.error && (
503− <div>
504− <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">
505− Error
506− </span>
507− <FormattedCodeBlock
508− content={liveTask.error}
509− language="text"
510− tone="red"
511− className="max-h-48 overflow-y-auto"
512− />
513− </div>
514− )}
515− </CardContent>
516− </Card>
517− );
518−}
519−
52022 // ── Tasks View ──
52123
52224 export function TasksView() {
@@ -528,14 +30,14 @@ export function TasksView() { · 5 unmodified lines
52830
52931 const tasks = useQuery(
53032 convexApi.workspace.listTasks,
531− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
33+ workspaceQueryArgs(context),
53234 );
53335 const tasksLoading = !!context && tasks === undefined;
53436 const taskItems = tasks ?? [];
53537
53638 const approvals = useQuery(
53739 convexApi.workspace.listPendingApprovals,
538− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
40+ workspaceQueryArgs(context),
53941 );
54042 const pendingApprovals = approvals ?? [];
54143
@@ -631,10 +133,10 @@ export function TasksView() { · 89 unmodified lines
631133 </Card>
632134
633135 <div>
634− {selectedTask ? (
136+ {selectedTask && context ? (
635137 <TaskDetail
636138 task={selectedTask}
637− workspaceId={context!.workspaceId}
139+ workspaceId={context.workspaceId}
638140 sessionId={context?.sessionId}
639141 pendingApprovals={selectedTaskApprovals}
640142 onClose={() => selectTask(null)}
executor/apps/web/src/components/tool-explorer-copy-button.tsx+55−0
@@ -0,0 +1,55 @@
1+"use client";
2+
3+import { useCallback, useState } from "react";
4+import { Check, Copy } from "lucide-react";
5+import {
6+ Tooltip,
7+ TooltipContent,
8+ TooltipProvider,
9+ TooltipTrigger,
10+} from "@/components/ui/tooltip";
11+import { cn } from "@/lib/utils";
12+
13+interface CopyButtonProps {
14+ text: string;
15+ className?: string;
16+}
17+
18+export function CopyButton({ text, className }: CopyButtonProps) {
19+ const [copied, setCopied] = useState(false);
20+
21+ const handleCopy = useCallback(
22+ (event: React.MouseEvent) => {
23+ event.stopPropagation();
24+ navigator.clipboard.writeText(text);
25+ setCopied(true);
26+ setTimeout(() => setCopied(false), 1500);
27+ },
28+ [text],
29+ );
30+
31+ return (
32+ <TooltipProvider delayDuration={200}>
33+ <Tooltip>
34+ <TooltipTrigger asChild>
35+ <button
36+ onClick={handleCopy}
37+ className={cn(
38+ "h-5 w-5 rounded flex items-center justify-center text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted transition-colors shrink-0",
39+ className,
40+ )}
41+ >
42+ {copied ? (
43+ <Check className="h-3 w-3 text-terminal-green" />
44+ ) : (
45+ <Copy className="h-3 w-3" />
46+ )}
47+ </button>
48+ </TooltipTrigger>
49+ <TooltipContent side="left" className="text-[11px]">
50+ Copy path
51+ </TooltipContent>
52+ </Tooltip>
53+ </TooltipProvider>
54+ );
55+}
executor/apps/web/src/components/tool-explorer-derived.ts+124−0
@@ -0,0 +1,124 @@
1+import {
2+ buildApprovalTree,
3+ buildNamespaceTree,
4+ buildSourceTree,
5+ toolNamespace,
6+ type ToolGroup,
7+} from "@/lib/tool-explorer-grouping";
8+import { sourceLabel } from "@/lib/tool-source-utils";
9+import type { ToolDescriptor } from "@/lib/types";
10+import type { GroupBy, ViewMode } from "./tool-explorer-toolbar";
11+
12+export type FilterApproval = "all" | "required" | "auto";
13+
14+export function expandedKeysForSource(source: string | null): Set<string> {
15+ return source ? new Set([`source:${source}`]) : new Set();
16+}
17+
18+export function filterToolsBySourceAndApproval(
19+ tools: ToolDescriptor[],
20+ activeSource: string | null,
21+ filterApproval: FilterApproval,
22+): ToolDescriptor[] {
23+ let result = tools;
24+
25+ if (activeSource) {
26+ result = result.filter((tool) => sourceLabel(tool.source) === activeSource);
27+ }
28+
29+ if (filterApproval === "required") {
30+ result = result.filter((tool) => tool.approval === "required");
31+ } else if (filterApproval === "auto") {
32+ result = result.filter((tool) => tool.approval !== "required");
33+ }
34+
35+ return result;
36+}
37+
38+export function filterToolsBySearch(
39+ tools: ToolDescriptor[],
40+ search: string,
41+): ToolDescriptor[] {
42+ if (!search) {
43+ return tools;
44+ }
45+
46+ const lowerSearch = search.toLowerCase();
47+ return tools.filter(
48+ (tool) =>
49+ tool.path.toLowerCase().includes(lowerSearch) ||
50+ tool.description.toLowerCase().includes(lowerSearch),
51+ );
52+}
53+
54+export function treeGroupsForView(
55+ tools: ToolDescriptor[],
56+ viewMode: ViewMode,
57+ groupBy: GroupBy,
58+): ToolGroup[] {
59+ if (viewMode === "flat") {
60+ return [];
61+ }
62+
63+ if (groupBy === "source") {
64+ return buildSourceTree(tools);
65+ }
66+
67+ if (groupBy === "namespace") {
68+ return buildNamespaceTree(tools);
69+ }
70+
71+ return buildApprovalTree(tools);
72+}
73+
74+export function flatToolsForView(
75+ tools: ToolDescriptor[],
76+ viewMode: ViewMode,
77+): ToolDescriptor[] {
78+ if (viewMode !== "flat") {
79+ return [];
80+ }
81+
82+ return [...tools].sort((a, b) => a.path.localeCompare(b.path));
83+}
84+
85+export function autoExpandedKeysForSearch(
86+ search: string,
87+ filteredTools: ToolDescriptor[],
88+ viewMode: ViewMode,
89+): Set<string> | null {
90+ if (search.length < 2 || viewMode !== "tree") {
91+ return null;
92+ }
93+
94+ const allGroupKeys = new Set<string>();
95+ const lowerSearch = search.toLowerCase();
96+ const matching = filteredTools.filter(
97+ (tool) =>
98+ tool.path.toLowerCase().includes(lowerSearch) ||
99+ tool.description.toLowerCase().includes(lowerSearch),
100+ );
101+
102+ for (const tool of matching) {
103+ const source = sourceLabel(tool.source);
104+ const namespace = toolNamespace(tool.path);
105+ allGroupKeys.add(`source:${source}`);
106+ allGroupKeys.add(`source:${source}:ns:${namespace}`);
107+ allGroupKeys.add(`ns:${namespace}`);
108+ }
109+
110+ return allGroupKeys;
111+}
112+
113+export function countSelectedTools(
114+ selectedKeys: Set<string>,
115+ filteredTools: ToolDescriptor[],
116+): number {
117+ return Array.from(selectedKeys).filter((key) =>
118+ filteredTools.some((tool) => tool.path === key),
119+ ).length;
120+}
121+
122+export function sourceOptionsFromTools(tools: ToolDescriptor[]): string[] {
123+ return Array.from(new Set(tools.map((tool) => sourceLabel(tool.source))));
124+}
executor/apps/web/src/components/tool-explorer-groups.tsx+238−0
@@ -0,0 +1,238 @@
1+"use client";
2+
3+import { useMemo } from "react";
4+import {
5+ Check,
6+ ChevronDown,
7+ ChevronRight,
8+ Globe,
9+ Layers,
10+ Server,
11+ ShieldCheck,
12+} from "lucide-react";
13+import {
14+ Collapsible,
15+ CollapsibleContent,
16+ CollapsibleTrigger,
17+} from "@/components/ui/collapsible";
18+import { cn } from "@/lib/utils";
19+import { sourceLabel, sourceType } from "@/lib/tool-source-utils";
20+import { toolOperation, type ToolGroup } from "@/lib/tool-explorer-grouping";
21+import type { ToolDescriptor } from "@/lib/types";
22+import { SelectableToolRow } from "./tool-explorer-rows";
23+
24+export function GroupNode({
25+ group,
26+ depth,
27+ expandedKeys,
28+ onToggle,
29+ selectedKeys,
30+ onSelectGroup,
31+ onSelectTool,
32+ search,
33+}: {
34+ group: ToolGroup;
35+ depth: number;
36+ expandedKeys: Set<string>;
37+ onToggle: (key: string) => void;
38+ selectedKeys: Set<string>;
39+ onSelectGroup: (key: string, e: React.MouseEvent) => void;
40+ onSelectTool: (path: string, e: React.MouseEvent) => void;
41+ search: string;
42+}) {
43+ const isExpanded = expandedKeys.has(group.key);
44+ const isSource = group.type === "source";
45+ const isGroupSelected = selectedKeys.has(group.key);
46+ const SourceIcon =
47+ group.sourceType === "mcp"
48+ ? Server
49+ : group.sourceType === "graphql"
50+ ? Layers
51+ : Globe;
52+
53+ const hasNestedGroups =
54+ group.children.length > 0 && "key" in group.children[0];
55+
56+ return (
57+ <Collapsible open={isExpanded} onOpenChange={() => onToggle(group.key)}>
58+ <CollapsibleTrigger asChild>
59+ <div
60+ className={cn(
61+ "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/row select-none",
62+ "sticky bg-background/95 backdrop-blur-sm",
63+ isExpanded && "border-b border-border/30",
64+ isGroupSelected
65+ ? "bg-primary/10 ring-1 ring-primary/20"
66+ : "hover:bg-accent/30",
67+ )}
68+ style={{
69+ paddingLeft: `${depth * 20 + 8}px`,
70+ top: `${depth * 32}px`,
71+ zIndex: 20 - depth,
72+ }}
73+ >
74+ <button
75+ onClick={(e) => onSelectGroup(group.key, e)}
76+ className={cn(
77+ "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors",
78+ isGroupSelected
79+ ? "bg-primary border-primary text-primary-foreground"
80+ : "border-border hover:border-muted-foreground/50",
81+ )}
82+ >
83+ {isGroupSelected && <Check className="h-2.5 w-2.5" />}
84+ </button>
85+
86+ <div className="h-4 w-4 flex items-center justify-center shrink-0">
87+ {isExpanded ? (
88+ <ChevronDown className="h-3 w-3 text-muted-foreground" />
89+ ) : (
90+ <ChevronRight className="h-3 w-3 text-muted-foreground" />
91+ )}
92+ </div>
93+
94+ {isSource && (
95+ <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0">
96+ <SourceIcon className="h-3 w-3 text-muted-foreground" />
97+ </div>
98+ )}
99+
100+ <span
101+ className={cn(
102+ "font-mono text-[13px] truncate",
103+ isSource
104+ ? "font-semibold text-foreground"
105+ : "font-medium text-foreground/90",
106+ )}
107+ >
108+ {group.label}
109+ </span>
110+
111+ <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto flex items-center gap-2 shrink-0">
112+ {isSource && group.sourceType && (
113+ <span className="uppercase tracking-wider opacity-70">
114+ {group.sourceType}
115+ </span>
116+ )}
117+ {group.approvalCount > 0 && (
118+ <span className="inline-flex items-center gap-0.5 text-terminal-amber">
119+ <ShieldCheck className="h-2.5 w-2.5" />
120+ {group.approvalCount}
121+ </span>
122+ )}
123+ <span className="tabular-nums">{group.childCount}</span>
124+ </span>
125+ </div>
126+ </CollapsibleTrigger>
127+
128+ <CollapsibleContent>
129+ {hasNestedGroups
130+ ? (group.children as ToolGroup[]).map((child) => (
131+ <GroupNode
132+ key={child.key}
133+ group={child}
134+ depth={depth + 1}
135+ expandedKeys={expandedKeys}
136+ onToggle={onToggle}
137+ selectedKeys={selectedKeys}
138+ onSelectGroup={onSelectGroup}
139+ onSelectTool={onSelectTool}
140+ search={search}
141+ />
142+ ))
143+ : (group.children as ToolDescriptor[]).map((tool) => (
144+ <SelectableToolRow
145+ key={tool.path}
146+ tool={tool}
147+ label={search ? tool.path : toolOperation(tool.path)}
148+ depth={depth + 1}
149+ selectedKeys={selectedKeys}
150+ onSelectTool={onSelectTool}
151+ />
152+ ))}
153+ </CollapsibleContent>
154+ </Collapsible>
155+ );
156+}
157+
158+export function SourceSidebar({
159+ tools,
160+ activeSource,
161+ onSelectSource,
162+}: {
163+ tools: ToolDescriptor[];
164+ activeSource: string | null;
165+ onSelectSource: (source: string | null) => void;
166+}) {
167+ const groups = useMemo(() => {
168+ const map = new Map<
169+ string,
170+ { name: string; type: string; count: number; approvalCount: number }
171+ >();
172+
173+ for (const tool of tools) {
174+ const name = sourceLabel(tool.source);
175+ const type = sourceType(tool.source);
176+ let group = map.get(name);
177+ if (!group) {
178+ group = { name, type, count: 0, approvalCount: 0 };
179+ map.set(name, group);
180+ }
181+ group.count++;
182+ if (tool.approval === "required") {
183+ group.approvalCount++;
184+ }
185+ }
186+
187+ return Array.from(map.values()).sort((a, b) => b.count - a.count);
188+ }, [tools]);
189+
190+ return (
191+ <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block">
192+ <div className="px-3 pb-2 pt-1">
193+ <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50">
194+ Sources
195+ </p>
196+ </div>
197+ <div className="space-y-0.5 px-1">
198+ <button
199+ onClick={() => onSelectSource(null)}
200+ className={cn(
201+ "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]",
202+ activeSource === null
203+ ? "bg-accent/40 text-foreground"
204+ : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
205+ )}
206+ >
207+ <Layers className="h-3 w-3 shrink-0" />
208+ <span className="font-medium truncate">All sources</span>
209+ <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60">
210+ {tools.length}
211+ </span>
212+ </button>
213+
214+ {groups.map((g) => {
215+ const Icon = g.type === "mcp" ? Server : Globe;
216+ return (
217+ <button
218+ key={g.name}
219+ onClick={() => onSelectSource(g.name)}
220+ className={cn(
221+ "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]",
222+ activeSource === g.name
223+ ? "bg-accent/40 text-foreground"
224+ : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
225+ )}
226+ >
227+ <Icon className="h-3 w-3 shrink-0" />
228+ <span className="font-mono font-medium truncate">{g.name}</span>
229+ <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60">
230+ {g.count}
231+ </span>
232+ </button>
233+ );
234+ })}
235+ </div>
236+ </div>
237+ );
238+}
executor/apps/web/src/components/tool-explorer-helpers.ts+34−0
@@ -0,0 +1,34 @@
1+import type { ToolDescriptor } from "@/lib/types";
2+import type { ToolGroup } from "@/lib/tool-explorer-grouping";
3+
4+export function findToolsInGroupByKey(
5+ groups: ToolGroup[],
6+ key: string,
7+): ToolDescriptor[] {
8+ for (const group of groups) {
9+ if (group.key === key) {
10+ return collectToolsFromGroup(group);
11+ }
12+
13+ if (group.children.length > 0 && "key" in group.children[0]) {
14+ const found = findToolsInGroupByKey(group.children as ToolGroup[], key);
15+ if (found.length > 0) {
16+ return found;
17+ }
18+ }
19+ }
20+
21+ return [];
22+}
23+
24+function collectToolsFromGroup(group: ToolGroup): ToolDescriptor[] {
25+ if (group.children.length === 0) {
26+ return [];
27+ }
28+
29+ if ("key" in group.children[0]) {
30+ return (group.children as ToolGroup[]).flatMap(collectToolsFromGroup);
31+ }
32+
33+ return group.children as ToolDescriptor[];
34+}
executor/apps/web/src/components/tool-explorer-loading.tsx+35−0
@@ -0,0 +1,35 @@
1+import { Loader2 } from "lucide-react";
2+import { displaySourceName } from "@/lib/tool-source-utils";
3+import type { ToolSourceRecord } from "@/lib/types";
4+
5+export function ToolExplorerLoading({ sources }: { sources: ToolSourceRecord[] }) {
6+ const enabledSources = sources.filter((source) => source.enabled);
7+
8+ return (
9+ <div className="space-y-3 rounded-md border border-border/40 bg-muted/20 p-3">
10+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
11+ <Loader2 className="h-4 w-4 animate-spin" />
12+ Loading tool inventory...
13+ </div>
14+
15+ {enabledSources.length > 0 && (
16+ <div className="space-y-1">
17+ {enabledSources.map((source) => (
18+ <div
19+ key={source.id}
20+ className="flex items-center gap-2 rounded-md border border-border bg-background/70 px-3 py-2 text-xs"
21+ >
22+ <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
23+ <span className="truncate font-medium">
24+ {displaySourceName(source.name)}
25+ </span>
26+ <span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
27+ {source.type}
28+ </span>
29+ </div>
30+ ))}
31+ </div>
32+ )}
33+ </div>
34+ );
35+}
executor/apps/web/src/components/tool-explorer-rows.tsx+175−0
@@ -0,0 +1,175 @@
1+"use client";
2+
3+import { useCallback, useState, memo } from "react";
4+import {
5+ Check,
6+ ChevronDown,
7+ ChevronRight,
8+ Search,
9+ ShieldCheck,
10+ Zap,
11+} from "lucide-react";
12+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
13+import { cn } from "@/lib/utils";
14+import type { ToolDescriptor } from "@/lib/types";
15+import { CopyButton } from "./tool-explorer-copy-button";
16+import { ToolDetail } from "./tool-explorer-tool-detail";
17+
18+const ToolRow = memo(function ToolRow({
19+ tool,
20+ label,
21+ depth,
22+ selected,
23+ onSelect,
24+}: {
25+ tool: ToolDescriptor;
26+ label: string;
27+ depth: number;
28+ selected: boolean;
29+ onSelect: (e: React.MouseEvent) => void;
30+}) {
31+ const [expanded, setExpanded] = useState(false);
32+
33+ return (
34+ <Collapsible open={expanded} onOpenChange={setExpanded}>
35+ <CollapsibleTrigger asChild>
36+ <div
37+ className={cn(
38+ "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/tool select-none",
39+ expanded
40+ ? "sticky bg-background/95 backdrop-blur-sm bg-accent/30"
41+ : selected
42+ ? "bg-primary/5 ring-1 ring-primary/10"
43+ : "hover:bg-accent/20",
44+ )}
45+ style={{
46+ paddingLeft: `${depth * 20 + 8}px`,
47+ ...(expanded ? { top: `${depth * 32}px`, zIndex: 20 - depth } : {}),
48+ }}
49+ >
50+ <button
51+ onClick={onSelect}
52+ className={cn(
53+ "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors",
54+ selected
55+ ? "bg-primary border-primary text-primary-foreground"
56+ : "border-border hover:border-muted-foreground/50",
57+ )}
58+ >
59+ {selected && <Check className="h-2.5 w-2.5" />}
60+ </button>
61+
62+ <div className="h-4 w-4 flex items-center justify-center shrink-0">
63+ {expanded ? (
64+ <ChevronDown className="h-3 w-3 text-muted-foreground/50" />
65+ ) : (
66+ <ChevronRight className="h-3 w-3 text-muted-foreground/30" />
67+ )}
68+ </div>
69+
70+ <Zap className="h-3 w-3 text-primary/60 shrink-0" />
71+
72+ <span className="text-[13px] font-mono text-foreground/90 truncate">
73+ {label}
74+ </span>
75+
76+ {tool.approval === "required" && (
77+ <span className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/8 px-1.5 py-0.5 rounded border border-terminal-amber/15 shrink-0">
78+ <ShieldCheck className="h-2.5 w-2.5" />
79+ gated
80+ </span>
81+ )}
82+
83+ <div className="ml-auto flex items-center gap-1 shrink-0 opacity-0 group-hover/tool:opacity-100 transition-opacity">
84+ <CopyButton text={tool.path} />
85+ </div>
86+ </div>
87+ </CollapsibleTrigger>
88+
89+ <CollapsibleContent>
90+ <ToolDetail tool={tool} depth={depth} />
91+ </CollapsibleContent>
92+ </Collapsible>
93+ );
94+});
95+
96+export const SelectableToolRow = memo(function SelectableToolRow({
97+ tool,
98+ label,
99+ depth,
100+ selectedKeys,
101+ onSelectTool,
102+}: {
103+ tool: ToolDescriptor;
104+ label: string;
105+ depth: number;
106+ selectedKeys: Set<string>;
107+ onSelectTool: (path: string, e: React.MouseEvent) => void;
108+}) {
109+ const selected = selectedKeys.has(tool.path);
110+ const handleSelect = useCallback(
111+ (e: React.MouseEvent) => onSelectTool(tool.path, e),
112+ [onSelectTool, tool.path],
113+ );
114+
115+ return (
116+ <ToolRow
117+ tool={tool}
118+ label={label}
119+ depth={depth}
120+ selected={selected}
121+ onSelect={handleSelect}
122+ />
123+ );
124+},
125+(prev, next) =>
126+ prev.tool === next.tool &&
127+ prev.label === next.label &&
128+ prev.depth === next.depth &&
129+ prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path),
130+);
131+
132+export function VirtualFlatList({
133+ tools,
134+ selectedKeys,
135+ onSelectTool,
136+ scrollContainerRef,
137+}: {
138+ tools: ToolDescriptor[];
139+ selectedKeys: Set<string>;
140+ onSelectTool: (path: string, e: React.MouseEvent) => void;
141+ scrollContainerRef: React.RefObject<HTMLDivElement | null>;
142+}) {
143+ return (
144+ <div
145+ ref={scrollContainerRef}
146+ className="max-h-[calc(100vh-320px)] overflow-y-auto rounded-md border border-border/30 bg-background/30"
147+ >
148+ <div className="p-1">
149+ {tools.map((tool) => (
150+ <SelectableToolRow
151+ key={tool.path}
152+ tool={tool}
153+ label={tool.path}
154+ depth={0}
155+ selectedKeys={selectedKeys}
156+ onSelectTool={onSelectTool}
157+ />
158+ ))}
159+ </div>
160+ </div>
161+ );
162+}
163+
164+export function EmptyState({ hasSearch }: { hasSearch: boolean }) {
165+ return (
166+ <div className="flex flex-col items-center justify-center h-full py-12 gap-2">
167+ <div className="h-10 w-10 rounded-full bg-muted/50 flex items-center justify-center">
168+ <Search className="h-5 w-5 text-muted-foreground/30" />
169+ </div>
170+ <p className="text-sm text-muted-foreground/60">
171+ {hasSearch ? "No tools match your search" : "No tools available"}
172+ </p>
173+ </div>
174+ );
175+}
executor/apps/web/src/components/tool-explorer-tool-detail.tsx+43−0
@@ -0,0 +1,43 @@
1+"use client";
2+
3+import { Streamdown } from "streamdown";
4+import { createCodePlugin } from "@streamdown/code";
5+import type { ToolDescriptor } from "@/lib/types";
6+import { TypeSignature } from "./tool-explorer-type-signature";
7+
8+const codePlugin = createCodePlugin({
9+ themes: ["github-light", "github-dark"],
10+});
11+
12+export function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) {
13+ const insetLeft = depth * 20 + 8 + 16 + 8;
14+
15+ return (
16+ <div className="space-y-2.5 pb-3 pt-1 pr-2" style={{ paddingLeft: insetLeft }}>
17+ {tool.description && (
18+ <div className="tool-description text-[12px] leading-relaxed text-muted-foreground">
19+ <Streamdown plugins={{ code: codePlugin }}>{tool.description}</Streamdown>
20+ </div>
21+ )}
22+
23+ <div className="flex flex-wrap gap-1.5">
24+ <span className="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground/70">
25+ {tool.path}
26+ </span>
27+ {tool.source && (
28+ <span className="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground/70">
29+ source: {tool.source}
30+ </span>
31+ )}
32+ {tool.operationId && (
33+ <span className="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground/70">
34+ op: {tool.operationId}
35+ </span>
36+ )}
37+ </div>
38+
39+ {tool.argsType && <TypeSignature raw={tool.argsType} label="Arguments" />}
40+ {tool.returnsType && <TypeSignature raw={tool.returnsType} label="Returns" />}
41+ </div>
42+ );
43+}
executor/apps/web/src/components/tool-explorer-toolbar.tsx+319−0
@@ -0,0 +1,319 @@
1+import {
2+ Search,
3+ ShieldCheck,
4+ Server,
5+ Filter,
6+ X,
7+ FolderTree,
8+ List,
9+} from "lucide-react";
10+import { Input } from "@/components/ui/input";
11+import { Button } from "@/components/ui/button";
12+import {
13+ DropdownMenu,
14+ DropdownMenuContent,
15+ DropdownMenuCheckboxItem,
16+ DropdownMenuLabel,
17+ DropdownMenuSeparator,
18+ DropdownMenuTrigger,
19+} from "@/components/ui/dropdown-menu";
20+import { cn } from "@/lib/utils";
21+
22+export type GroupBy = "source" | "namespace" | "approval";
23+export type ViewMode = "tree" | "flat";
24+type FilterApproval = "all" | "required" | "auto";
25+
26+interface ToolExplorerToolbarProps {
27+ search: string;
28+ filteredToolCount: number;
29+ hasSearch: boolean;
30+ resultCount: number;
31+ viewMode: ViewMode;
32+ groupBy: GroupBy;
33+ filterApproval: FilterApproval;
34+ showSourceSidebar: boolean;
35+ activeSource: string | null;
36+ sourceOptions: string[];
37+ selectedToolCount: number;
38+ warningsCount: number;
39+ onSearchChange: (value: string) => void;
40+ onClearSearch: () => void;
41+ onViewModeChange: (mode: ViewMode) => void;
42+ onGroupByChange: (mode: GroupBy) => void;
43+ onFilterApprovalChange: (filter: FilterApproval) => void;
44+ onSourceSelect: (source: string | null) => void;
45+ onSelectAll: () => void;
46+ onClearSelection: () => void;
47+ onExpandAll: () => void;
48+ onCollapseAll: () => void;
49+}
50+
51+export function ToolExplorerToolbar({
52+ search,
53+ filteredToolCount,
54+ hasSearch,
55+ resultCount,
56+ viewMode,
57+ groupBy,
58+ filterApproval,
59+ showSourceSidebar,
60+ activeSource,
61+ sourceOptions,
62+ selectedToolCount,
63+ warningsCount,
64+ onSearchChange,
65+ onClearSearch,
66+ onViewModeChange,
67+ onGroupByChange,
68+ onFilterApprovalChange,
69+ onSourceSelect,
70+ onSelectAll,
71+ onClearSelection,
72+ onExpandAll,
73+ onCollapseAll,
74+}: ToolExplorerToolbarProps) {
75+ return (
76+ <div className="shrink-0">
77+ <div className="flex items-center gap-2 pb-3 flex-wrap">
78+ <div className="relative flex-1 min-w-[200px]">
79+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
80+ <Input
81+ value={search}
82+ onChange={(e) => onSearchChange(e.target.value)}
83+ placeholder={`Search ${filteredToolCount} tools...`}
84+ className="h-8 text-xs pl-8 bg-background/50 border-border/50 focus:border-primary/30"
85+ />
86+ {search && (
87+ <button
88+ onClick={onClearSearch}
89+ className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 rounded flex items-center justify-center text-muted-foreground hover:text-foreground"
90+ >
91+ <X className="h-3 w-3" />
92+ </button>
93+ )}
94+ </div>
95+
96+ <div className="flex h-8 rounded-md border border-border/50 overflow-hidden">
97+ <button
98+ onClick={() => onViewModeChange("tree")}
99+ className={cn(
100+ "px-2 flex items-center gap-1 text-[11px] transition-colors",
101+ viewMode === "tree"
102+ ? "bg-accent/40 text-foreground"
103+ : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
104+ )}
105+ >
106+ <FolderTree className="h-3 w-3" />
107+ <span className="hidden sm:inline">Tree</span>
108+ </button>
109+ <button
110+ onClick={() => onViewModeChange("flat")}
111+ className={cn(
112+ "px-2 flex items-center gap-1 text-[11px] transition-colors border-l border-border/50",
113+ viewMode === "flat"
114+ ? "bg-accent/40 text-foreground"
115+ : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
116+ )}
117+ >
118+ <List className="h-3 w-3" />
119+ <span className="hidden sm:inline">Flat</span>
120+ </button>
121+ </div>
122+
123+ {viewMode === "tree" && (
124+ <DropdownMenu>
125+ <DropdownMenuTrigger asChild>
126+ <Button
127+ variant="outline"
128+ size="sm"
129+ className="h-8 text-[11px] border-border/50"
130+ >
131+ <FolderTree className="h-3 w-3 mr-1" />
132+ {groupBy === "source"
133+ ? "By source"
134+ : groupBy === "namespace"
135+ ? "By namespace"
136+ : "By approval"}
137+ </Button>
138+ </DropdownMenuTrigger>
139+ <DropdownMenuContent align="end">
140+ <DropdownMenuLabel className="text-[11px]">Group by</DropdownMenuLabel>
141+ <DropdownMenuSeparator />
142+ <DropdownMenuCheckboxItem
143+ checked={groupBy === "source"}
144+ onCheckedChange={() => onGroupByChange("source")}
145+ className="text-xs"
146+ >
147+ Source → Namespace
148+ </DropdownMenuCheckboxItem>
149+ <DropdownMenuCheckboxItem
150+ checked={groupBy === "namespace"}
151+ onCheckedChange={() => onGroupByChange("namespace")}
152+ className="text-xs"
153+ >
154+ Namespace
155+ </DropdownMenuCheckboxItem>
156+ <DropdownMenuCheckboxItem
157+ checked={groupBy === "approval"}
158+ onCheckedChange={() => onGroupByChange("approval")}
159+ className="text-xs"
160+ >
161+ Approval mode
162+ </DropdownMenuCheckboxItem>
163+ </DropdownMenuContent>
164+ </DropdownMenu>
165+ )}
166+
167+ <DropdownMenu>
168+ <DropdownMenuTrigger asChild>
169+ <Button
170+ variant="outline"
171+ size="sm"
172+ className={cn(
173+ "h-8 text-[11px] border-border/50",
174+ filterApproval !== "all" && "border-primary/30 text-primary",
175+ )}
176+ >
177+ <Filter className="h-3 w-3 mr-1" />
178+ {filterApproval === "all"
179+ ? "Filter"
180+ : filterApproval === "required"
181+ ? "Gated only"
182+ : "Auto only"}
183+ </Button>
184+ </DropdownMenuTrigger>
185+ <DropdownMenuContent align="end">
186+ <DropdownMenuLabel className="text-[11px]">Approval filter</DropdownMenuLabel>
187+ <DropdownMenuSeparator />
188+ <DropdownMenuCheckboxItem
189+ checked={filterApproval === "all"}
190+ onCheckedChange={() => onFilterApprovalChange("all")}
191+ className="text-xs"
192+ >
193+ All tools
194+ </DropdownMenuCheckboxItem>
195+ <DropdownMenuCheckboxItem
196+ checked={filterApproval === "required"}
197+ onCheckedChange={() => onFilterApprovalChange("required")}
198+ className="text-xs"
199+ >
200+ Approval required
201+ </DropdownMenuCheckboxItem>
202+ <DropdownMenuCheckboxItem
203+ checked={filterApproval === "auto"}
204+ onCheckedChange={() => onFilterApprovalChange("auto")}
205+ className="text-xs"
206+ >
207+ Auto-approved
208+ </DropdownMenuCheckboxItem>
209+ </DropdownMenuContent>
210+ </DropdownMenu>
211+
212+ <DropdownMenu>
213+ <DropdownMenuTrigger asChild>
214+ <Button
215+ variant="outline"
216+ size="sm"
217+ className={cn(
218+ "h-8 text-[11px] border-border/50",
219+ showSourceSidebar && "lg:hidden",
220+ activeSource && "border-primary/30 text-primary",
221+ )}
222+ >
223+ <Server className="h-3 w-3 mr-1" />
224+ {activeSource ?? "All"}
225+ </Button>
226+ </DropdownMenuTrigger>
227+ <DropdownMenuContent align="end">
228+ <DropdownMenuLabel className="text-[11px]">Source</DropdownMenuLabel>
229+ <DropdownMenuSeparator />
230+ <DropdownMenuCheckboxItem
231+ checked={activeSource === null}
232+ onCheckedChange={() => onSourceSelect(null)}
233+ className="text-xs"
234+ >
235+ All sources
236+ </DropdownMenuCheckboxItem>
237+ {sourceOptions.map((src) => (
238+ <DropdownMenuCheckboxItem
239+ key={src}
240+ checked={activeSource === src}
241+ onCheckedChange={() => onSourceSelect(activeSource === src ? null : src)}
242+ className="text-xs font-mono"
243+ >
244+ {src}
245+ </DropdownMenuCheckboxItem>
246+ ))}
247+ </DropdownMenuContent>
248+ </DropdownMenu>
249+ </div>
250+
251+ {selectedToolCount > 0 && (
252+ <div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-md bg-primary/5 border border-primary/10">
253+ <span className="text-[12px] font-mono text-primary">
254+ {selectedToolCount} tool{selectedToolCount !== 1 ? "s" : ""} selected
255+ </span>
256+ <div className="flex-1" />
257+ <Button
258+ variant="ghost"
259+ size="sm"
260+ className="h-6 text-[11px] text-muted-foreground"
261+ onClick={onSelectAll}
262+ >
263+ Select all ({filteredToolCount})
264+ </Button>
265+ <Button
266+ variant="ghost"
267+ size="sm"
268+ className="h-6 text-[11px] text-muted-foreground"
269+ onClick={onClearSelection}
270+ >
271+ Clear
272+ </Button>
273+ <div className="h-4 w-px bg-border/50" />
274+ <Button
275+ variant="outline"
276+ size="sm"
277+ className="h-6 text-[11px] border-terminal-amber/30 text-terminal-amber hover:bg-terminal-amber/10"
278+ >
279+ <ShieldCheck className="h-3 w-3 mr-1" />
280+ Set approval
281+ </Button>
282+ </div>
283+ )}
284+
285+ {warningsCount > 0 && (
286+ <div className="mb-2 rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2">
287+ <p className="text-[11px] text-terminal-amber/80">
288+ {warningsCount} source warning{warningsCount !== 1 ? "s" : ""} — inventory may
289+ be incomplete
290+ </p>
291+ </div>
292+ )}
293+
294+ <div className="flex items-center justify-between pb-1.5">
295+ <span className="text-[10px] font-mono text-muted-foreground/50">
296+ {hasSearch ? `${resultCount} results` : `${filteredToolCount} tools`}
297+ {activeSource && ` in ${activeSource}`}
298+ </span>
299+ {viewMode === "tree" && (
300+ <div className="flex gap-1">
301+ <button
302+ onClick={onExpandAll}
303+ className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
304+ >
305+ Expand all
306+ </button>
307+ <span className="text-[10px] text-muted-foreground/30">·</span>
308+ <button
309+ onClick={onCollapseAll}
310+ className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
311+ >
312+ Collapse all
313+ </button>
314+ </div>
315+ )}
316+ </div>
317+ </div>
318+ );
319+}
executor/apps/web/src/components/tool-explorer-type-signature.tsx+89−0
@@ -0,0 +1,89 @@
1+"use client";
2+
3+import { useEffect, useMemo, useState } from "react";
4+
5+function prettyType(raw: string): string {
6+ const trimmed = raw.trim();
7+ if (trimmed.includes("\n") || trimmed.length < 50) {
8+ return trimmed;
9+ }
10+
11+ let output = "";
12+ let indent = 0;
13+ const tab = " ";
14+ let index = 0;
15+
16+ while (index < trimmed.length) {
17+ const char = trimmed[index];
18+
19+ if (char === "[" && trimmed[index + 1] === "]") {
20+ output += "[]";
21+ index += 2;
22+ } else if (char === "{") {
23+ indent++;
24+ output += `${char}\n${tab.repeat(indent)}`;
25+ index++;
26+ while (index < trimmed.length && trimmed[index] === " ") {
27+ index++;
28+ }
29+ } else if (char === "}") {
30+ indent = Math.max(0, indent - 1);
31+ output = output.replace(/\s+$/, "");
32+ output += `\n${tab.repeat(indent)}${char}`;
33+ index++;
34+ } else if (char === ";" && trimmed[index + 1] === " ") {
35+ output += `;\n${tab.repeat(indent)}`;
36+ index += 2;
37+ } else if (char === "," && indent > 0 && trimmed[index + 1] === " ") {
38+ output += `,\n${tab.repeat(indent)}`;
39+ index += 2;
40+ } else {
41+ output += char;
42+ index++;
43+ }
44+ }
45+
46+ return output;
47+}
48+
49+export function TypeSignature({ raw, label }: { raw: string; label: string }) {
50+ const formatted = useMemo(() => prettyType(raw), [raw]);
51+ const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
52+
53+ useEffect(() => {
54+ let cancelled = false;
55+
56+ import("shiki").then(({ codeToHtml }) =>
57+ codeToHtml(formatted, {
58+ lang: "typescript",
59+ themes: { light: "github-light", dark: "github-dark" },
60+ }).then((html) => {
61+ if (!cancelled) {
62+ setHighlightedHtml(html);
63+ }
64+ }),
65+ );
66+
67+ return () => {
68+ cancelled = true;
69+ };
70+ }, [formatted]);
71+
72+ return (
73+ <div>
74+ <p className="mb-1 text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50">
75+ {label}
76+ </p>
77+ {highlightedHtml ? (
78+ <div
79+ 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"
80+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
81+ />
82+ ) : (
83+ <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">
84+ {formatted}
85+ </pre>
86+ )}
87+ </div>
88+ );
89+}
executor/apps/web/src/components/tool-explorer.tsx+83−1236
@@ -1,789 +1,34 @@
11 "use client";
22
3+import { useState, useMemo, useCallback, useRef } from "react";
4+import { cn } from "@/lib/utils";
35 import {
4− useState,
5− useMemo,
6− useCallback,
7− useEffect,
8− useRef,
9− memo,
10−} from "react";
11−import {
12− Search,
13− ChevronRight,
14− ChevronDown,
15− ShieldCheck,
16− Zap,
17− Server,
18− Globe,
19− Filter,
20− X,
21− FolderTree,
22− List,
23− Check,
24− Copy,
25− Layers,
26− Loader2,
27−} from "lucide-react";
28−import { Input } from "@/components/ui/input";
29−import { Button } from "@/components/ui/button";
6+ collectGroupKeys,
7+} from "@/lib/tool-explorer-grouping";
8+import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types";
9+import { findToolsInGroupByKey } from "./tool-explorer-helpers";
3010 import {
31− DropdownMenu,
32− DropdownMenuContent,
33− DropdownMenuCheckboxItem,
34− DropdownMenuLabel,
35− DropdownMenuSeparator,
36− DropdownMenuTrigger,
37−} from "@/components/ui/dropdown-menu";
11+ autoExpandedKeysForSearch,
12+ countSelectedTools,
13+ expandedKeysForSource,
14+ filterToolsBySearch,
15+ filterToolsBySourceAndApproval,
16+ flatToolsForView,
17+ sourceOptionsFromTools,
18+ treeGroupsForView,
19+ type FilterApproval,
20+} from "./tool-explorer-derived";
21+import { ToolExplorerLoading } from "./tool-explorer-loading";
3822 import {
39− Tooltip,
40− TooltipContent,
41− TooltipProvider,
42− TooltipTrigger,
43−} from "@/components/ui/tooltip";
23+ EmptyState,
24+ VirtualFlatList,
25+} from "./tool-explorer-rows";
26+import { GroupNode, SourceSidebar } from "./tool-explorer-groups";
4427 import {
45− Collapsible,
46− CollapsibleContent,
47− CollapsibleTrigger,
48−} from "@/components/ui/collapsible";
49−import { Streamdown } from "streamdown";
50−import { createCodePlugin } from "@streamdown/code";
51−import { cn } from "@/lib/utils";
52−import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types";
53−
54−// ── Streamdown code plugin (light + dark dual-theme) ──
55−const codePlugin = createCodePlugin({
56− themes: ["github-light", "github-dark"],
57−});
58−
59−// ── Helpers ──
60−
61−function sourceLabel(source?: string): string {
62− if (!source) return "built-in";
63− const idx = source.indexOf(":");
64− return idx >= 0 ? source.slice(idx + 1) : source;
65−}
66−
67−function sourceType(source?: string): string {
68− if (!source) return "local";
69− const idx = source.indexOf(":");
70− return idx >= 0 ? source.slice(0, idx) : "local";
71−}
72−
73−function toolNamespace(path: string): string {
74− const parts = path.split(".");
75− if (parts.length >= 2) return parts.slice(0, -1).join(".");
76− return parts[0];
77−}
78−
79−function toolOperation(path: string): string {
80− const parts = path.split(".");
81− return parts[parts.length - 1];
82−}
83−
84−function trimLeadingNamespace(path: string, prefix: string): string {
85− const dottedPrefix = `${prefix}.`;
86− if (path.startsWith(dottedPrefix)) {
87− return path.slice(dottedPrefix.length);
88− }
89− return path;
90−}
91−
92−function displaySourceName(name: string): string {
93− const parts = name.split(/[-_.]+/).filter(Boolean);
94− if (parts.length === 0) return name;
95−
96− const deduped = parts.filter((part, index, all) => {
97− if (index === 0) return true;
98− return part.toLowerCase() !== all[index - 1]?.toLowerCase();
99− });
100−
101− const tokenMap: Record<string, string> = {
102− api: "API",
103− oauth: "OAuth",
104− graphql: "GraphQL",
105− mcp: "MCP",
106− github: "GitHub",
107− };
108−
109− return deduped
110− .map((token) => {
111− const lower = token.toLowerCase();
112− if (tokenMap[lower]) return tokenMap[lower];
113− return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`;
114− })
115− .join(" ");
116−}
117−
118−// ── Types for grouped tree data ──
119−
120−interface ToolGroup {
121− key: string;
122− label: string;
123− type: "source" | "namespace";
124− sourceType?: string;
125− childCount: number;
126− approvalCount: number;
127− children: ToolGroup[] | ToolDescriptor[];
128−}
129−
130−type GroupBy = "source" | "namespace" | "approval";
131−type ViewMode = "tree" | "flat";
132−
133−// ── Build grouped data structures ──
134−
135−function buildSourceTree(tools: ToolDescriptor[]): ToolGroup[] {
136− const bySource = new Map<string, ToolDescriptor[]>();
137− for (const tool of tools) {
138− const src = sourceLabel(tool.source);
139− let list = bySource.get(src);
140− if (!list) {
141− list = [];
142− bySource.set(src, list);
143− }
144− list.push(tool);
145− }
146−
147− return Array.from(bySource.entries())
148− .sort((a, b) => b[1].length - a[1].length)
149− .map(([src, srcTools]) => {
150− const sType = srcTools[0] ? sourceType(srcTools[0].source) : "local";
151−
152− const byNs = new Map<string, ToolDescriptor[]>();
153− for (const tool of srcTools) {
154− const ns = toolNamespace(tool.path);
155− let list = byNs.get(ns);
156− if (!list) {
157− list = [];
158− byNs.set(ns, list);
159− }
160− list.push(tool);
161− }
162−
163− const nsGroups: ToolGroup[] = Array.from(byNs.entries())
164− .sort((a, b) => a[0].localeCompare(b[0]))
165− .map(([ns, nsTools]) => ({
166− key: `source:${src}:ns:${ns}`,
167− label: trimLeadingNamespace(ns, src),
168− type: "namespace" as const,
169− childCount: nsTools.length,
170− approvalCount: nsTools.filter((t) => t.approval === "required")
171− .length,
172− children: [...nsTools].sort((a, b) =>
173− a.path.localeCompare(b.path),
174− ),
175− }));
176−
177− return {
178− key: `source:${src}`,
179− label: src,
180− type: "source" as const,
181− sourceType: sType,
182− childCount: srcTools.length,
183− approvalCount: srcTools.filter((t) => t.approval === "required")
184− .length,
185− children: nsGroups,
186− };
187− });
188−}
189−
190−function buildNamespaceTree(tools: ToolDescriptor[]): ToolGroup[] {
191− const byNs = new Map<string, ToolDescriptor[]>();
192− for (const tool of tools) {
193− const ns = toolNamespace(tool.path);
194− let list = byNs.get(ns);
195− if (!list) {
196− list = [];
197− byNs.set(ns, list);
198− }
199− list.push(tool);
200− }
201−
202− return Array.from(byNs.entries())
203− .sort((a, b) => a[0].localeCompare(b[0]))
204− .map(([ns, nsTools]) => ({
205− key: `ns:${ns}`,
206− label: ns,
207− type: "namespace" as const,
208− childCount: nsTools.length,
209− approvalCount: nsTools.filter((t) => t.approval === "required").length,
210− children: [...nsTools].sort((a, b) => a.path.localeCompare(b.path)),
211− }));
212−}
213−
214−function buildApprovalTree(tools: ToolDescriptor[]): ToolGroup[] {
215− const gated = tools.filter((t) => t.approval === "required");
216− const auto = tools.filter((t) => t.approval !== "required");
217− const groups: ToolGroup[] = [];
218−
219− if (gated.length > 0) {
220− groups.push({
221− key: "approval:required",
222− label: "Approval Required",
223− type: "namespace",
224− childCount: gated.length,
225− approvalCount: gated.length,
226− children: [...gated].sort((a, b) => a.path.localeCompare(b.path)),
227− });
228− }
229− if (auto.length > 0) {
230− groups.push({
231− key: "approval:auto",
232− label: "Auto-approved",
233− type: "namespace",
234− childCount: auto.length,
235− approvalCount: 0,
236− children: [...auto].sort((a, b) => a.path.localeCompare(b.path)),
237− });
238− }
239−
240− return groups;
241−}
242−
243−function collectGroupKeys(groups: ToolGroup[]): Set<string> {
244− const keys = new Set<string>();
245− const stack = [...groups];
246−
247− while (stack.length > 0) {
248− const group = stack.pop();
249− if (!group) continue;
250−
251− keys.add(group.key);
252− if (group.children.length > 0 && "key" in group.children[0]) {
253− stack.push(...(group.children as ToolGroup[]));
254− }
255− }
256−
257− return keys;
258−}
259−
260−// ── Copy button ──
261−
262−function CopyButton({
263− text,
264− className,
265−}: {
266− text: string;
267− className?: string;
268−}) {
269− const [copied, setCopied] = useState(false);
270−
271− const handleCopy = useCallback(
272− (e: React.MouseEvent) => {
273− e.stopPropagation();
274− navigator.clipboard.writeText(text);
275− setCopied(true);
276− setTimeout(() => setCopied(false), 1500);
277− },
278− [text],
279− );
280−
281− return (
282− <TooltipProvider delayDuration={200}>
283− <Tooltip>
284− <TooltipTrigger asChild>
285− <button
286− onClick={handleCopy}
287− className={cn(
288− "h-5 w-5 rounded flex items-center justify-center text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted transition-colors shrink-0",
289− className,
290− )}
291− >
292− {copied ? (
293− <Check className="h-3 w-3 text-terminal-green" />
294− ) : (
295− <Copy className="h-3 w-3" />
296− )}
297− </button>
298− </TooltipTrigger>
299− <TooltipContent side="left" className="text-[11px]">
300− Copy path
301− </TooltipContent>
302− </Tooltip>
303− </TooltipProvider>
304− );
305−}
306−
307−// ── Pretty-print a TS type literal for display ──
308−// Turns `{ owner: string; repo: string; ref?: string }` into a multi-line
309−// indented format. Handles nested objects/arrays. Pure string transform.
310−
311−function prettyType(raw: string): string {
312− const trimmed = raw.trim();
313− // If it's already multi-line or short enough, leave it
314− if (trimmed.includes("\n") || trimmed.length < 50) return trimmed;
315−
316− let out = "";
317− let indent = 0;
318− const TAB = " ";
319− let i = 0;
320−
321− while (i < trimmed.length) {
322− const ch = trimmed[i];
323−
324− // Array type bracket — e.g. `string[]` or `}[]` — keep inline
325− if (ch === "[" && trimmed[i + 1] === "]") {
326− out += "[]";
327− i += 2;
328− } else if (ch === "{") {
329− indent++;
330− out += ch + "\n" + TAB.repeat(indent);
331− i++;
332− // skip whitespace after brace
333− while (i < trimmed.length && trimmed[i] === " ") i++;
334− } else if (ch === "}") {
335− indent = Math.max(0, indent - 1);
336− // trim trailing whitespace on current line
337− out = out.replace(/\s+$/, "");
338− out += "\n" + TAB.repeat(indent) + ch;
339− i++;
340− } else if (ch === ";" && trimmed[i + 1] === " ") {
341− // property separator — newline after semicolon
342− out += ";\n" + TAB.repeat(indent);
343− i += 2; // skip the semicolon + space
344− } else if (ch === "," && indent > 0 && trimmed[i + 1] === " ") {
345− out += ",\n" + TAB.repeat(indent);
346− i += 2;
347− } else {
348− out += ch;
349− i++;
350− }
351− }
352−
353− return out;
354−}
355−
356−// ── Formatted + highlighted type signature display ──
357−
358−function TypeSignature({ raw, label }: { raw: string; label: string }) {
359− const formatted = useMemo(() => prettyType(raw), [raw]);
360− const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
361−
362− useEffect(() => {
363− let cancelled = false;
364− import("shiki").then(({ codeToHtml }) =>
365− codeToHtml(formatted, {
366− lang: "typescript",
367− themes: { light: "github-light", dark: "github-dark" },
368− }).then((html) => {
369− if (!cancelled) setHighlightedHtml(html);
370− }),
371− );
372− return () => { cancelled = true; };
373− }, [formatted]);
374−
375− return (
376− <div>
377− <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50 mb-1">
378− {label}
379− </p>
380− {highlightedHtml ? (
381− <div
382− 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"
383− dangerouslySetInnerHTML={{ __html: highlightedHtml }}
384− />
385− ) : (
386− <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">
387− {formatted}
388− </pre>
389− )}
390− </div>
391− );
392−}
393−
394−// ── Inline tool detail (shown when tool row is expanded) ──
395−
396−function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) {
397− // Align details under the expand chevron.
398− const insetLeft = depth * 20 + 8 + 16 + 8;
399− return (
400− <div className="pr-2 pb-3 pt-1 space-y-2.5" style={{ paddingLeft: insetLeft }}>
401− {/* Description rendered as markdown/HTML via Streamdown */}
402− {tool.description && (
403− <div className="tool-description text-[12px] text-muted-foreground leading-relaxed">
404− <Streamdown plugins={{ code: codePlugin }}>
405− {tool.description}
406− </Streamdown>
407− </div>
408− )}
409−
410− {/* Metadata pills */}
411− <div className="flex flex-wrap gap-1.5">
412− <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded">
413− {tool.path}
414− </span>
415− {tool.source && (
416− <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded">
417− source: {tool.source}
418− </span>
419− )}
420− {tool.operationId && (
421− <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded">
422− op: {tool.operationId}
423− </span>
424− )}
425− </div>
426−
427− {/* Args / Returns — pretty-printed type signatures */}
428− {tool.argsType && (
429− <TypeSignature raw={tool.argsType} label="Arguments" />
430− )}
431− {tool.returnsType && (
432− <TypeSignature raw={tool.returnsType} label="Returns" />
433− )}
434− </div>
435− );
436−}
437−
438−// ── Tool row — self-contained expand state, memoized ──
439−// Expand/collapse is local state so toggling one row never re-renders siblings.
440−
441−const ToolRow = memo(function ToolRow({
442− tool,
443− label,
444− depth,
445− selected,
446− onSelect,
447−}: {
448− tool: ToolDescriptor;
449− label: string;
450− depth: number;
451− selected: boolean;
452− onSelect: (e: React.MouseEvent) => void;
453−}) {
454− const [expanded, setExpanded] = useState(false);
455−
456− return (
457− <Collapsible open={expanded} onOpenChange={setExpanded}>
458− <CollapsibleTrigger asChild>
459− <div
460− className={cn(
461− "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/tool select-none",
462− expanded
463− ? "sticky bg-background/95 backdrop-blur-sm bg-accent/30"
464− : selected
465− ? "bg-primary/5 ring-1 ring-primary/10"
466− : "hover:bg-accent/20",
467− )}
468− style={{
469− paddingLeft: `${depth * 20 + 8}px`,
470− ...(expanded ? { top: `${depth * 32}px`, zIndex: 20 - depth } : {}),
471− }}
472− >
473− {/* Selection checkbox */}
474− <button
475− onClick={onSelect}
476− className={cn(
477− "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors",
478− selected
479− ? "bg-primary border-primary text-primary-foreground"
480− : "border-border hover:border-muted-foreground/50",
481− )}
482− >
483− {selected && <Check className="h-2.5 w-2.5" />}
484− </button>
485−
486− {/* Expand chevron */}
487− <div className="h-4 w-4 flex items-center justify-center shrink-0">
488− {expanded ? (
489− <ChevronDown className="h-3 w-3 text-muted-foreground/50" />
490− ) : (
491− <ChevronRight className="h-3 w-3 text-muted-foreground/30" />
492− )}
493− </div>
494−
495− {/* Tool icon */}
496− <Zap className="h-3 w-3 text-primary/60 shrink-0" />
497−
498− {/* Tool name */}
499− <span className="text-[13px] font-mono text-foreground/90 truncate">
500− {label}
501− </span>
502−
503− {/* Badges */}
504− {tool.approval === "required" && (
505− <span className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/8 px-1.5 py-0.5 rounded border border-terminal-amber/15 shrink-0">
506− <ShieldCheck className="h-2.5 w-2.5" />
507− gated
508− </span>
509− )}
510−
511− {/* Right side actions */}
512− <div className="ml-auto flex items-center gap-1 shrink-0 opacity-0 group-hover/tool:opacity-100 transition-opacity">
513− <CopyButton text={tool.path} />
514− </div>
515− </div>
516− </CollapsibleTrigger>
517−
518− <CollapsibleContent>
519− <ToolDetail tool={tool} depth={depth} />
520− </CollapsibleContent>
521− </Collapsible>
522− );
523−});
524−
525−// ── Group row with Collapsible children ──
526−
527−function GroupNode({
528− group,
529− depth,
530− expandedKeys,
531− onToggle,
532− selectedKeys,
533− onSelectGroup,
534− onSelectTool,
535− search,
536−}: {
537− group: ToolGroup;
538− depth: number;
539− expandedKeys: Set<string>;
540− onToggle: (key: string) => void;
541− selectedKeys: Set<string>;
542− onSelectGroup: (key: string, e: React.MouseEvent) => void;
543− onSelectTool: (path: string, e: React.MouseEvent) => void;
544− search: string;
545−}) {
546− const isExpanded = expandedKeys.has(group.key);
547− const isSource = group.type === "source";
548− const isGroupSelected = selectedKeys.has(group.key);
549− const SourceIcon =
550− group.sourceType === "mcp"
551− ? Server
552− : group.sourceType === "graphql"
553− ? Layers
554− : Globe;
555−
556− const hasNestedGroups =
557− group.children.length > 0 && "key" in group.children[0];
558−
559− return (
560− <Collapsible open={isExpanded} onOpenChange={() => onToggle(group.key)}>
561− <CollapsibleTrigger asChild>
562− <div
563− className={cn(
564− "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/row select-none",
565− "sticky bg-background/95 backdrop-blur-sm",
566− isExpanded && "border-b border-border/30",
567− isGroupSelected
568− ? "bg-primary/10 ring-1 ring-primary/20"
569− : "hover:bg-accent/30",
570− )}
571− style={{
572− paddingLeft: `${depth * 20 + 8}px`,
573− top: `${depth * 32}px`,
574− zIndex: 20 - depth,
575− }}
576− >
577− {/* Selection checkbox */}
578− <button
579− onClick={(e) => onSelectGroup(group.key, e)}
580− className={cn(
581− "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors",
582− isGroupSelected
583− ? "bg-primary border-primary text-primary-foreground"
584− : "border-border hover:border-muted-foreground/50",
585− )}
586− >
587− {isGroupSelected && <Check className="h-2.5 w-2.5" />}
588− </button>
589−
590− {/* Expand/collapse chevron */}
591− <div className="h-4 w-4 flex items-center justify-center shrink-0">
592− {isExpanded ? (
593− <ChevronDown className="h-3 w-3 text-muted-foreground" />
594− ) : (
595− <ChevronRight className="h-3 w-3 text-muted-foreground" />
596− )}
597− </div>
598−
599− {/* Icon */}
600− {isSource && (
601− <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0">
602− <SourceIcon className="h-3 w-3 text-muted-foreground" />
603− </div>
604− )}
605−
606− {/* Label */}
607− <span
608− className={cn(
609− "font-mono text-[13px] truncate",
610− isSource
611− ? "font-semibold text-foreground"
612− : "font-medium text-foreground/90",
613− )}
614− >
615− {group.label}
616− </span>
617−
618− {/* Metadata */}
619− <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto flex items-center gap-2 shrink-0">
620− {isSource && group.sourceType && (
621− <span className="uppercase tracking-wider opacity-70">
622− {group.sourceType}
623− </span>
624− )}
625− {group.approvalCount > 0 && (
626− <span className="inline-flex items-center gap-0.5 text-terminal-amber">
627− <ShieldCheck className="h-2.5 w-2.5" />
628− {group.approvalCount}
629− </span>
630− )}
631− <span className="tabular-nums">{group.childCount}</span>
632− </span>
633− </div>
634− </CollapsibleTrigger>
635−
636− <CollapsibleContent>
637− {hasNestedGroups
638− ? (group.children as ToolGroup[]).map((child) => (
639− <GroupNode
640− key={child.key}
641− group={child}
642− depth={depth + 1}
643− expandedKeys={expandedKeys}
644− onToggle={onToggle}
645− selectedKeys={selectedKeys}
646− onSelectGroup={onSelectGroup}
647− onSelectTool={onSelectTool}
648− search={search}
649− />
650− ))
651− : (group.children as ToolDescriptor[]).map((tool) => (
652− <SelectableToolRow
653− key={tool.path}
654− tool={tool}
655− label={search ? tool.path : toolOperation(tool.path)}
656− depth={depth + 1}
657− selectedKeys={selectedKeys}
658− onSelectTool={onSelectTool}
659− />
660− ))}
661− </CollapsibleContent>
662− </Collapsible>
663− );
664−}
665−
666−/**
667− * Thin adapter: extracts a boolean from the Set so ToolRow's memo sees
668− * a primitive `selected` and skips re-render when a *different* tool changes.
669− */
670−const SelectableToolRow = memo(function SelectableToolRow({
671− tool,
672− label,
673− depth,
674− selectedKeys,
675− onSelectTool,
676−}: {
677− tool: ToolDescriptor;
678− label: string;
679− depth: number;
680− selectedKeys: Set<string>;
681− onSelectTool: (path: string, e: React.MouseEvent) => void;
682−}) {
683− const selected = selectedKeys.has(tool.path);
684− const handleSelect = useCallback(
685− (e: React.MouseEvent) => onSelectTool(tool.path, e),
686− [onSelectTool, tool.path],
687− );
688−
689− return (
690− <ToolRow
691− tool={tool}
692− label={label}
693− depth={depth}
694− selected={selected}
695− onSelect={handleSelect}
696− />
697− );
698−},
699−(prev, next) =>
700− prev.tool === next.tool &&
701− prev.label === next.label &&
702− prev.depth === next.depth &&
703− prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path),
704−);
705−
706−// ── Sidebar (Source list) ──
707−
708−function SourceSidebar({
709− tools,
710− activeSource,
711− onSelectSource,
712−}: {
713− tools: ToolDescriptor[];
714− activeSource: string | null;
715− onSelectSource: (source: string | null) => void;
716−}) {
717− const groups = useMemo(() => {
718− const map = new Map<
719− string,
720− { name: string; type: string; count: number; approvalCount: number }
721− >();
722−
723− for (const tool of tools) {
724− const name = sourceLabel(tool.source);
725− const type = sourceType(tool.source);
726− let group = map.get(name);
727− if (!group) {
728− group = { name, type, count: 0, approvalCount: 0 };
729− map.set(name, group);
730− }
731− group.count++;
732− if (tool.approval === "required") group.approvalCount++;
733− }
734−
735− return Array.from(map.values()).sort((a, b) => b.count - a.count);
736− }, [tools]);
737−
738− return (
739− <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block">
740− <div className="px-3 pb-2 pt-1">
741− <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50">
742− Sources
743− </p>
744− </div>
745− <div className="space-y-0.5 px-1">
746− <button
747− onClick={() => onSelectSource(null)}
748− className={cn(
749− "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]",
750− activeSource === null
751− ? "bg-accent/40 text-foreground"
752− : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
753− )}
754− >
755− <Layers className="h-3 w-3 shrink-0" />
756− <span className="font-medium truncate">All sources</span>
757− <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60">
758− {tools.length}
759− </span>
760− </button>
761−
762− {groups.map((g) => {
763− const Icon = g.type === "mcp" ? Server : Globe;
764− return (
765− <button
766− key={g.name}
767− onClick={() => onSelectSource(g.name)}
768− className={cn(
769− "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]",
770− activeSource === g.name
771− ? "bg-accent/40 text-foreground"
772− : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
773− )}
774− >
775− <Icon className="h-3 w-3 shrink-0" />
776− <span className="font-mono font-medium truncate">{g.name}</span>
777− <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60">
778− {g.count}
779− </span>
780− </button>
781− );
782− })}
783− </div>
784− </div>
785− );
786−}
28+ ToolExplorerToolbar,
29+ type GroupBy,
30+ type ViewMode,
31+} from "./tool-explorer-toolbar";
78732
78833 // ── Main Explorer ──
78934
@@ -811,16 +56,12 @@ export function ToolExplorer({ · 21 unmodified lines
81156 const [search, setSearch] = useState("");
81257 const [viewMode, setViewMode] = useState<ViewMode>("tree");
81358 const [groupBy, setGroupBy] = useState<GroupBy>("source");
814− const [internalActiveSource, setInternalActiveSource] = useState<string | null>(
815− initialSource,
816− );
59+ const [internalActiveSource, setInternalActiveSource] = useState<string | null>(initialSource);
81760 const [expandedKeys, setExpandedKeys] = useState<Set<string>>(
818− () => (initialSource ? new Set([`source:${initialSource}`]) : new Set()),
61+ () => expandedKeysForSource(initialSource),
81962 );
82063 const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
821− const [filterApproval, setFilterApproval] = useState<
822− "all" | "required" | "auto"
823− >("all");
64+ const [filterApproval, setFilterApproval] = useState<FilterApproval>("all");
82465 const treeListRef = useRef<HTMLDivElement>(null);
82566 const flatListRef = useRef<HTMLDivElement>(null);
82667 const resolvedActiveSource =
@@ -830,78 +71,35 @@ export function ToolExplorer({ · 3 unmodified lines
83071 if (activeSource === undefined) {
83172 setInternalActiveSource(source);
83273 }
74+
83375 onActiveSourceChange?.(source);
834− setExpandedKeys(source ? new Set([`source:${source}`]) : new Set());
76+ setExpandedKeys(expandedKeysForSource(source));
83577 }, [activeSource, onActiveSourceChange]);
83678
837− // Filter tools by active source and approval filter
83879 const filteredTools = useMemo(() => {
839− let result = tools;
840−
841− if (resolvedActiveSource) {
842− result = result.filter(
843− (t) => sourceLabel(t.source) === resolvedActiveSource,
844− );
845− }
846−
847− if (filterApproval === "required") {
848− result = result.filter((t) => t.approval === "required");
849− } else if (filterApproval === "auto") {
850− result = result.filter((t) => t.approval !== "required");
851− }
852−
853− return result;
80+ return filterToolsBySourceAndApproval(
81+ tools,
82+ resolvedActiveSource,
83+ filterApproval,
84+ );
85485 }, [tools, resolvedActiveSource, filterApproval]);
85586
856− // Apply search filter
85787 const searchedTools = useMemo(() => {
858− if (!search) return filteredTools;
859− const lowerSearch = search.toLowerCase();
860− return filteredTools.filter(
861− (t) =>
862− t.path.toLowerCase().includes(lowerSearch) ||
863− t.description.toLowerCase().includes(lowerSearch),
864− );
88+ return filterToolsBySearch(filteredTools, search);
86589 }, [filteredTools, search]);
86690
867− // Build grouped tree
86891 const treeGroups = useMemo(() => {
869− if (viewMode === "flat") return [];
870− if (groupBy === "source") return buildSourceTree(searchedTools);
871− if (groupBy === "namespace") return buildNamespaceTree(searchedTools);
872− return buildApprovalTree(searchedTools);
92+ return treeGroupsForView(searchedTools, viewMode, groupBy);
87393 }, [searchedTools, viewMode, groupBy]);
87494
875− // Flat list
87695 const flatTools = useMemo(() => {
877− if (viewMode !== "flat") return [];
878− return [...searchedTools].sort((a, b) => a.path.localeCompare(b.path));
96+ return flatToolsForView(searchedTools, viewMode);
87997 }, [searchedTools, viewMode]);
88098
881− // Auto-expand matching groups while searching without mutating user state.
88299 const autoExpandedKeys = useMemo(() => {
883− if (search.length < 2 || viewMode !== "tree") {
884− return null;
885− }
886−
887− const allGroupKeys = new Set<string>();
888− const lowerSearch = search.toLowerCase();
889− const matching = filteredTools.filter(
890− (t) =>
891− t.path.toLowerCase().includes(lowerSearch) ||
892− t.description.toLowerCase().includes(lowerSearch),
893− );
894−
895− for (const tool of matching) {
896− const src = sourceLabel(tool.source);
897− const ns = toolNamespace(tool.path);
898− allGroupKeys.add(`source:${src}`);
899− allGroupKeys.add(`source:${src}:ns:${ns}`);
900− allGroupKeys.add(`ns:${ns}`);
901− }
902−
903− return allGroupKeys;
100+ return autoExpandedKeysForSearch(search, filteredTools, viewMode);
904101 }, [search, filteredTools, viewMode]);
102+
905103 const visibleExpandedKeys = autoExpandedKeys ?? expandedKeys;
906104
907105 const toggleExpand = useCallback((key: string) => {
@@ -939,44 +137,7 @@ export function ToolExplorer({ · 31 unmodified lines
939137 e.preventDefault();
940138 setSelectedKeys((prev) => {
941139 const next = new Set(prev);
942−
943− const findToolsInGroup = (
944− groups: ToolGroup[],
945− ): ToolDescriptor[] => {
946− const result: ToolDescriptor[] = [];
947− for (const g of groups) {
948− if (g.key === key) {
949− const collectTools = (group: ToolGroup): void => {
950− if (
951− group.children.length > 0 &&
952− "key" in group.children[0]
953− ) {
954− for (const child of group.children as ToolGroup[]) {
955− collectTools(child);
956− }
957− } else {
958− result.push(
959− ...(group.children as ToolDescriptor[]),
960− );
961− }
962− };
963− collectTools(g);
964− return result;
965− }
966− if (
967− g.children.length > 0 &&
968− "key" in g.children[0]
969− ) {
970− const found = findToolsInGroup(
971− g.children as ToolGroup[],
972− );
973− if (found.length > 0) return found;
974− }
975− }
976− return result;
977− };
978−
979− const childTools = findToolsInGroup(treeGroups);
140+ const childTools = findToolsInGroupByKey(treeGroups, key);
980141 const allSelected =
981142 childTools.length > 0 &&
982143 childTools.every((t) => prev.has(t.path));
@@ -1003,11 +164,19 @@ export function ToolExplorer({ · 20 unmodified lines
1003164 }, [filteredTools]);
1004165
1005166 const selectedToolCount = useMemo(() => {
1006− return Array.from(selectedKeys).filter((k) =>
1007− filteredTools.some((t) => t.path === k),
1008− ).length;
167+ return countSelectedTools(selectedKeys, filteredTools);
1009168 }, [selectedKeys, filteredTools]);
1010169
170+ const sourceOptions = useMemo(() => sourceOptionsFromTools(tools), [tools]);
171+
172+ const handleExpandAll = useCallback(() => {
173+ setExpandedKeys(collectGroupKeys(treeGroups));
174+ }, [treeGroups]);
175+
176+ const handleCollapseAll = useCallback(() => {
177+ setExpandedKeys(new Set());
178+ }, []);
179+
1011180 const handleExplorerWheel = useCallback(
1012181 (e: React.WheelEvent<HTMLDivElement>) => {
1013182 const listEl = viewMode === "flat" ? flatListRef.current : treeListRef.current;
@@ -1028,33 +197,7 @@ export function ToolExplorer({ · 14 unmodified lines
1028197 [viewMode],
1029198 );
1030199
1031− if (loading) {
1032− const enabledSources = sources.filter((s) => s.enabled);
1033− return (
1034− <div className="space-y-3 rounded-md border border-border/40 bg-muted/20 p-3">
1035− <div className="flex items-center gap-2 text-sm text-muted-foreground">
1036− <Loader2 className="h-4 w-4 animate-spin" />
1037− Loading tool inventory...
1038− </div>
1039− {enabledSources.length > 0 && (
1040− <div className="space-y-1">
1041− {enabledSources.map((s) => (
1042− <div
1043− key={s.id}
1044− className="flex items-center gap-2 rounded-md border border-border bg-background/70 px-3 py-2 text-xs"
1045− >
1046− <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
1047− <span className="font-medium truncate">{displaySourceName(s.name)}</span>
1048− <span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
1049− {s.type}
1050− </span>
1051− </div>
1052− ))}
1053− </div>
1054− )}
1055− </div>
1056− );
1057− }
200+ if (loading) return <ToolExplorerLoading sources={sources} />;
1058201
1059202 return (
1060203 <div className="flex" onWheelCapture={handleExplorerWheel}>
@@ -1066,284 +209,37 @@ export function ToolExplorer({ · 5 unmodified lines
1066209 />
1067210 ) : null}
1068211
1069− {/* Main content */}
1070− <div className={cn("flex-1 min-w-0 flex flex-col", showSourceSidebar ? "pl-0 lg:pl-4" : "pl-0")}>
1071− <div className="shrink-0">
1072− {/* Toolbar */}
1073− <div className="flex items-center gap-2 pb-3 flex-wrap">
1074− {/* Search */}
1075− <div className="relative flex-1 min-w-[200px]">
1076− <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
1077− <Input
1078− value={search}
1079− onChange={(e) => setSearch(e.target.value)}
1080− placeholder={`Search ${filteredTools.length} tools...`}
1081− className="h-8 text-xs pl-8 bg-background/50 border-border/50 focus:border-primary/30"
1082− />
1083− {search && (
1084− <button
1085− onClick={() => setSearch("")}
1086− className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 rounded flex items-center justify-center text-muted-foreground hover:text-foreground"
1087− >
1088− <X className="h-3 w-3" />
1089− </button>
1090− )}
1091− </div>
1092−
1093− {/* View mode toggle */}
1094− <div className="flex h-8 rounded-md border border-border/50 overflow-hidden">
1095− <button
1096− onClick={() => setViewMode("tree")}
1097− className={cn(
1098− "px-2 flex items-center gap-1 text-[11px] transition-colors",
1099− viewMode === "tree"
1100− ? "bg-accent/40 text-foreground"
1101− : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
1102− )}
1103− >
1104− <FolderTree className="h-3 w-3" />
1105− <span className="hidden sm:inline">Tree</span>
1106− </button>
1107− <button
1108− onClick={() => setViewMode("flat")}
1109− className={cn(
1110− "px-2 flex items-center gap-1 text-[11px] transition-colors border-l border-border/50",
1111− viewMode === "flat"
1112− ? "bg-accent/40 text-foreground"
1113− : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
1114− )}
1115− >
1116− <List className="h-3 w-3" />
1117− <span className="hidden sm:inline">Flat</span>
1118− </button>
1119− </div>
1120−
1121− {/* Group by (only in tree mode) */}
1122− {viewMode === "tree" && (
1123− <DropdownMenu>
1124− <DropdownMenuTrigger asChild>
1125− <Button
1126− variant="outline"
1127− size="sm"
1128− className="h-8 text-[11px] border-border/50"
1129− >
1130− <FolderTree className="h-3 w-3 mr-1" />
1131− {groupBy === "source"
1132− ? "By source"
1133− : groupBy === "namespace"
1134− ? "By namespace"
1135− : "By approval"}
1136− </Button>
1137− </DropdownMenuTrigger>
1138− <DropdownMenuContent align="end">
1139− <DropdownMenuLabel className="text-[11px]">
1140− Group by
1141− </DropdownMenuLabel>
1142− <DropdownMenuSeparator />
1143− <DropdownMenuCheckboxItem
1144− checked={groupBy === "source"}
1145− onCheckedChange={() => setGroupBy("source")}
1146− className="text-xs"
1147− >
1148− Source → Namespace
1149− </DropdownMenuCheckboxItem>
1150− <DropdownMenuCheckboxItem
1151− checked={groupBy === "namespace"}
1152− onCheckedChange={() => setGroupBy("namespace")}
1153− className="text-xs"
1154− >
1155− Namespace
1156− </DropdownMenuCheckboxItem>
1157− <DropdownMenuCheckboxItem
1158− checked={groupBy === "approval"}
1159− onCheckedChange={() => setGroupBy("approval")}
1160− className="text-xs"
1161− >
1162− Approval mode
1163− </DropdownMenuCheckboxItem>
1164− </DropdownMenuContent>
1165− </DropdownMenu>
1166− )}
1167−
1168− {/* Filter */}
1169− <DropdownMenu>
1170− <DropdownMenuTrigger asChild>
1171− <Button
1172− variant="outline"
1173− size="sm"
1174− className={cn(
1175− "h-8 text-[11px] border-border/50",
1176− filterApproval !== "all" &&
1177− "border-primary/30 text-primary",
1178− )}
1179− >
1180− <Filter className="h-3 w-3 mr-1" />
1181− {filterApproval === "all"
1182− ? "Filter"
1183− : filterApproval === "required"
1184− ? "Gated only"
1185− : "Auto only"}
1186− </Button>
1187− </DropdownMenuTrigger>
1188− <DropdownMenuContent align="end">
1189− <DropdownMenuLabel className="text-[11px]">
1190− Approval filter
1191− </DropdownMenuLabel>
1192− <DropdownMenuSeparator />
1193− <DropdownMenuCheckboxItem
1194− checked={filterApproval === "all"}
1195− onCheckedChange={() => setFilterApproval("all")}
1196− className="text-xs"
1197− >
1198− All tools
1199− </DropdownMenuCheckboxItem>
1200− <DropdownMenuCheckboxItem
1201− checked={filterApproval === "required"}
1202− onCheckedChange={() => setFilterApproval("required")}
1203− className="text-xs"
1204− >
1205− Approval required
1206− </DropdownMenuCheckboxItem>
1207− <DropdownMenuCheckboxItem
1208− checked={filterApproval === "auto"}
1209− onCheckedChange={() => setFilterApproval("auto")}
1210− className="text-xs"
1211− >
1212− Auto-approved
1213− </DropdownMenuCheckboxItem>
1214− </DropdownMenuContent>
1215− </DropdownMenu>
1216−
1217− {/* Mobile source selector */}
1218− <DropdownMenu>
1219− <DropdownMenuTrigger asChild>
1220− <Button
1221− variant="outline"
1222− size="sm"
1223− className={cn(
1224− "h-8 text-[11px] border-border/50",
1225− showSourceSidebar && "lg:hidden",
1226− resolvedActiveSource && "border-primary/30 text-primary",
1227− )}
1228− >
1229− <Server className="h-3 w-3 mr-1" />
1230− {resolvedActiveSource ?? "All"}
1231− </Button>
1232− </DropdownMenuTrigger>
1233− <DropdownMenuContent align="end">
1234− <DropdownMenuLabel className="text-[11px]">
1235− Source
1236− </DropdownMenuLabel>
1237− <DropdownMenuSeparator />
1238− <DropdownMenuCheckboxItem
1239− checked={resolvedActiveSource === null}
1240− onCheckedChange={() => handleSourceSelect(null)}
1241− className="text-xs"
1242− >
1243− All sources
1244− </DropdownMenuCheckboxItem>
1245− {Array.from(
1246− new Set(tools.map((t) => sourceLabel(t.source))),
1247− ).map((src) => (
1248− <DropdownMenuCheckboxItem
1249− key={src}
1250− checked={resolvedActiveSource === src}
1251− onCheckedChange={() =>
1252− handleSourceSelect(
1253− resolvedActiveSource === src ? null : src,
1254− )
1255− }
1256− className="text-xs font-mono"
1257− >
1258− {src}
1259− </DropdownMenuCheckboxItem>
1260− ))}
1261− </DropdownMenuContent>
1262− </DropdownMenu>
1263− </div>
1264−
1265− {/* Selection bar */}
1266− {selectedToolCount > 0 && (
1267− <div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-md bg-primary/5 border border-primary/10">
1268− <span className="text-[12px] font-mono text-primary">
1269− {selectedToolCount} tool
1270− {selectedToolCount !== 1 ? "s" : ""} selected
1271− </span>
1272− <div className="flex-1" />
1273− <Button
1274− variant="ghost"
1275− size="sm"
1276− className="h-6 text-[11px] text-muted-foreground"
1277− onClick={selectAll}
1278− >
1279− Select all ({filteredTools.length})
1280− </Button>
1281− <Button
1282− variant="ghost"
1283− size="sm"
1284− className="h-6 text-[11px] text-muted-foreground"
1285− onClick={clearSelection}
1286− >
1287− Clear
1288− </Button>
1289− <div className="h-4 w-px bg-border/50" />
1290− <Button
1291− variant="outline"
1292− size="sm"
1293− className="h-6 text-[11px] border-terminal-amber/30 text-terminal-amber hover:bg-terminal-amber/10"
1294− >
1295− <ShieldCheck className="h-3 w-3 mr-1" />
1296− Set approval
1297− </Button>
1298− </div>
1299− )}
1300−
1301− {/* Warnings */}
1302− {warnings.length > 0 && (
1303− <div className="mb-2 rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2">
1304− <p className="text-[11px] text-terminal-amber/80">
1305− {warnings.length} source warning
1306− {warnings.length !== 1 ? "s" : ""} — inventory may be
1307− incomplete
1308− </p>
1309− </div>
1310− )}
1311−
1312− {/* Results info */}
1313− <div className="flex items-center justify-between pb-1.5">
1314− <span className="text-[10px] font-mono text-muted-foreground/50">
1315− {search
1316− ? `${searchedTools.length} results`
1317− : `${filteredTools.length} tools`}
1318− {resolvedActiveSource && ` in ${resolvedActiveSource}`}
1319− </span>
1320− {viewMode === "tree" && (
1321− <div className="flex gap-1">
1322− <button
1323− onClick={() => {
1324− setExpandedKeys(collectGroupKeys(treeGroups));
1325− }}
1326− className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
1327− >
1328− Expand all
1329− </button>
1330− <span className="text-[10px] text-muted-foreground/30">
1331− ·
1332− </span>
1333− <button
1334− onClick={() => {
1335− setExpandedKeys(new Set());
1336− }}
1337− className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
1338− >
1339− Collapse all
1340− </button>
1341− </div>
1342− )}
1343− </div>
1344− </div>
212+ <div
213+ className={cn(
214+ "flex-1 min-w-0 flex flex-col",
215+ showSourceSidebar ? "pl-0 lg:pl-4" : "pl-0",
216+ )}
217+ >
218+ <ToolExplorerToolbar
219+ search={search}
220+ filteredToolCount={filteredTools.length}
221+ hasSearch={search.length > 0}
222+ resultCount={searchedTools.length}
223+ viewMode={viewMode}
224+ groupBy={groupBy}
225+ filterApproval={filterApproval}
226+ showSourceSidebar={showSourceSidebar}
227+ activeSource={resolvedActiveSource}
228+ sourceOptions={sourceOptions}
229+ selectedToolCount={selectedToolCount}
230+ warningsCount={warnings.length}
231+ onSearchChange={setSearch}
232+ onClearSearch={() => setSearch("")}
233+ onViewModeChange={setViewMode}
234+ onGroupByChange={setGroupBy}
235+ onFilterApprovalChange={setFilterApproval}
236+ onSourceSelect={handleSourceSelect}
237+ onSelectAll={selectAll}
238+ onClearSelection={clearSelection}
239+ onExpandAll={handleExpandAll}
240+ onCollapseAll={handleCollapseAll}
241+ />
1345242
1346− {/* Scrollable list */}
1347243 {viewMode === "flat" ? (
1348244 flatTools.length === 0 ? (
1349245 <div
@@ -1390,52 +286,3 @@ export function ToolExplorer({ · 40 unmodified lines
1390286 </div>
1391287 );
1392288 }
1393−
1394−// ── Flat list ──
1395−
1396−function VirtualFlatList({
1397− tools,
1398− selectedKeys,
1399− onSelectTool,
1400− scrollContainerRef,
1401−}: {
1402− tools: ToolDescriptor[];
1403− selectedKeys: Set<string>;
1404− onSelectTool: (path: string, e: React.MouseEvent) => void;
1405− scrollContainerRef: React.RefObject<HTMLDivElement | null>;
1406−}) {
1407− return (
1408− <div
1409− ref={scrollContainerRef}
1410− className="max-h-[calc(100vh-320px)] overflow-y-auto rounded-md border border-border/30 bg-background/30"
1411− >
1412− <div className="p-1">
1413− {tools.map((tool) => (
1414− <SelectableToolRow
1415− key={tool.path}
1416− tool={tool}
1417− label={tool.path}
1418− depth={0}
1419− selectedKeys={selectedKeys}
1420− onSelectTool={onSelectTool}
1421− />
1422− ))}
1423− </div>
1424− </div>
1425− );
1426−}
1427−
1428−// ── Empty state ──
1429−
1430−function EmptyState({ hasSearch }: { hasSearch: boolean }) {
1431− return (
1432− <div className="flex flex-col items-center justify-center h-full py-12 gap-2">
1433− <div className="h-10 w-10 rounded-full bg-muted/50 flex items-center justify-center">
1434− <Search className="h-5 w-5 text-muted-foreground/30" />
1435− </div>
1436− <p className="text-sm text-muted-foreground/60">
1437− {hasSearch ? "No tools match your search" : "No tools available"}
1438− </p>
1439− </div>
1440− );
1441−}
executor/apps/web/src/components/tools-view-add-source-dialog-helpers.ts+92−0
@@ -0,0 +1,92 @@
1+import {
2+ type CatalogCollectionItem,
3+ HARD_CODED_CATALOG_ITEMS,
4+} from "@/lib/catalog-collections";
5+
6+export type SourceCatalogSort = "popular" | "recent";
7+export type SourceType = "mcp" | "openapi" | "graphql";
8+
9+export const DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY = "userId";
10+
11+export function getVisibleCatalogItems(
12+ query: string,
13+ sort: SourceCatalogSort,
14+): CatalogCollectionItem[] {
15+ const normalizedQuery = query.trim().toLowerCase();
16+ const filtered = HARD_CODED_CATALOG_ITEMS.filter((item) => {
17+ if (!normalizedQuery) {
18+ return true;
19+ }
20+
21+ return [
22+ item.name,
23+ item.providerName,
24+ item.summary,
25+ item.categories ?? "",
26+ ].some((value) => value.toLowerCase().includes(normalizedQuery));
27+ });
28+
29+ return [...filtered].sort((a, b) => {
30+ if (sort === "recent") {
31+ return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime();
32+ }
33+
34+ return a.rank - b.rank;
35+ });
36+}
37+
38+export function endpointLabelForType(type: SourceType): string {
39+ if (type === "mcp") {
40+ return "Endpoint URL";
41+ }
42+
43+ if (type === "graphql") {
44+ return "GraphQL Endpoint";
45+ }
46+
47+ return "Spec URL";
48+}
49+
50+export function endpointPlaceholderForType(type: SourceType): string {
51+ if (type === "mcp") {
52+ return "https://mcp-server.example.com/sse";
53+ }
54+
55+ if (type === "graphql") {
56+ return "https://api.example.com/graphql";
57+ }
58+
59+ return "https://api.example.com/openapi.json";
60+}
61+
62+export function createCustomSourceConfig({
63+ type,
64+ endpoint,
65+ baseUrl,
66+ mcpTransport,
67+ mcpActorQueryParamKey,
68+ actorId,
69+}: {
70+ type: SourceType;
71+ endpoint: string;
72+ baseUrl: string;
73+ mcpTransport: "auto" | "streamable-http" | "sse";
74+ mcpActorQueryParamKey: string;
75+ actorId?: string;
76+}): Record<string, unknown> {
77+ if (type === "mcp") {
78+ return {
79+ url: endpoint,
80+ ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}),
81+ ...(mcpActorQueryParamKey.trim() && actorId
82+ ? { queryParams: { [mcpActorQueryParamKey.trim()]: actorId } }
83+ : {}),
84+ };
85+ }
86+
87+ if (type === "graphql") {
88+ return { endpoint };
89+ }
90+
91+ return { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) };
92+}
executor/apps/web/src/components/tools-view-add-source-dialog-sections.tsx+264−0
@@ -0,0 +1,264 @@
1+import { ChevronRight, Plus } from "lucide-react";
2+import Image from "next/image";
3+import { Streamdown } from "streamdown";
4+import { Button } from "@/components/ui/button";
5+import { Input } from "@/components/ui/input";
6+import { Label } from "@/components/ui/label";
7+import {
8+ Select,
9+ SelectContent,
10+ SelectItem,
11+ SelectTrigger,
12+ SelectValue,
13+} from "@/components/ui/select";
14+import { Separator } from "@/components/ui/separator";
15+import type { CatalogCollectionItem } from "@/lib/catalog-collections";
16+import {
17+ endpointLabelForType,
18+ endpointPlaceholderForType,
19+ type SourceCatalogSort,
20+ type SourceType,
21+} from "./tools-view-add-source-dialog-helpers";
22+
23+export function CatalogViewSection({
24+ catalogQuery,
25+ onCatalogQueryChange,
26+ catalogSort,
27+ onCatalogSortChange,
28+ visibleCatalogItems,
29+ addingCatalogId,
30+ onSwitchToCustom,
31+ onAddCatalog,
32+}: {
33+ catalogQuery: string;
34+ onCatalogQueryChange: (value: string) => void;
35+ catalogSort: SourceCatalogSort;
36+ onCatalogSortChange: (value: SourceCatalogSort) => void;
37+ visibleCatalogItems: CatalogCollectionItem[];
38+ addingCatalogId: string | null;
39+ onSwitchToCustom: () => void;
40+ onAddCatalog: (item: CatalogCollectionItem) => void;
41+}) {
42+ return (
43+ <div className="space-y-3">
44+ <div className="flex items-center gap-2 flex-wrap">
45+ <Input
46+ value={catalogQuery}
47+ onChange={(event) => onCatalogQueryChange(event.target.value)}
48+ placeholder="Search APIs"
49+ className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]"
50+ />
51+ <Select value={catalogSort} onValueChange={(value) => onCatalogSortChange(value as SourceCatalogSort)}>
52+ <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0">
53+ <SelectValue />
54+ </SelectTrigger>
55+ <SelectContent>
56+ <SelectItem value="popular" className="text-xs">Popular</SelectItem>
57+ <SelectItem value="recent" className="text-xs">Recent</SelectItem>
58+ </SelectContent>
59+ </Select>
60+ </div>
61+
62+ <p className="text-[11px] text-muted-foreground">
63+ Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}.
64+ </p>
65+
66+ <Separator />
67+
68+ <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1">
69+ <button
70+ type="button"
71+ onClick={onSwitchToCustom}
72+ className="w-full max-w-full overflow-hidden text-left px-3 py-2 rounded-md border border-border/70 bg-muted/40 hover:bg-accent/30 transition-colors"
73+ >
74+ <div className="flex items-center gap-2">
75+ <span className="inline-flex h-6 w-6 items-center justify-center rounded bg-muted">
76+ <Plus className="h-3.5 w-3.5 text-muted-foreground" />
77+ </span>
78+ <div className="min-w-0 w-0 flex-1 overflow-hidden">
79+ <p className="text-xs font-medium">Add custom source</p>
80+ <p className="text-[10px] text-muted-foreground">MCP, OpenAPI, or GraphQL endpoint</p>
81+ </div>
82+ </div>
83+ </button>
84+
85+ {visibleCatalogItems.map((item) => (
86+ <div
87+ key={item.id}
88+ className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50"
89+ >
90+ {item.logoUrl && (
91+ <Image
92+ src={item.logoUrl}
93+ alt=""
94+ width={20}
95+ height={20}
96+ className="w-5 h-5 rounded shrink-0 mt-0.5 object-contain"
97+ loading="lazy"
98+ unoptimized
99+ />
100+ )}
101+ <div className="flex-1 min-w-0 w-0 overflow-hidden">
102+ <p className="text-xs font-medium truncate">{item.name}</p>
103+ <p className="text-[10px] text-muted-foreground truncate">
104+ {item.providerName}
105+ {item.version ? ` · v${item.version}` : ""}
106+ </p>
107+ {item.summary && (
108+ <div
109+ className="mt-0.5 w-full min-w-0 max-w-full overflow-hidden text-[10px] text-muted-foreground/90 leading-relaxed break-words [overflow-wrap:anywhere] [&_*]:max-w-full [&_*]:min-w-0 [&_*]:break-words [&_a]:break-all [&_a]:whitespace-normal [&_code]:break-all [&_code]:whitespace-pre-wrap [&_p]:m-0 [&_pre]:max-w-full [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:break-words line-clamp-2"
110+ style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}
111+ >
112+ <Streamdown controls={false}>{item.summary}</Streamdown>
113+ </div>
114+ )}
115+ </div>
116+ <div className="flex items-center gap-1 shrink-0">
117+ <Button
118+ type="button"
119+ size="sm"
120+ className="h-7 px-2 text-[11px]"
121+ onClick={() => onAddCatalog(item)}
122+ disabled={Boolean(addingCatalogId)}
123+ >
124+ {addingCatalogId === item.id ? "Adding..." : "Add"}
125+ </Button>
126+ </div>
127+ </div>
128+ ))}
129+
130+ {visibleCatalogItems.length === 0 && (
131+ <p className="text-[11px] text-muted-foreground px-1 py-1">
132+ No collections found for this query.
133+ </p>
134+ )}
135+ </div>
136+ </div>
137+ );
138+}
139+
140+export function CustomViewSection({
141+ type,
142+ onTypeChange,
143+ endpoint,
144+ onEndpointChange,
145+ name,
146+ onNameChange,
147+ baseUrl,
148+ onBaseUrlChange,
149+ mcpTransport,
150+ onMcpTransportChange,
151+ mcpActorQueryParamKey,
152+ onMcpActorQueryParamKeyChange,
153+ submitting,
154+ submitDisabled,
155+ onBackToCatalog,
156+ onSubmit,
157+}: {
158+ type: SourceType;
159+ onTypeChange: (value: SourceType) => void;
160+ endpoint: string;
161+ onEndpointChange: (value: string) => void;
162+ name: string;
163+ onNameChange: (value: string) => void;
164+ baseUrl: string;
165+ onBaseUrlChange: (value: string) => void;
166+ mcpTransport: "auto" | "streamable-http" | "sse";
167+ onMcpTransportChange: (value: "auto" | "streamable-http" | "sse") => void;
168+ mcpActorQueryParamKey: string;
169+ onMcpActorQueryParamKeyChange: (value: string) => void;
170+ submitting: boolean;
171+ submitDisabled: boolean;
172+ onBackToCatalog: () => void;
173+ onSubmit: () => void;
174+}) {
175+ return (
176+ <div className="space-y-3">
177+ <Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onBackToCatalog}>
178+ <ChevronRight className="h-3.5 w-3.5 mr-1 rotate-180" />
179+ Back to API list
180+ </Button>
181+
182+ <div className="space-y-1.5">
183+ <Label className="text-xs text-muted-foreground">Type</Label>
184+ <Select value={type} onValueChange={(value) => onTypeChange(value as SourceType)}>
185+ <SelectTrigger className="h-8 text-xs bg-background">
186+ <SelectValue />
187+ </SelectTrigger>
188+ <SelectContent>
189+ <SelectItem value="mcp" className="text-xs">MCP Server</SelectItem>
190+ <SelectItem value="openapi" className="text-xs">OpenAPI Spec</SelectItem>
191+ <SelectItem value="graphql" className="text-xs">GraphQL</SelectItem>
192+ </SelectContent>
193+ </Select>
194+ </div>
195+
196+ <div className="space-y-1.5">
197+ <Label className="text-xs text-muted-foreground">{endpointLabelForType(type)}</Label>
198+ <Input
199+ value={endpoint}
200+ onChange={(event) => onEndpointChange(event.target.value)}
201+ placeholder={endpointPlaceholderForType(type)}
202+ className="h-8 text-xs font-mono bg-background"
203+ />
204+ </div>
205+
206+ <div className="space-y-1.5">
207+ <Label className="text-xs text-muted-foreground">Name</Label>
208+ <Input
209+ value={name}
210+ onChange={(event) => onNameChange(event.target.value)}
211+ placeholder="e.g. my-service"
212+ className="h-8 text-xs font-mono bg-background"
213+ />
214+ </div>
215+
216+ {type === "openapi" && (
217+ <div className="space-y-1.5">
218+ <Label className="text-xs text-muted-foreground">Base URL (optional)</Label>
219+ <Input
220+ value={baseUrl}
221+ onChange={(event) => onBaseUrlChange(event.target.value)}
222+ placeholder="https://api.example.com"
223+ className="h-8 text-xs font-mono bg-background"
224+ />
225+ </div>
226+ )}
227+
228+ {type === "mcp" && (
229+ <>
230+ <div className="space-y-1.5">
231+ <Label className="text-xs text-muted-foreground">Transport</Label>
232+ <Select
233+ value={mcpTransport}
234+ onValueChange={(value) => onMcpTransportChange(value as "auto" | "streamable-http" | "sse")}
235+ >
236+ <SelectTrigger className="h-8 text-xs bg-background">
237+ <SelectValue />
238+ </SelectTrigger>
239+ <SelectContent>
240+ <SelectItem value="auto" className="text-xs">Auto (streamable, then SSE)</SelectItem>
241+ <SelectItem value="streamable-http" className="text-xs">Streamable HTTP</SelectItem>
242+ <SelectItem value="sse" className="text-xs">SSE</SelectItem>
243+ </SelectContent>
244+ </Select>
245+ </div>
246+
247+ <div className="space-y-1.5">
248+ <Label className="text-xs text-muted-foreground">Anon actor query key (optional)</Label>
249+ <Input
250+ value={mcpActorQueryParamKey}
251+ onChange={(event) => onMcpActorQueryParamKeyChange(event.target.value)}
252+ placeholder="userId"
253+ className="h-8 text-xs font-mono bg-background"
254+ />
255+ </div>
256+ </>
257+ )}
258+
259+ <Button onClick={onSubmit} disabled={submitDisabled} className="w-full h-9" size="sm">
260+ {submitting ? "Adding..." : "Add Source"}
261+ </Button>
262+ </div>
263+ );
264+}
executor/apps/web/src/components/tools-view-add-source-dialog.tsx+242−0
@@ -0,0 +1,242 @@
1+"use client";
2+
3+import { useMemo, useState } from "react";
4+import { Plus } from "lucide-react";
5+import { useMutation } from "convex/react";
6+import { toast } from "sonner";
7+import { Button } from "@/components/ui/button";
8+import {
9+ Dialog,
10+ DialogContent,
11+ DialogHeader,
12+ DialogTitle,
13+ DialogTrigger,
14+} from "@/components/ui/dialog";
15+import { useSession } from "@/lib/session-context";
16+import { convexApi } from "@/lib/convex-api";
17+import type { ToolSourceRecord } from "@/lib/types";
18+import {
19+ type CatalogCollectionItem,
20+} from "@/lib/catalog-collections";
21+import {
22+ catalogSourceName,
23+ inferNameFromUrl,
24+ withUniqueSourceName,
25+} from "@/lib/tools-source-helpers";
26+import {
27+ createCustomSourceConfig,
28+ DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY,
29+ getVisibleCatalogItems,
30+ type SourceCatalogSort,
31+ type SourceType,
32+} from "./tools-view-add-source-dialog-helpers";
33+import {
34+ CatalogViewSection,
35+ CustomViewSection,
36+} from "./tools-view-add-source-dialog-sections";
37+
38+export function AddSourceDialog({
39+ existingSourceNames,
40+ onSourceAdded,
41+}: {
42+ existingSourceNames: Set<string>;
43+ onSourceAdded?: (source: ToolSourceRecord) => void;
44+}) {
45+ const { context } = useSession();
46+ const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
47+ const [open, setOpen] = useState(false);
48+ const [view, setView] = useState<"catalog" | "custom">("catalog");
49+ const [type, setType] = useState<SourceType>("mcp");
50+ const [name, setName] = useState("");
51+ const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
52+ const [endpoint, setEndpoint] = useState("");
53+ const [baseUrl, setBaseUrl] = useState("");
54+ const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto");
55+ const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState(
56+ DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY,
57+ );
58+ const [submitting, setSubmitting] = useState(false);
59+ const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]);
60+ const [catalogQuery, setCatalogQuery] = useState("");
61+ const [catalogSort, setCatalogSort] = useState<SourceCatalogSort>("popular");
62+ const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null);
63+
64+ const visibleCatalogItems = useMemo(
65+ () => getVisibleCatalogItems(catalogQuery, catalogSort),
66+ [catalogQuery, catalogSort],
67+ );
68+
69+ const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]);
70+
71+ const reserveSourceName = (sourceName: string) => {
72+ setLocallyReservedNames((current) =>
73+ current.includes(sourceName)
74+ ? current
75+ : [...current, sourceName],
76+ );
77+ };
78+
79+ const getUniqueAutoSourceName = (candidate: string) => {
80+ return withUniqueSourceName(candidate, getTakenSourceNames());
81+ };
82+
83+ const handleEndpointChange = (value: string) => {
84+ setEndpoint(value);
85+ if (!nameManuallyEdited) {
86+ const inferred = inferNameFromUrl(value);
87+ if (inferred) {
88+ setName(inferred);
89+ }
90+ }
91+ };
92+
93+ const handleNameChange = (value: string) => {
94+ setName(value);
95+ setNameManuallyEdited(true);
96+ };
97+
98+ const resetDialogState = () => {
99+ setView("catalog");
100+ setType("mcp");
101+ setName("");
102+ setEndpoint("");
103+ setBaseUrl("");
104+ setMcpTransport("auto");
105+ setMcpActorQueryParamKey(DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY);
106+ setNameManuallyEdited(false);
107+ setLocallyReservedNames([]);
108+ setCatalogQuery("");
109+ setCatalogSort("popular");
110+ setAddingCatalogId(null);
111+ };
112+
113+ const handleOpenChange = (isOpen: boolean) => {
114+ setOpen(isOpen);
115+ if (!isOpen) {
116+ resetDialogState();
117+ }
118+ };
119+
120+ const addSource = async (
121+ sourceName: string,
122+ sourceType: "mcp" | "openapi" | "graphql",
123+ config: Record<string, unknown>,
124+ ) => {
125+ if (!context) {
126+ return;
127+ }
128+ const created = await upsertToolSource({
129+ workspaceId: context.workspaceId,
130+ sessionId: context.sessionId,
131+ name: sourceName,
132+ type: sourceType,
133+ config,
134+ });
135+ onSourceAdded?.(created as ToolSourceRecord);
136+ toast.success(`Source "${sourceName}" added — loading tools…`);
137+ };
138+
139+ const handleCatalogAdd = async (item: CatalogCollectionItem) => {
140+ if (!item.specUrl.trim()) {
141+ toast.error("Missing OpenAPI spec URL for this API source");
142+ return;
143+ }
144+
145+ setAddingCatalogId(item.id);
146+ try {
147+ const sourceName = getUniqueAutoSourceName(catalogSourceName(item));
148+ await addSource(sourceName, "openapi", {
149+ spec: item.specUrl,
150+ });
151+ reserveSourceName(sourceName);
152+ } catch (error) {
153+ toast.error(error instanceof Error ? error.message : "Failed to add API source");
154+ } finally {
155+ setAddingCatalogId(null);
156+ }
157+ };
158+
159+ const handleCustomSubmit = async () => {
160+ if (!context || !name.trim() || !endpoint.trim()) {
161+ return;
162+ }
163+
164+ const takenNames = [...getTakenSourceNames()].map((entry) => entry.toLowerCase());
165+ if (takenNames.includes(name.trim().toLowerCase())) {
166+ toast.error(`Source name "${name.trim()}" already exists`);
167+ return;
168+ }
169+
170+ setSubmitting(true);
171+ try {
172+ const config = createCustomSourceConfig({
173+ type,
174+ endpoint,
175+ baseUrl,
176+ mcpTransport,
177+ mcpActorQueryParamKey,
178+ actorId: context.actorId,
179+ });
180+ await addSource(name.trim(), type, config);
181+ reserveSourceName(name.trim());
182+ resetDialogState();
183+ setOpen(false);
184+ } catch (err) {
185+ toast.error(err instanceof Error ? err.message : "Failed to add source");
186+ } finally {
187+ setSubmitting(false);
188+ }
189+ };
190+
191+ return (
192+ <Dialog open={open} onOpenChange={handleOpenChange}>
193+ <DialogTrigger asChild>
194+ <Button size="sm" className="h-8 text-xs">
195+ <Plus className="h-3.5 w-3.5 mr-1.5" />
196+ Add Source
197+ </Button>
198+ </DialogTrigger>
199+ <DialogContent className="bg-card border-border sm:max-w-md p-0 gap-0">
200+ <DialogHeader className="px-5 pt-5 pb-0">
201+ <DialogTitle className="text-sm font-medium">
202+ Add Tool Source
203+ </DialogTitle>
204+ </DialogHeader>
205+
206+ <div className="p-5 space-y-4">
207+ {view === "catalog" ? (
208+ <CatalogViewSection
209+ catalogQuery={catalogQuery}
210+ onCatalogQueryChange={setCatalogQuery}
211+ catalogSort={catalogSort}
212+ onCatalogSortChange={setCatalogSort}
213+ visibleCatalogItems={visibleCatalogItems}
214+ addingCatalogId={addingCatalogId}
215+ onSwitchToCustom={() => setView("custom")}
216+ onAddCatalog={(item) => void handleCatalogAdd(item)}
217+ />
218+ ) : (
219+ <CustomViewSection
220+ type={type}
221+ onTypeChange={setType}
222+ endpoint={endpoint}
223+ onEndpointChange={handleEndpointChange}
224+ name={name}
225+ onNameChange={handleNameChange}
226+ baseUrl={baseUrl}
227+ onBaseUrlChange={setBaseUrl}
228+ mcpTransport={mcpTransport}
229+ onMcpTransportChange={setMcpTransport}
230+ mcpActorQueryParamKey={mcpActorQueryParamKey}
231+ onMcpActorQueryParamKeyChange={setMcpActorQueryParamKey}
232+ submitting={submitting}
233+ submitDisabled={submitting || !name.trim() || !endpoint.trim()}
234+ onBackToCatalog={() => setView("catalog")}
235+ onSubmit={handleCustomSubmit}
236+ />
237+ )}
238+ </div>
239+ </DialogContent>
240+ </Dialog>
241+ );
242+}
executor/apps/web/src/components/tools-view-connection-auth-fields.tsx+101−0
@@ -0,0 +1,101 @@
1+"use client";
2+
3+import { Input } from "@/components/ui/input";
4+import { Label } from "@/components/ui/label";
5+import type { SourceAuthType } from "@/lib/types";
6+import type { ConnectionMode } from "./tools-view-connection-form-dialog-state";
7+
8+type SelectedAuth = {
9+ type: SourceAuthType;
10+};
11+
12+type ConnectionAuthFieldsProps = {
13+ editing: boolean;
14+ connectionMode: ConnectionMode;
15+ selectedAuth: SelectedAuth;
16+ tokenValue: string;
17+ apiKeyValue: string;
18+ basicUsername: string;
19+ basicPassword: string;
20+ onTokenValueChange: (value: string) => void;
21+ onApiKeyValueChange: (value: string) => void;
22+ onBasicUsernameChange: (value: string) => void;
23+ onBasicPasswordChange: (value: string) => void;
24+};
25+
26+export function ConnectionAuthFields({
27+ editing,
28+ connectionMode,
29+ selectedAuth,
30+ tokenValue,
31+ apiKeyValue,
32+ basicUsername,
33+ basicPassword,
34+ onTokenValueChange,
35+ onApiKeyValueChange,
36+ onBasicUsernameChange,
37+ onBasicPasswordChange,
38+}: ConnectionAuthFieldsProps) {
39+ if (!editing && connectionMode !== "new") {
40+ return null;
41+ }
42+
43+ return (
44+ <>
45+ {editing && (
46+ <p className="text-[11px] text-muted-foreground">
47+ Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it.
48+ </p>
49+ )}
50+
51+ {selectedAuth.type === "none" ? (
52+ <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p>
53+ ) : selectedAuth.type === "mixed" ? (
54+ <p className="text-[11px] text-terminal-amber">
55+ This source has mixed auth requirements. Link an existing connection for now.
56+ </p>
57+ ) : selectedAuth.type === "apiKey" ? (
58+ <div className="space-y-1.5">
59+ <Label className="text-xs text-muted-foreground">API Key Value</Label>
60+ <Input
61+ value={apiKeyValue}
62+ onChange={(e) => onApiKeyValueChange(e.target.value)}
63+ placeholder="sk_live_..."
64+ className="h-8 text-xs font-mono bg-background"
65+ />
66+ </div>
67+ ) : selectedAuth.type === "basic" ? (
68+ <div className="grid grid-cols-2 gap-3">
69+ <div className="space-y-1.5">
70+ <Label className="text-xs text-muted-foreground">Username</Label>
71+ <Input
72+ value={basicUsername}
73+ onChange={(e) => onBasicUsernameChange(e.target.value)}
74+ className="h-8 text-xs font-mono bg-background"
75+ />
76+ </div>
77+ <div className="space-y-1.5">
78+ <Label className="text-xs text-muted-foreground">Password</Label>
79+ <Input
80+ type="password"
81+ value={basicPassword}
82+ onChange={(e) => onBasicPasswordChange(e.target.value)}
83+ className="h-8 text-xs font-mono bg-background"
84+ />
85+ </div>
86+ </div>
87+ ) : (
88+ <div className="space-y-1.5">
89+ <Label className="text-xs text-muted-foreground">Bearer Token</Label>
90+ <Input
91+ type="password"
92+ value={tokenValue}
93+ onChange={(e) => onTokenValueChange(e.target.value)}
94+ placeholder="ghp_..."
95+ className="h-8 text-xs font-mono bg-background"
96+ />
97+ </div>
98+ )}
99+ </>
100+ );
101+}
executor/apps/web/src/components/tools-view-connection-form-dialog-form.ts+257−0
@@ -0,0 +1,257 @@
1+import { useEffect, useMemo, useReducer } from "react";
2+import type {
3+ CredentialRecord,
4+ CredentialScope,
5+ SourceAuthProfile,
6+ ToolSourceRecord,
7+} from "@/lib/types";
8+import {
9+ formatHeaderOverrides,
10+ sourceAuthForKey,
11+} from "@/lib/credentials-source-helpers";
12+import {
13+ buildConnectionOptions,
14+ buildSourceOptions,
15+ compatibleConnections,
16+ selectedAuthBadge,
17+ type ConnectionMode,
18+} from "./tools-view-connection-form-dialog-state";
19+
20+type UseConnectionFormDialogFormParams = {
21+ open: boolean;
22+ editing: CredentialRecord | null;
23+ initialSourceKey?: string | null;
24+ sources: ToolSourceRecord[];
25+ credentials: CredentialRecord[];
26+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
27+ actorIdFallback?: string;
28+};
29+
30+type FormState = {
31+ sourceKey: string;
32+ scope: CredentialScope;
33+ actorId: string;
34+ connectionMode: ConnectionMode;
35+ existingConnectionId: string;
36+ tokenValue: string;
37+ apiKeyValue: string;
38+ basicUsername: string;
39+ basicPassword: string;
40+ customHeadersText: string;
41+};
42+
43+type FormAction =
44+ | { type: "patch"; patch: Partial<FormState> }
45+ | { type: "reset"; next: FormState };
46+
47+function formReducer(state: FormState, action: FormAction): FormState {
48+ if (action.type === "reset") {
49+ return action.next;
50+ }
51+ return { ...state, ...action.patch };
52+}
53+
54+function initialFormState({
55+ editing,
56+ initialSourceKey,
57+ sourceOptions,
58+ sourceAuthProfiles,
59+ actorIdFallback,
60+}: {
61+ editing: CredentialRecord | null;
62+ initialSourceKey?: string | null;
63+ sourceOptions: ReturnType<typeof buildSourceOptions>;
64+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
65+ actorIdFallback?: string;
66+}): FormState {
67+ if (editing) {
68+ return {
69+ sourceKey: editing.sourceKey,
70+ scope: editing.scope,
71+ actorId: editing.actorId ?? actorIdFallback ?? "",
72+ connectionMode: "new",
73+ existingConnectionId: editing.id,
74+ tokenValue: "",
75+ apiKeyValue: "",
76+ basicUsername: "",
77+ basicPassword: "",
78+ customHeadersText: formatHeaderOverrides(editing.overridesJson),
79+ };
80+ }
81+
82+ const resolvedSourceKey = initialSourceKey ?? sourceOptions[0]?.key ?? "";
83+ const auth = sourceAuthForKey(sourceOptions, resolvedSourceKey, sourceAuthProfiles);
84+ return {
85+ sourceKey: resolvedSourceKey,
86+ scope: auth.mode ?? "workspace",
87+ actorId: actorIdFallback ?? "",
88+ connectionMode: "new",
89+ existingConnectionId: "",
90+ tokenValue: "",
91+ apiKeyValue: "",
92+ basicUsername: "",
93+ basicPassword: "",
94+ customHeadersText: "",
95+ };
96+}
97+
98+export function useConnectionFormDialogForm({
99+ open,
100+ editing,
101+ initialSourceKey,
102+ sources,
103+ credentials,
104+ sourceAuthProfiles,
105+ actorIdFallback,
106+}: UseConnectionFormDialogFormParams) {
107+ const sourceOptions = useMemo(() => buildSourceOptions(sources), [sources]);
108+ const connectionOptions = useMemo(() => buildConnectionOptions(credentials), [credentials]);
109+ const [form, dispatch] = useReducer(
110+ formReducer,
111+ initialFormState({
112+ editing,
113+ initialSourceKey,
114+ sourceOptions,
115+ sourceAuthProfiles,
116+ actorIdFallback,
117+ }),
118+ );
119+
120+ const {
121+ sourceKey,
122+ scope,
123+ actorId,
124+ connectionMode,
125+ existingConnectionId,
126+ tokenValue,
127+ apiKeyValue,
128+ basicUsername,
129+ basicPassword,
130+ customHeadersText,
131+ } = form;
132+ const compatibleConnectionOptions = useMemo(
133+ () => compatibleConnections(connectionOptions, scope, actorId),
134+ [actorId, connectionOptions, scope],
135+ );
136+ const selectedAuth = useMemo(
137+ () => sourceAuthForKey(sourceOptions, sourceKey, sourceAuthProfiles),
138+ [sourceAuthProfiles, sourceKey, sourceOptions],
139+ );
140+ const authBadge = useMemo(
141+ () => selectedAuthBadge(selectedAuth.type, selectedAuth.mode),
142+ [selectedAuth.mode, selectedAuth.type],
143+ );
144+
145+ useEffect(() => {
146+ if (!existingConnectionId) {
147+ return;
148+ }
149+ if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) {
150+ dispatch({ type: "patch", patch: { existingConnectionId: "" } });
151+ }
152+ }, [compatibleConnectionOptions, existingConnectionId]);
153+
154+ useEffect(() => {
155+ if (!open) {
156+ return;
157+ }
158+
159+ if (editing) {
160+ dispatch({
161+ type: "reset",
162+ next: initialFormState({
163+ editing,
164+ initialSourceKey,
165+ sourceOptions,
166+ sourceAuthProfiles,
167+ actorIdFallback,
168+ }),
169+ });
170+ return;
171+ }
172+
173+ dispatch({
174+ type: "reset",
175+ next: initialFormState({
176+ editing: null,
177+ initialSourceKey,
178+ sourceOptions,
179+ sourceAuthProfiles,
180+ actorIdFallback,
181+ }),
182+ });
183+ }, [actorIdFallback, editing, initialSourceKey, open, sourceAuthProfiles, sourceOptions]);
184+
185+ const handleSourceKeyChange = (nextSourceKey: string) => {
186+ const patch: Partial<FormState> = { sourceKey: nextSourceKey };
187+ const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles);
188+ if (!editing) {
189+ patch.scope = auth.mode ?? "workspace";
190+ }
191+ dispatch({ type: "patch", patch });
192+ };
193+
194+ const setScope = (nextScope: CredentialScope) => {
195+ dispatch({ type: "patch", patch: { scope: nextScope } });
196+ };
197+
198+ const setActorId = (nextActorId: string) => {
199+ dispatch({ type: "patch", patch: { actorId: nextActorId } });
200+ };
201+
202+ const setConnectionMode = (nextMode: ConnectionMode) => {
203+ dispatch({ type: "patch", patch: { connectionMode: nextMode } });
204+ };
205+
206+ const setExistingConnectionId = (nextConnectionId: string) => {
207+ dispatch({ type: "patch", patch: { existingConnectionId: nextConnectionId } });
208+ };
209+
210+ const setTokenValue = (nextToken: string) => {
211+ dispatch({ type: "patch", patch: { tokenValue: nextToken } });
212+ };
213+
214+ const setApiKeyValue = (nextApiKeyValue: string) => {
215+ dispatch({ type: "patch", patch: { apiKeyValue: nextApiKeyValue } });
216+ };
217+
218+ const setBasicUsername = (nextBasicUsername: string) => {
219+ dispatch({ type: "patch", patch: { basicUsername: nextBasicUsername } });
220+ };
221+
222+ const setBasicPassword = (nextBasicPassword: string) => {
223+ dispatch({ type: "patch", patch: { basicPassword: nextBasicPassword } });
224+ };
225+
226+ const setCustomHeadersText = (nextCustomHeadersText: string) => {
227+ dispatch({ type: "patch", patch: { customHeadersText: nextCustomHeadersText } });
228+ };
229+
230+ return {
231+ sourceKey,
232+ scope,
233+ actorId,
234+ connectionMode,
235+ existingConnectionId,
236+ tokenValue,
237+ apiKeyValue,
238+ basicUsername,
239+ basicPassword,
240+ customHeadersText,
241+ sourceOptions,
242+ connectionOptions,
243+ compatibleConnectionOptions,
244+ selectedAuth,
245+ authBadge,
246+ setScope,
247+ setActorId,
248+ setConnectionMode,
249+ setExistingConnectionId,
250+ setTokenValue,
251+ setApiKeyValue,
252+ setBasicUsername,
253+ setBasicPassword,
254+ setCustomHeadersText,
255+ handleSourceKeyChange,
256+ };
257+}
executor/apps/web/src/components/tools-view-connection-form-dialog-state.ts+82−0
@@ -0,0 +1,82 @@
1+import type {
2+ CredentialRecord,
3+ CredentialScope,
4+ SourceAuthType,
5+ ToolSourceRecord,
6+} from "@/lib/types";
7+import {
8+ sourceOptionLabel,
9+ type SourceOption,
10+} from "@/lib/credentials-source-helpers";
11+import { sourceKeyForSource } from "@/lib/tools-source-helpers";
12+
13+export type ConnectionMode = "new" | "existing";
14+
15+export type ConnectionOption = {
16+ id: string;
17+ scope: CredentialScope;
18+ actorId?: string;
19+ sourceKeys: Set<string>;
20+ updatedAt: number;
21+};
22+
23+export function buildSourceOptions(sources: ToolSourceRecord[]): SourceOption[] {
24+ return sources
25+ .map((source) => {
26+ const key = sourceKeyForSource(source);
27+ return {
28+ source,
29+ key,
30+ label: sourceOptionLabel(source),
31+ };
32+ })
33+ .filter((entry): entry is SourceOption => entry.key !== null);
34+}
35+
36+export function buildConnectionOptions(credentials: CredentialRecord[]): ConnectionOption[] {
37+ const grouped = new Map<string, ConnectionOption>();
38+
39+ for (const credential of credentials) {
40+ const existing = grouped.get(credential.id);
41+ if (existing) {
42+ existing.sourceKeys.add(credential.sourceKey);
43+ existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt);
44+ } else {
45+ grouped.set(credential.id, {
46+ id: credential.id,
47+ scope: credential.scope,
48+ actorId: credential.actorId,
49+ sourceKeys: new Set([credential.sourceKey]),
50+ updatedAt: credential.updatedAt,
51+ });
52+ }
53+ }
54+
55+ return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt);
56+}
57+
58+export function compatibleConnections(
59+ options: ConnectionOption[],
60+ scope: CredentialScope,
61+ actorId: string,
62+): ConnectionOption[] {
63+ return options.filter((connection) => {
64+ if (connection.scope !== scope) {
65+ return false;
66+ }
67+ if (scope === "actor") {
68+ return connection.actorId === actorId.trim();
69+ }
70+ return true;
71+ });
72+}
73+
74+export function selectedAuthBadge(type: SourceAuthType, mode?: CredentialScope): string {
75+ if (type === "none") {
76+ return "none";
77+ }
78+ if (type === "mixed") {
79+ return "mixed";
80+ }
81+ return `${type}:${mode ?? "workspace"}`;
82+}
executor/apps/web/src/components/tools-view-connection-form-dialog.tsx+334−0
@@ -0,0 +1,334 @@
1+"use client";
2+
3+import { useState } from "react";
4+import { useAction } from "convex/react";
5+import { toast } from "sonner";
6+import { Button } from "@/components/ui/button";
7+import { Input } from "@/components/ui/input";
8+import { Label } from "@/components/ui/label";
9+import { Textarea } from "@/components/ui/textarea";
10+import { Badge } from "@/components/ui/badge";
11+import {
12+ Select,
13+ SelectContent,
14+ SelectItem,
15+ SelectTrigger,
16+ SelectValue,
17+} from "@/components/ui/select";
18+import {
19+ Dialog,
20+ DialogContent,
21+ DialogHeader,
22+ DialogTitle,
23+} from "@/components/ui/dialog";
24+import { useSession } from "@/lib/session-context";
25+import { convexApi } from "@/lib/convex-api";
26+import type {
27+ CredentialRecord,
28+ CredentialScope,
29+ SourceAuthProfile,
30+ ToolSourceRecord,
31+} from "@/lib/types";
32+import {
33+ connectionDisplayName,
34+ parseHeaderOverrides,
35+} from "@/lib/credentials-source-helpers";
36+import { ConnectionAuthFields } from "./tools-view-connection-auth-fields";
37+import { type ConnectionMode } from "./tools-view-connection-form-dialog-state";
38+import { useConnectionFormDialogForm } from "./tools-view-connection-form-dialog-form";
39+import {
40+ buildSecretJson,
41+ connectionSubmitCopy,
42+ connectionSuccessCopy,
43+} from "./tools-view-connection-form-save";
44+
45+export function ConnectionFormDialog({
46+ open,
47+ onOpenChange,
48+ editing,
49+ initialSourceKey,
50+ sources,
51+ credentials,
52+ sourceAuthProfiles,
53+}: {
54+ open: boolean;
55+ onOpenChange: (open: boolean) => void;
56+ editing: CredentialRecord | null;
57+ initialSourceKey?: string | null;
58+ sources: ToolSourceRecord[];
59+ credentials: CredentialRecord[];
60+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
61+}) {
62+ const { context, clientConfig } = useSession();
63+ const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
64+ const [saving, setSaving] = useState(false);
65+ const {
66+ sourceKey,
67+ scope,
68+ actorId,
69+ connectionMode,
70+ existingConnectionId,
71+ tokenValue,
72+ apiKeyValue,
73+ basicUsername,
74+ basicPassword,
75+ customHeadersText,
76+ sourceOptions,
77+ connectionOptions,
78+ compatibleConnectionOptions,
79+ selectedAuth,
80+ authBadge,
81+ setScope,
82+ setActorId,
83+ setConnectionMode,
84+ setExistingConnectionId,
85+ setTokenValue,
86+ setApiKeyValue,
87+ setBasicUsername,
88+ setBasicPassword,
89+ setCustomHeadersText,
90+ handleSourceKeyChange,
91+ } = useConnectionFormDialogForm({
92+ open,
93+ editing,
94+ initialSourceKey,
95+ sources,
96+ credentials,
97+ sourceAuthProfiles,
98+ actorIdFallback: context?.actorId,
99+ });
100+
101+ const storageCopy = clientConfig?.authProviderMode === "workos"
102+ ? "Stored encrypted"
103+ : "Stored locally on this machine";
104+
105+ const handleSave = async () => {
106+ if (!context) {
107+ return;
108+ }
109+ if (!sourceKey.trim()) {
110+ toast.error("Source key is required");
111+ return;
112+ }
113+ if (scope === "actor" && !actorId.trim()) {
114+ toast.error("Actor ID is required for actor-scoped credentials");
115+ return;
116+ }
117+
118+ const parsedHeaders = parseHeaderOverrides(customHeadersText);
119+ if (!parsedHeaders.value) {
120+ toast.error(parsedHeaders.error ?? "Invalid header overrides");
121+ return;
122+ }
123+
124+ const linkExisting = !editing && connectionMode === "existing";
125+ if (linkExisting && !existingConnectionId) {
126+ toast.error("Select an existing connection to link");
127+ return;
128+ }
129+ if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) {
130+ toast.error("Selected connection does not match this scope");
131+ return;
132+ }
133+
134+ if (selectedAuth.type === "none") {
135+ toast.error("This source does not require auth");
136+ return;
137+ }
138+
139+ if (selectedAuth.type === "mixed" && !linkExisting && !editing) {
140+ toast.error("Mixed-auth sources must link to an existing connection");
141+ return;
142+ }
143+
144+ const secretResult = buildSecretJson({
145+ selectedAuthType: selectedAuth.type,
146+ linkExisting,
147+ editing: Boolean(editing),
148+ basicUsername,
149+ basicPassword,
150+ apiKeyValue,
151+ tokenValue,
152+ parsedHeaders: parsedHeaders.value,
153+ });
154+
155+ if (!secretResult.secretJson) {
156+ toast.error(secretResult.error ?? "Failed to prepare secret payload");
157+ return;
158+ }
159+
160+ setSaving(true);
161+ try {
162+ await upsertCredential({
163+ ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}),
164+ workspaceId: context.workspaceId,
165+ sessionId: context.sessionId,
166+ sourceKey: sourceKey.trim(),
167+ scope,
168+ ...(scope === "actor" ? { actorId: actorId.trim() } : {}),
169+ secretJson: secretResult.secretJson,
170+ });
171+
172+ toast.success(connectionSuccessCopy(Boolean(editing), linkExisting));
173+ onOpenChange(false);
174+ } catch (err) {
175+ toast.error(err instanceof Error ? err.message : "Failed to save connection");
176+ } finally {
177+ setSaving(false);
178+ }
179+ };
180+
181+ return (
182+ <Dialog open={open} onOpenChange={onOpenChange}>
183+ <DialogContent className="bg-card border-border sm:max-w-lg">
184+ <DialogHeader>
185+ <DialogTitle className="text-sm font-medium">
186+ {editing ? "Edit Connection" : "Add Connection"}
187+ </DialogTitle>
188+ </DialogHeader>
189+ <div className="space-y-3">
190+ <div className="space-y-1.5">
191+ <Label className="text-xs text-muted-foreground">Source</Label>
192+ {sourceOptions.length > 0 ? (
193+ <Select value={sourceKey} onValueChange={handleSourceKeyChange}>
194+ <SelectTrigger className="h-8 text-xs bg-background">
195+ <SelectValue />
196+ </SelectTrigger>
197+ <SelectContent>
198+ {sourceOptions.map((entry) => (
199+ <SelectItem key={entry.key} value={entry.key} className="text-xs">
200+ {entry.label}
201+ </SelectItem>
202+ ))}
203+ </SelectContent>
204+ </Select>
205+ ) : (
206+ <Input
207+ value={sourceKey}
208+ onChange={(e) => handleSourceKeyChange(e.target.value)}
209+ placeholder="source:<source-id>"
210+ className="h-8 text-xs font-mono bg-background"
211+ />
212+ )}
213+ {sourceKey && (
214+ <p className="text-[10px] text-muted-foreground font-mono">key: {sourceKey}</p>
215+ )}
216+ </div>
217+
218+ <div className="flex items-center gap-2 flex-wrap">
219+ <span className="text-[11px] text-muted-foreground">Detected auth</span>
220+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
221+ {authBadge}
222+ </Badge>
223+ {selectedAuth.inferred && (
224+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
225+ inferred
226+ </Badge>
227+ )}
228+ {selectedAuth.type === "apiKey" && selectedAuth.header && (
229+ <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
230+ header: {selectedAuth.header}
231+ </span>
232+ )}
233+ </div>
234+
235+ <div className="grid grid-cols-2 gap-3">
236+ <div className="space-y-1.5">
237+ <Label className="text-xs text-muted-foreground">Scope</Label>
238+ <Select value={scope} onValueChange={(value) => setScope(value as CredentialScope)}>
239+ <SelectTrigger className="h-8 text-xs bg-background">
240+ <SelectValue />
241+ </SelectTrigger>
242+ <SelectContent>
243+ <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
244+ <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem>
245+ </SelectContent>
246+ </Select>
247+ </div>
248+ <div className="space-y-1.5">
249+ <Label className="text-xs text-muted-foreground">Storage</Label>
250+ <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" />
251+ </div>
252+ </div>
253+
254+ {scope === "actor" && (
255+ <div className="space-y-1.5">
256+ <Label className="text-xs text-muted-foreground">Actor ID</Label>
257+ <Input
258+ value={actorId}
259+ onChange={(e) => setActorId(e.target.value)}
260+ placeholder="actor_123"
261+ className="h-8 text-xs font-mono bg-background"
262+ />
263+ </div>
264+ )}
265+
266+ {!editing && connectionOptions.length > 0 && (
267+ <div className="space-y-1.5">
268+ <Label className="text-xs text-muted-foreground">Connection Mode</Label>
269+ <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as ConnectionMode)}>
270+ <SelectTrigger className="h-8 text-xs bg-background">
271+ <SelectValue />
272+ </SelectTrigger>
273+ <SelectContent>
274+ <SelectItem value="new" className="text-xs">Create new connection</SelectItem>
275+ <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem>
276+ </SelectContent>
277+ </Select>
278+ </div>
279+ )}
280+
281+ {!editing && connectionMode === "existing" && (
282+ <div className="space-y-1.5">
283+ <Label className="text-xs text-muted-foreground">Existing Connection</Label>
284+ <Select value={existingConnectionId} onValueChange={setExistingConnectionId}>
285+ <SelectTrigger className="h-8 text-xs bg-background">
286+ <SelectValue placeholder="Select a connection" />
287+ </SelectTrigger>
288+ <SelectContent>
289+ {compatibleConnectionOptions.map((connection) => (
290+ <SelectItem key={connection.id} value={connection.id} className="text-xs">
291+ {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"})
292+ </SelectItem>
293+ ))}
294+ </SelectContent>
295+ </Select>
296+ {compatibleConnectionOptions.length === 0 && (
297+ <p className="text-[10px] text-muted-foreground">No compatible existing connections for this scope.</p>
298+ )}
299+ </div>
300+ )}
301+
302+ <ConnectionAuthFields
303+ editing={Boolean(editing)}
304+ connectionMode={connectionMode}
305+ selectedAuth={selectedAuth}
306+ tokenValue={tokenValue}
307+ apiKeyValue={apiKeyValue}
308+ basicUsername={basicUsername}
309+ basicPassword={basicPassword}
310+ onTokenValueChange={setTokenValue}
311+ onApiKeyValueChange={setApiKeyValue}
312+ onBasicUsernameChange={setBasicUsername}
313+ onBasicPasswordChange={setBasicPassword}
314+ />
315+
316+ <div className="space-y-1.5">
317+ <Label className="text-xs text-muted-foreground">Custom Headers (optional)</Label>
318+ <Textarea
319+ value={customHeadersText}
320+ onChange={(e) => setCustomHeadersText(e.target.value)}
321+ rows={4}
322+ placeholder="x-tenant-id: acme\nx-env: staging"
323+ className="text-xs font-mono bg-background"
324+ />
325+ </div>
326+
327+ <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm">
328+ {connectionSubmitCopy(Boolean(editing), saving, connectionMode)}
329+ </Button>
330+ </div>
331+ </DialogContent>
332+ </Dialog>
333+ );
334+}
executor/apps/web/src/components/tools-view-connection-form-save.ts+76−0
@@ -0,0 +1,76 @@
1+import type { SourceAuthType } from "@/lib/types";
2+
3+type BuildSecretJsonParams = {
4+ selectedAuthType: SourceAuthType;
5+ linkExisting: boolean;
6+ editing: boolean;
7+ basicUsername: string;
8+ basicPassword: string;
9+ apiKeyValue: string;
10+ tokenValue: string;
11+ parsedHeaders: Record<string, string>;
12+};
13+
14+export function buildSecretJson({
15+ selectedAuthType,
16+ linkExisting,
17+ editing,
18+ basicUsername,
19+ basicPassword,
20+ apiKeyValue,
21+ tokenValue,
22+ parsedHeaders,
23+}: BuildSecretJsonParams): { secretJson?: Record<string, unknown>; error?: string } {
24+ const secretJson: Record<string, unknown> = {};
25+ if (!linkExisting) {
26+ if (selectedAuthType === "basic") {
27+ const hasUsername = basicUsername.trim().length > 0;
28+ const hasPassword = basicPassword.trim().length > 0;
29+ if (hasUsername || hasPassword) {
30+ if (!hasUsername || !hasPassword) {
31+ return { error: "Username and password are required for basic auth" };
32+ }
33+ secretJson.username = basicUsername;
34+ secretJson.password = basicPassword;
35+ }
36+ } else if (selectedAuthType === "apiKey") {
37+ if (apiKeyValue.trim()) {
38+ secretJson.value = apiKeyValue.trim();
39+ }
40+ } else if (selectedAuthType === "bearer") {
41+ if (tokenValue.trim()) {
42+ secretJson.token = tokenValue.trim();
43+ }
44+ }
45+ }
46+
47+ if (Object.keys(parsedHeaders).length > 0) {
48+ secretJson.__headers = parsedHeaders;
49+ }
50+
51+ if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) {
52+ if (selectedAuthType === "basic") {
53+ return { error: "Username and password are required" };
54+ }
55+ if (selectedAuthType === "apiKey") {
56+ return { error: "API key value is required" };
57+ }
58+ return { error: "Token is required" };
59+ }
60+
61+ return { secretJson };
62+}
63+
64+export function connectionSuccessCopy(editing: boolean, linkExisting: boolean): string {
65+ return editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved";
66+}
67+
68+export function connectionSubmitCopy(editing: boolean, saving: boolean, connectionMode: "new" | "existing"): string {
69+ if (saving) {
70+ return "Saving...";
71+ }
72+ if (editing) {
73+ return "Update Connection";
74+ }
75+ return connectionMode === "existing" ? "Link Connection" : "Save Connection";
76+}
executor/apps/web/src/components/tools-view-credentials.tsx+228−0
@@ -0,0 +1,228 @@
1+"use client";
2+
3+import { useMemo, useState } from "react";
4+import { KeyRound, Plus } from "lucide-react";
5+import Image from "next/image";
6+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7+import { Button } from "@/components/ui/button";
8+import { Badge } from "@/components/ui/badge";
9+import { Skeleton } from "@/components/ui/skeleton";
10+import { useSession } from "@/lib/session-context";
11+import type {
12+ CredentialRecord,
13+ CredentialScope,
14+ SourceAuthProfile,
15+ ToolSourceRecord,
16+} from "@/lib/types";
17+import {
18+ connectionDisplayName,
19+ providerLabel,
20+} from "@/lib/credentials-source-helpers";
21+import {
22+ getSourceFavicon,
23+ sourceForCredentialKey,
24+} from "@/lib/tools-source-helpers";
25+import { ConnectionFormDialog } from "@/components/tools-view-connection-form-dialog";
26+
27+export function CredentialsPanel({
28+ sources,
29+ credentials,
30+ sourceAuthProfiles,
31+ loading,
32+ focusSourceKey,
33+ onFocusHandled,
34+}: {
35+ sources: ToolSourceRecord[];
36+ credentials: CredentialRecord[];
37+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
38+ loading: boolean;
39+ focusSourceKey?: string | null;
40+ onFocusHandled?: () => void;
41+}) {
42+ const { clientConfig } = useSession();
43+ const [open, setOpen] = useState(false);
44+ const [editing, setEditing] = useState<CredentialRecord | null>(null);
45+ const [initialSourceKey, setInitialSourceKey] = useState<string | null>(null);
46+
47+ const storageCopy = clientConfig?.authProviderMode === "workos"
48+ ? "Stored encrypted"
49+ : "Stored locally on this machine";
50+
51+ const connectionOptions = useMemo(() => {
52+ const grouped = new Map<string, {
53+ id: string;
54+ scope: CredentialScope;
55+ actorId?: string;
56+ provider: "local-convex" | "workos-vault";
57+ sourceKeys: Set<string>;
58+ updatedAt: number;
59+ }>();
60+
61+ for (const credential of credentials) {
62+ const existing = grouped.get(credential.id);
63+ if (existing) {
64+ existing.sourceKeys.add(credential.sourceKey);
65+ existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt);
66+ } else {
67+ grouped.set(credential.id, {
68+ id: credential.id,
69+ scope: credential.scope,
70+ actorId: credential.actorId,
71+ provider: credential.provider,
72+ sourceKeys: new Set([credential.sourceKey]),
73+ updatedAt: credential.updatedAt,
74+ });
75+ }
76+ }
77+
78+ return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt);
79+ }, [credentials]);
80+
81+ const representativeCredentialByConnection = useMemo(() => {
82+ const map = new Map<string, CredentialRecord>();
83+ for (const credential of credentials) {
84+ if (!map.has(credential.id)) {
85+ map.set(credential.id, credential);
86+ }
87+ }
88+ return map;
89+ }, [credentials]);
90+
91+ const openForCreate = () => {
92+ setEditing(null);
93+ setInitialSourceKey(null);
94+ setOpen(true);
95+ };
96+
97+ const openForEdit = (credential: CredentialRecord) => {
98+ setEditing(credential);
99+ setInitialSourceKey(null);
100+ setOpen(true);
101+ };
102+
103+ const handleOpenChange = (nextOpen: boolean) => {
104+ setOpen(nextOpen);
105+ if (focusSourceKey) {
106+ onFocusHandled?.();
107+ }
108+ if (!nextOpen) {
109+ setEditing(null);
110+ setInitialSourceKey(null);
111+ }
112+ };
113+
114+ const effectiveOpen = open || !!focusSourceKey;
115+ const effectiveEditing = focusSourceKey ? null : editing;
116+ const effectiveInitialSourceKey = focusSourceKey ?? initialSourceKey;
117+
118+ return (
119+ <Card className="bg-card border-border">
120+ <CardHeader className="pb-3">
121+ <div className="flex items-center justify-between">
122+ <CardTitle className="text-sm font-medium flex items-center gap-2">
123+ <KeyRound className="h-4 w-4 text-muted-foreground" />
124+ Connections
125+ </CardTitle>
126+ <Button size="sm" className="h-8 text-xs" onClick={openForCreate}>
127+ <Plus className="h-3.5 w-3.5 mr-1.5" />
128+ Add Connection
129+ </Button>
130+ </div>
131+ </CardHeader>
132+ <CardContent className="pt-0 space-y-3">
133+ {loading ? (
134+ <div className="space-y-2">
135+ {Array.from({ length: 3 }).map((_, i) => (
136+ <Skeleton key={i} className="h-14" />
137+ ))}
138+ </div>
139+ ) : connectionOptions.length === 0 ? (
140+ <div className="flex flex-col items-center justify-center py-10 gap-2">
141+ <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
142+ <KeyRound className="h-5 w-5 text-muted-foreground/50" />
143+ </div>
144+ <p className="text-sm text-muted-foreground">No connections configured</p>
145+ <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">
146+ Add a source, then create or link a reusable connection.
147+ </p>
148+ </div>
149+ ) : (
150+ <div className="space-y-2">
151+ {connectionOptions.map((connection) => {
152+ const representative = representativeCredentialByConnection.get(connection.id);
153+ if (!representative) {
154+ return null;
155+ }
156+ const firstSource = sourceForCredentialKey(sources, representative.sourceKey);
157+ const favicon = firstSource ? getSourceFavicon(firstSource) : null;
158+
159+ return (
160+ <div
161+ key={connection.id}
162+ className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40"
163+ >
164+ <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden">
165+ {favicon ? (
166+ <Image
167+ src={favicon}
168+ alt=""
169+ width={20}
170+ height={20}
171+ className="w-5 h-5"
172+ loading="lazy"
173+ unoptimized
174+ />
175+ ) : (
176+ <KeyRound className="h-4 w-4 text-muted-foreground" />
177+ )}
178+ </div>
179+ <div className="flex-1 min-w-0">
180+ <div className="flex items-center gap-1.5 mb-1 flex-wrap">
181+ <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span>
182+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
183+ {connection.scope}
184+ </Badge>
185+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
186+ {providerLabel(connection.provider)}
187+ </Badge>
188+ {connection.scope === "actor" && connection.actorId && (
189+ <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
190+ {connection.actorId}
191+ </span>
192+ )}
193+ </div>
194+ <p className="text-[11px] text-muted-foreground">
195+ Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} - {storageCopy}
196+ </p>
197+ <p className="text-[11px] text-muted-foreground mt-0.5">
198+ ID: <span className="font-mono">{connection.id}</span> -{" "}
199+ Updated {new Date(connection.updatedAt).toLocaleString()}
200+ </p>
201+ </div>
202+ <Button
203+ variant="outline"
204+ size="sm"
205+ className="h-7 text-[11px]"
206+ onClick={() => openForEdit(representative)}
207+ >
208+ Edit
209+ </Button>
210+ </div>
211+ );
212+ })}
213+ </div>
214+ )}
215+ </CardContent>
216+
217+ <ConnectionFormDialog
218+ open={effectiveOpen}
219+ onOpenChange={handleOpenChange}
220+ editing={effectiveEditing}
221+ initialSourceKey={effectiveInitialSourceKey}
222+ sources={sources}
223+ credentials={credentials}
224+ sourceAuthProfiles={sourceAuthProfiles}
225+ />
226+ </Card>
227+ );
228+}
executor/apps/web/src/components/tools-view-source-auth-dialog.tsx+192−0
@@ -0,0 +1,192 @@
1+"use client";
2+
3+import { useState } from "react";
4+import { Pencil } from "lucide-react";
5+import { useMutation } from "convex/react";
6+import { toast } from "sonner";
7+import { Button } from "@/components/ui/button";
8+import { Input } from "@/components/ui/input";
9+import { Label } from "@/components/ui/label";
10+import {
11+ Select,
12+ SelectContent,
13+ SelectItem,
14+ SelectTrigger,
15+ SelectValue,
16+} from "@/components/ui/select";
17+import {
18+ Dialog,
19+ DialogContent,
20+ DialogHeader,
21+ DialogTitle,
22+ DialogTrigger,
23+} from "@/components/ui/dialog";
24+import { useSession } from "@/lib/session-context";
25+import { convexApi } from "@/lib/convex-api";
26+import type { SourceAuthProfile, ToolSourceRecord } from "@/lib/types";
27+import {
28+ readSourceAuth,
29+ sourceKeyForSource,
30+ type SourceAuthMode,
31+ type SourceAuthType,
32+} from "@/lib/tools-source-helpers";
33+
34+export function ConfigureSourceAuthDialog({
35+ source,
36+ inferredProfile,
37+}: {
38+ source: ToolSourceRecord;
39+ inferredProfile?: SourceAuthProfile;
40+}) {
41+ const { context } = useSession();
42+ const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
43+ const [open, setOpen] = useState(false);
44+ const [saving, setSaving] = useState(false);
45+
46+ const currentAuth = readSourceAuth(source, inferredProfile);
47+ const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type;
48+ const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>(
49+ editableInitialAuthType,
50+ );
51+ const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace");
52+ const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key");
53+
54+ const configurable = source.type === "openapi" || source.type === "graphql";
55+
56+ const resetFromSource = () => {
57+ const auth = readSourceAuth(source, inferredProfile);
58+ setAuthType(auth.type === "mixed" ? "bearer" : auth.type);
59+ setAuthMode(auth.mode ?? "workspace");
60+ setApiKeyHeader(auth.header ?? "x-api-key");
61+ };
62+
63+ const handleOpenChange = (isOpen: boolean) => {
64+ setOpen(isOpen);
65+ if (isOpen) {
66+ resetFromSource();
67+ }
68+ };
69+
70+ const handleSave = async () => {
71+ if (!context || !configurable) {
72+ return;
73+ }
74+
75+ setSaving(true);
76+ try {
77+ const authConfig: Record<string, unknown> =
78+ authType === "none"
79+ ? { type: "none" }
80+ : authType === "apiKey"
81+ ? { type: "apiKey", mode: authMode, header: apiKeyHeader.trim() || "x-api-key" }
82+ : { type: authType, mode: authMode };
83+
84+ await upsertToolSource({
85+ id: source.id,
86+ workspaceId: context.workspaceId,
87+ sessionId: context.sessionId,
88+ name: source.name,
89+ type: source.type,
90+ config: {
91+ ...source.config,
92+ auth: authConfig,
93+ },
94+ });
95+
96+ toast.success(`Updated auth for ${source.name}`);
97+ setOpen(false);
98+ } catch (err) {
99+ toast.error(err instanceof Error ? err.message : "Failed to update auth");
100+ } finally {
101+ setSaving(false);
102+ }
103+ };
104+
105+ if (!configurable) {
106+ return null;
107+ }
108+
109+ return (
110+ <Dialog open={open} onOpenChange={handleOpenChange}>
111+ <DialogTrigger asChild>
112+ <Button variant="outline" size="sm" className="h-7 text-[11px]">
113+ <Pencil className="h-3 w-3 mr-1.5" />
114+ Auth
115+ </Button>
116+ </DialogTrigger>
117+ <DialogContent className="bg-card border-border sm:max-w-md">
118+ <DialogHeader>
119+ <DialogTitle className="text-sm font-medium">Configure Source Auth</DialogTitle>
120+ </DialogHeader>
121+ <div className="space-y-3">
122+ <div className="space-y-1.5">
123+ <Label className="text-xs text-muted-foreground">Source</Label>
124+ <Input value={source.name} readOnly className="h-8 text-xs font-mono bg-background" />
125+ </div>
126+
127+ <div className="space-y-1.5">
128+ <Label className="text-xs text-muted-foreground">Auth Type</Label>
129+ <Select
130+ value={authType}
131+ onValueChange={(value) => setAuthType(value as Exclude<SourceAuthType, "mixed">)}
132+ >
133+ <SelectTrigger className="h-8 text-xs bg-background">
134+ <SelectValue />
135+ </SelectTrigger>
136+ <SelectContent>
137+ <SelectItem value="none" className="text-xs">None</SelectItem>
138+ <SelectItem value="bearer" className="text-xs">Bearer token</SelectItem>
139+ <SelectItem value="apiKey" className="text-xs">API key header</SelectItem>
140+ <SelectItem value="basic" className="text-xs">Basic auth</SelectItem>
141+ </SelectContent>
142+ </Select>
143+ </div>
144+
145+ {currentAuth.inferred && (
146+ <p className="text-[11px] text-muted-foreground">
147+ Suggested from spec inference. Save to pin an explicit auth config.
148+ </p>
149+ )}
150+
151+ {authType !== "none" && (
152+ <>
153+ <div className="space-y-1.5">
154+ <Label className="text-xs text-muted-foreground">Credential Scope</Label>
155+ <Select value={authMode} onValueChange={(value) => setAuthMode(value as SourceAuthMode)}>
156+ <SelectTrigger className="h-8 text-xs bg-background">
157+ <SelectValue />
158+ </SelectTrigger>
159+ <SelectContent>
160+ <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
161+ <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem>
162+ </SelectContent>
163+ </Select>
164+ </div>
165+
166+ {authType === "apiKey" && (
167+ <div className="space-y-1.5">
168+ <Label className="text-xs text-muted-foreground">Header Name</Label>
169+ <Input
170+ value={apiKeyHeader}
171+ onChange={(event) => setApiKeyHeader(event.target.value)}
172+ placeholder="x-api-key"
173+ className="h-8 text-xs font-mono bg-background"
174+ />
175+ </div>
176+ )}
177+
178+ <p className="text-[11px] text-muted-foreground">
179+ Save this first, then add a connection in the Connections tab using source key
180+ <code className="ml-1">{sourceKeyForSource(source)}</code>.
181+ </p>
182+ </>
183+ )}
184+
185+ <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm">
186+ {saving ? "Saving..." : "Save Auth"}
187+ </Button>
188+ </div>
189+ </DialogContent>
190+ </Dialog>
191+ );
192+}
executor/apps/web/src/components/tools-view-source-card.tsx+176−0
@@ -0,0 +1,176 @@
1+"use client";
2+
3+import { useState } from "react";
4+import { Globe, Server, Trash2 } from "lucide-react";
5+import { useMutation } from "convex/react";
6+import Image from "next/image";
7+import { toast } from "sonner";
8+import { Button } from "@/components/ui/button";
9+import { Badge } from "@/components/ui/badge";
10+import { useSession } from "@/lib/session-context";
11+import { convexApi } from "@/lib/convex-api";
12+import type {
13+ OpenApiSourceQuality,
14+ SourceAuthProfile,
15+ ToolSourceRecord,
16+} from "@/lib/types";
17+import { cn } from "@/lib/utils";
18+import { displaySourceName } from "@/lib/tool-source-utils";
19+import {
20+ compactEndpointLabel,
21+ formatSourceAuthBadge,
22+ getSourceFavicon,
23+ readSourceAuth,
24+ sourceEndpointLabel,
25+ sourceKeyForSource,
26+} from "@/lib/tools-source-helpers";
27+import { ConfigureSourceAuthDialog } from "./tools-view-source-auth-dialog";
28+import {
29+ OpenApiQualityDetails,
30+ SourceQualitySummary,
31+} from "./tools-view-source-quality-details";
32+
33+export function SourceCard({
34+ source,
35+ quality,
36+ qualityLoading,
37+ credentialStats,
38+ sourceAuthProfiles,
39+ selected = false,
40+ onFocusSource,
41+}: {
42+ source: ToolSourceRecord;
43+ quality?: OpenApiSourceQuality;
44+ qualityLoading?: boolean;
45+ credentialStats: { workspaceCount: number; actorCount: number };
46+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
47+ selected?: boolean;
48+ onFocusSource?: (sourceName: string) => void;
49+}) {
50+ const { context } = useSession();
51+ const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
52+ const [deleting, setDeleting] = useState(false);
53+
54+ const handleDelete = async () => {
55+ if (!context) {
56+ return;
57+ }
58+ setDeleting(true);
59+ try {
60+ await deleteToolSource({
61+ workspaceId: context.workspaceId,
62+ sessionId: context.sessionId,
63+ sourceId: source.id,
64+ });
65+ toast.success(`Removed "${source.name}"`);
66+ } catch (err) {
67+ toast.error(err instanceof Error ? err.message : "Failed to delete");
68+ } finally {
69+ setDeleting(false);
70+ }
71+ };
72+
73+ const TypeIcon = source.type === "mcp" ? Server : Globe;
74+ const favicon = getSourceFavicon(source);
75+ const sourceKey = sourceKeyForSource(source) ?? "";
76+ const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]);
77+ const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]);
78+ const hasAuthConfigured = auth.type !== "none";
79+ const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount;
80+ const prettyName = displaySourceName(source.name);
81+ const compactEndpoint = compactEndpointLabel(source);
82+ const showTypeSummary = source.type === "openapi" && (quality || qualityLoading);
83+
84+ return (
85+ <div
86+ className={cn(
87+ "group flex items-start gap-3 rounded-lg border border-border/60 bg-gradient-to-b from-muted/45 to-muted/20 px-3 py-3",
88+ selected && "border-primary/35 bg-primary/5",
89+ )}
90+ >
91+ <div className="mt-0.5 h-9 w-9 rounded-md bg-muted/80 flex items-center justify-center shrink-0 overflow-hidden">
92+ {favicon ? (
93+ <Image
94+ src={favicon}
95+ alt=""
96+ width={20}
97+ height={20}
98+ className="w-5 h-5"
99+ loading="lazy"
100+ unoptimized
101+ />
102+ ) : (
103+ <TypeIcon className="h-4 w-4 text-muted-foreground" />
104+ )}
105+ </div>
106+ <div className="flex-1 min-w-0">
107+ <div className="flex items-center gap-2 min-w-0">
108+ <span className="text-sm font-medium truncate" title={source.name}>
109+ {prettyName}
110+ </span>
111+ <Badge variant="outline" className="text-[9px] uppercase tracking-wide">
112+ {source.type}
113+ </Badge>
114+ {!source.enabled && (
115+ <Badge
116+ variant="outline"
117+ className="text-[9px] uppercase tracking-wide text-terminal-red border-terminal-red/30"
118+ >
119+ disabled
120+ </Badge>
121+ )}
122+ {authBadge && (
123+ <Badge
124+ variant="outline"
125+ className="text-[9px] uppercase tracking-wide text-primary border-primary/30"
126+ >
127+ {authBadge}
128+ </Badge>
129+ )}
130+ {hasAuthConfigured && (
131+ <Badge
132+ variant="outline"
133+ className={cn(
134+ "text-[9px] uppercase tracking-wide",
135+ totalCredentials > 0
136+ ? "text-terminal-green border-terminal-green/30"
137+ : "text-terminal-amber border-terminal-amber/30",
138+ )}
139+ >
140+ {totalCredentials > 0 ? "connections ready" : "connection needed"}
141+ </Badge>
142+ )}
143+ </div>
144+ <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}>
145+ {compactEndpoint}
146+ </span>
147+ {showTypeSummary && <SourceQualitySummary quality={quality} qualityLoading={qualityLoading} />}
148+ {source.type === "openapi" && (
149+ <OpenApiQualityDetails quality={quality} qualityLoading={qualityLoading} />
150+ )}
151+ </div>
152+ <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
153+ {onFocusSource ? (
154+ <Button
155+ variant={selected ? "default" : "outline"}
156+ size="sm"
157+ className="h-7 text-[11px]"
158+ onClick={() => onFocusSource(source.name)}
159+ >
160+ {selected ? "Viewing" : "View tools"}
161+ </Button>
162+ ) : null}
163+ <ConfigureSourceAuthDialog source={source} inferredProfile={sourceAuthProfiles[sourceKey]} />
164+ <Button
165+ variant="ghost"
166+ size="icon"
167+ className="h-7 w-7 text-muted-foreground hover:text-terminal-red"
168+ onClick={handleDelete}
169+ disabled={deleting}
170+ >
171+ <Trash2 className="h-3.5 w-3.5" />
172+ </Button>
173+ </div>
174+ </div>
175+ );
176+}
executor/apps/web/src/components/tools-view-source-quality-details.tsx+110−0
@@ -0,0 +1,110 @@
1+"use client";
2+
3+import { useState } from "react";
4+import { ChevronRight } from "lucide-react";
5+import { Badge } from "@/components/ui/badge";
6+import { Button } from "@/components/ui/button";
7+import {
8+ Collapsible,
9+ CollapsibleContent,
10+ CollapsibleTrigger,
11+} from "@/components/ui/collapsible";
12+import type { OpenApiSourceQuality } from "@/lib/types";
13+import { cn } from "@/lib/utils";
14+import {
15+ formatQualityPercent,
16+ qualitySummaryLabel,
17+ qualityToneClass,
18+} from "@/lib/tools-source-helpers";
19+
20+export function SourceQualitySummary({
21+ quality,
22+ qualityLoading,
23+}: {
24+ quality?: OpenApiSourceQuality;
25+ qualityLoading?: boolean;
26+}) {
27+ if (!quality && !qualityLoading) {
28+ return null;
29+ }
30+
31+ return (
32+ <div className="mt-1.5 flex items-center gap-2">
33+ {quality ? (
34+ <Badge
35+ variant="outline"
36+ className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))}
37+ >
38+ {formatQualityPercent(quality.overallQuality)} {qualitySummaryLabel(quality)}
39+ </Badge>
40+ ) : (
41+ <Badge variant="outline" className="text-[9px] uppercase tracking-wide text-muted-foreground">
42+ analyzing type quality
43+ </Badge>
44+ )}
45+ </div>
46+ );
47+}
48+
49+export function OpenApiQualityDetails({
50+ quality,
51+ qualityLoading,
52+}: {
53+ quality?: OpenApiSourceQuality;
54+ qualityLoading?: boolean;
55+}) {
56+ const [detailsOpen, setDetailsOpen] = useState(false);
57+
58+ return (
59+ <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
60+ <div className="mt-1.5">
61+ <CollapsibleTrigger asChild>
62+ <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground">
63+ <ChevronRight
64+ className={cn("mr-1 h-3 w-3 transition-transform", detailsOpen && "rotate-90")}
65+ />
66+ {detailsOpen ? "Hide details" : "View details"}
67+ </Button>
68+ </CollapsibleTrigger>
69+ </div>
70+ <CollapsibleContent className="mt-1.5">
71+ <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2">
72+ {quality && (
73+ <div className="space-y-1.5 text-[10px]">
74+ <div className="flex items-center justify-between">
75+ <span className="text-muted-foreground">Type quality</span>
76+ <span className={cn("font-medium", qualityToneClass(quality))}>
77+ {formatQualityPercent(quality.overallQuality)}
78+ </span>
79+ </div>
80+ <div className="flex items-center justify-between text-muted-foreground">
81+ <span>Args quality</span>
82+ <span>{formatQualityPercent(quality.argsQuality)}</span>
83+ </div>
84+ <div className="flex items-center justify-between text-muted-foreground">
85+ <span>Returns quality</span>
86+ <span>{formatQualityPercent(quality.returnsQuality)}</span>
87+ </div>
88+ <div className="flex items-center justify-between text-muted-foreground">
89+ <span>Inferred returns</span>
90+ <span>{quality.unknownReturnsCount}</span>
91+ </div>
92+ </div>
93+ )}
94+ {!quality && qualityLoading && (
95+ <div className="flex items-center justify-between text-[10px] text-muted-foreground">
96+ <span>Analyzing OpenAPI typing</span>
97+ <span className="inline-flex items-center gap-1 text-[10px]">
98+ <span className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-pulse" />
99+ in progress
100+ </span>
101+ </div>
102+ )}
103+ {!quality && !qualityLoading && (
104+ <div className="text-[10px] text-muted-foreground">Type quality data unavailable.</div>
105+ )}
106+ </div>
107+ </CollapsibleContent>
108+ </Collapsible>
109+ );
110+}
executor/apps/web/src/components/tools-view-sources.tsx+2−0
@@ -0,0 +1,2 @@
1+export { AddSourceDialog } from "./tools-view-add-source-dialog";
2+export { SourceCard } from "./tools-view-source-card";
executor/apps/web/src/components/tools-view.tsx+12−1846
@@ -1,1867 +1,33 @@
11 "use client";
22
3−import { useEffect, useMemo, useState } from "react";
3+import { useState } from "react";
44 import {
5− Wrench,
6− Plus,
7− Trash2,
85 Globe,
96 Server,
10− ChevronRight,
11− AlertTriangle,
12− KeyRound,
13− Pencil,
147 } from "lucide-react";
158 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
169 import { Button } from "@/components/ui/button";
17−import { Input } from "@/components/ui/input";
18−import { Label } from "@/components/ui/label";
19−import { Textarea } from "@/components/ui/textarea";
20−import { Badge } from "@/components/ui/badge";
21−import {
22− Select,
23− SelectContent,
24− SelectItem,
25− SelectTrigger,
26− SelectValue,
27−} from "@/components/ui/select";
28−import {
29− Dialog,
30− DialogContent,
31− DialogHeader,
32− DialogTitle,
33− DialogTrigger,
34−} from "@/components/ui/dialog";
35−import {
36− Collapsible,
37− CollapsibleContent,
38− CollapsibleTrigger,
39−} from "@/components/ui/collapsible";
40−import { Separator } from "@/components/ui/separator";
4110 import { Skeleton } from "@/components/ui/skeleton";
4211 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4312 import { PageHeader } from "@/components/page-header";
4413 import { McpSetupCard } from "@/components/mcp-setup-card";
4514 import { ToolExplorer } from "@/components/tool-explorer";
15+import { AddSourceDialog, SourceCard } from "@/components/tools-view-sources";
16+import { CredentialsPanel } from "@/components/tools-view-credentials";
4617 import { useSession } from "@/lib/session-context";
4718 import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
48−import { useAction, useMutation, useQuery } from "convex/react";
19+import { useQuery } from "convex/react";
4920 import { convexApi } from "@/lib/convex-api";
5021 import type {
5122 ToolSourceRecord,
5223 CredentialRecord,
53− CredentialScope,
54− SourceAuthProfile,
55− OpenApiSourceQuality,
5624 } from "@/lib/types";
57−import { parse as parseDomain } from "tldts";
58−import { toast } from "sonner";
59−import { cn } from "@/lib/utils";
60−import { Streamdown } from "streamdown";
61−
62−interface CatalogCollectionItem {
63− id: string;
64− name: string;
65− summary: string;
66− specUrl: string;
67− originUrl?: string;
68− providerName: string;
69− logoUrl?: string;
70− categories?: string;
71− version?: string;
72− rank: number;
73− addedAt: string;
74−}
75−
76−const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [
77− {
78− id: "github-rest",
79− name: "GitHub REST API",
80− summary: "Manage repositories, pull requests, issues, and org settings.",
81− specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
82− originUrl: "https://docs.github.com/en/rest",
83− providerName: "GitHub",
84− categories: "developer-tools",
85− version: "latest",
86− rank: 1,
87− addedAt: "2026-01-10",
88− },
89− {
90− id: "stripe-api",
91− name: "Stripe API",
92− summary: "Create payments, manage customers, and handle billing workflows.",
93− specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
94− originUrl: "https://docs.stripe.com/api",
95− providerName: "Stripe",
96− categories: "payments",
97− version: "2026-01",
98− rank: 2,
99− addedAt: "2026-01-08",
100− },
101− {
102− id: "openai-api",
103− name: "OpenAI API",
104− summary: "Generate text, run reasoning models, and process multimodal inputs.",
105− specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
106− originUrl: "https://platform.openai.com/docs/api-reference",
107− providerName: "OpenAI",
108− categories: "ai",
109− version: "latest",
110− rank: 3,
111− addedAt: "2026-01-06",
112− },
113− {
114− id: "cloudflare-api",
115− name: "Cloudflare API",
116− summary: "Control zones, DNS records, workers, and edge configuration.",
117− specUrl: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml",
118− originUrl: "https://api.cloudflare.com/",
119− providerName: "Cloudflare",
120− categories: "infrastructure",
121− version: "latest",
122− rank: 4,
123− addedAt: "2026-01-04",
124− },
125− {
126− id: "vercel-api",
127− name: "Vercel API",
128− summary: "Manage deployments, projects, domains, and team resources.",
129− specUrl: "https://openapi.vercel.sh",
130− originUrl: "https://vercel.com/docs/rest-api",
131− providerName: "Vercel",
132− categories: "developer-tools",
133− version: "latest",
134− rank: 5,
135− addedAt: "2025-12-18",
136− },
137− {
138− id: "slack-api",
139− name: "Slack API",
140− summary: "Work with channels, messages, users, and workspace automation.",
141− specUrl: "https://api.slack.com/specs/openapi/v2/slack_web.json",
142− originUrl: "https://api.slack.com/web",
143− providerName: "Slack",
144− categories: "communications",
145− version: "v2",
146− rank: 6,
147− addedAt: "2025-12-10",
148− },
149− {
150− id: "sentry-api",
151− name: "Sentry API",
152− summary: "Query issues, releases, projects, and alerting configuration.",
153− specUrl: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json",
154− originUrl: "https://docs.sentry.io/api/",
155− providerName: "Sentry",
156− categories: "observability",
157− version: "latest",
158− rank: 7,
159− addedAt: "2025-11-30",
160− },
161− {
162− id: "jira-cloud-api",
163− name: "Jira Cloud Platform",
164− summary: "Manage projects, issues, workflows, and Jira metadata.",
165− specUrl: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json",
166− originUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/",
167− providerName: "Atlassian",
168− categories: "project-management",
169− version: "v3",
170− rank: 8,
171− addedAt: "2025-11-15",
172− },
173−];
174−
175−/** Derive a favicon URL from any URL string via Google's favicon service. */
176−function faviconForUrl(url: string | undefined | null): string | null {
177− if (!url) return null;
178− try {
179− const domain = new URL(url).hostname;
180− return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
181− } catch {
182− return null;
183− }
184−}
185−
186−function getSourceFavicon(source: ToolSourceRecord): string | null {
187− if (source.type === "mcp") {
188− return faviconForUrl((source.config.url as string) ?? null);
189− }
190− if (source.type === "graphql") {
191− return faviconForUrl((source.config.endpoint as string) ?? null);
192− }
193− const spec = source.config.spec as string | undefined;
194− if (typeof spec === "string" && spec.startsWith("postman:")) {
195− return null;
196− }
197− const baseUrl = source.config.baseUrl as string | undefined;
198− const collectionUrl = source.config.collectionUrl as string | undefined;
199− const specUrl = typeof spec === "string" && spec.startsWith("http") ? spec : null;
200− return faviconForUrl(baseUrl ?? collectionUrl ?? specUrl);
201−}
202−
203−function sourceEndpointLabel(source: ToolSourceRecord): string {
204− if (source.type === "mcp") return (source.config.url as string) ?? "";
205− if (source.type === "graphql") return (source.config.endpoint as string) ?? "";
206−
207− const spec = source.config.spec;
208− if (typeof spec === "string" && spec.startsWith("postman:")) {
209− const uid = spec.slice("postman:".length).trim();
210− if (uid.length > 0) {
211− return `catalog:${uid}`;
212− }
213− return "catalog:collection";
214− }
215−
216− return (source.config.spec as string) ?? "";
217−}
218−
219−function sourceKeyForSource(source: ToolSourceRecord): string | null {
220− if (source.type === "openapi") return `source:${source.id}`;
221− if (source.type === "graphql") return `source:${source.id}`;
222− return null;
223−}
224−
225−function toolSourceLabelForSource(source: ToolSourceRecord): string {
226− return `${source.type}:${source.name}`;
227−}
228−
229−function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): ToolSourceRecord | null {
230− const prefix = "source:";
231− if (!sourceKey.startsWith(prefix)) return null;
232− const sourceId = sourceKey.slice(prefix.length);
233− if (!sourceId) return null;
234− return sources.find((source) => source.id === sourceId) ?? null;
235−}
236−
237−type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
238−type SourceAuthMode = "workspace" | "actor";
239−
240−function normalizeSourceAuthProfile(profile: SourceAuthProfile | undefined): {
241− type: SourceAuthType;
242− mode?: SourceAuthMode;
243− header?: string;
244− inferred?: boolean;
245−} {
246− if (!profile) {
247− return { type: "none" };
248− }
249−
250− const type = profile.type === "mixed"
251− ? "mixed"
252− : profile.type === "basic"
253− ? "basic"
254− : profile.type === "apiKey"
255− ? "apiKey"
256− : profile.type === "bearer"
257− ? "bearer"
258− : "none";
259−
260− const mode = profile.mode === "actor" ? "actor" : profile.mode === "workspace" ? "workspace" : undefined;
261− const header = typeof profile.header === "string" && profile.header.trim().length > 0
262− ? profile.header.trim()
263− : undefined;
264−
265− return {
266− type,
267− ...(mode ? { mode } : {}),
268− ...(header ? { header } : {}),
269− inferred: Boolean(profile.inferred),
270− };
271−}
272−
273−function readSourceAuth(
274− source: ToolSourceRecord,
275− inferredProfile?: SourceAuthProfile,
276−): {
277− type: SourceAuthType;
278− mode?: SourceAuthMode;
279− header?: string;
280− inferred?: boolean;
281−} {
282− if (source.type !== "openapi" && source.type !== "graphql") {
283− return { type: "none" };
284− }
285−
286− const inferred = normalizeSourceAuthProfile(inferredProfile);
287−
288− const auth = source.config.auth as Record<string, unknown> | undefined;
289− const type =
290− auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic", "mixed"].includes(auth.type)
291− ? (auth.type as SourceAuthType)
292− : inferred.type;
293−
294− const mode =
295− auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor")
296− ? (auth.mode as SourceAuthMode)
297− : inferred.mode;
298−
299− const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0
300− ? auth.header.trim()
301− : inferred.header;
302−
303− return {
304− type,
305− ...(mode ? { mode } : {}),
306− ...(header ? { header } : {}),
307− inferred: auth?.type === undefined ? inferred.inferred : false,
308− };
309−}
310−
311−function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null {
312− const auth = readSourceAuth(source, inferredProfile);
313− if (auth.type === "none") return null;
314− if (auth.type === "mixed") return "Mixed auth";
315− const mode = auth.mode ?? "workspace";
316− const authLabel =
317− auth.type === "apiKey"
318− ? "API Key"
319− : auth.type === "bearer"
320− ? "Bearer"
321− : auth.type === "basic"
322− ? "Basic"
323− : "Auth";
324− return `${authLabel} · ${mode === "actor" ? "user" : "workspace"}`;
325−}
326−
327−function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): {
328− workspaceCount: number;
329− actorCount: number;
330−} {
331− const sourceKey = sourceKeyForSource(source);
332− if (!sourceKey) {
333− return { workspaceCount: 0, actorCount: 0 };
334− }
335−
336− let workspaceCount = 0;
337− let actorCount = 0;
338− for (const credential of credentials) {
339− if (credential.sourceKey !== sourceKey) continue;
340− if (credential.scope === "workspace") workspaceCount += 1;
341− if (credential.scope === "actor") actorCount += 1;
342− }
343−
344− return { workspaceCount, actorCount };
345−}
346−
347−function formatQualityPercent(value: number): string {
348− return `${Math.round(value * 100)}%`;
349−}
350−
351−function qualityToneClass(quality: OpenApiSourceQuality): string {
352− if (quality.overallQuality >= 0.95) {
353− return "text-terminal-green";
354− }
355− if (quality.overallQuality >= 0.85) {
356− return "text-terminal-amber";
357− }
358− return "text-terminal-red";
359−}
360−
361−function qualitySummaryLabel(quality: OpenApiSourceQuality): string {
362− if (quality.overallQuality >= 0.95) {
363− return "strong typing";
364− }
365− if (quality.overallQuality >= 0.85) {
366− return "mostly typed";
367− }
368− return "needs type cleanup";
369−}
370−
371−function displaySourceName(name: string): string {
372− const parts = name.split(/[-_.]+/).filter(Boolean);
373− if (parts.length === 0) return name;
374−
375− const deduped = parts.filter((part, index, all) => {
376− if (index === 0) return true;
377− return part.toLowerCase() !== all[index - 1]?.toLowerCase();
378− });
379−
380− const tokenMap: Record<string, string> = {
381− api: "API",
382− oauth: "OAuth",
383− graphql: "GraphQL",
384− mcp: "MCP",
385− github: "GitHub",
386− };
387−
388− return deduped
389− .map((token) => {
390− const lower = token.toLowerCase();
391− if (tokenMap[lower]) return tokenMap[lower];
392− return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`;
393− })
394− .join(" ");
395−}
396−
397−function compactEndpointLabel(source: ToolSourceRecord): string {
398− const endpoint = sourceEndpointLabel(source);
399− if (endpoint.startsWith("catalog:")) return endpoint;
400− try {
401− const parsed = new URL(endpoint);
402− const path = parsed.pathname === "/" ? "" : parsed.pathname;
403− return `${parsed.hostname}${path}`;
404− } catch {
405− return endpoint;
406− }
407−}
408−
409−// ── Add Source Dialog ──
410−
411−const RAW_HOSTS = new Set([
412− "raw.githubusercontent.com",
413− "cdn.jsdelivr.net",
414− "unpkg.com",
415− "raw.github.com",
416−]);
417−
418−function inferNameFromUrl(url: string): string {
419− try {
420− const u = new URL(url);
421− const parsed = parseDomain(url);
422−
423− if (RAW_HOSTS.has(u.hostname)) {
424− const segments = u.pathname.split("/").filter(Boolean);
425− if (segments.length > 0) return segments[0].toLowerCase();
426− }
427−
428− if (parsed.domainWithoutSuffix) {
429− return parsed.domainWithoutSuffix;
430− }
431−
432− if (parsed.domain) {
433− return parsed.domain.split(".")[0];
434− }
435−
436− return u.hostname.replace(/\./g, "-");
437− } catch {
438− return "";
439− }
440−}
441−
442−function sanitizeSourceName(value: string): string {
443− const slug = value
444− .toLowerCase()
445− .replace(/[^a-z0-9]+/g, "-")
446− .replace(/-+/g, "-")
447− .replace(/^-|-$/g, "")
448− .slice(0, 48);
449− return slug || "source";
450−}
451−
452−function withUniqueSourceName(baseName: string, takenNames: Set<string>): string {
453− const loweredTaken = new Set([...takenNames].map((name) => name.toLowerCase()));
454− const candidate = sanitizeSourceName(baseName);
455− if (!loweredTaken.has(candidate.toLowerCase())) {
456− return candidate;
457− }
458−
459− let suffix = 2;
460− while (true) {
461− const next = `${candidate}-${suffix}`;
462− if (!loweredTaken.has(next.toLowerCase())) {
463− return next;
464− }
465− suffix += 1;
466− }
467−}
468−
469−function catalogSourceName(item: CatalogCollectionItem): string {
470− const owner = sanitizeSourceName(item.providerName || "catalog");
471− const title = sanitizeSourceName(item.name);
472− return sanitizeSourceName(`${owner}-${title}`);
473−}
474−
475−function AddSourceDialog({
476− existingSourceNames,
477− onSourceAdded,
478−}: {
479− existingSourceNames: Set<string>;
480− onSourceAdded?: (source: ToolSourceRecord) => void;
481−}) {
482− const { context } = useSession();
483− const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
484− const [open, setOpen] = useState(false);
485− const [view, setView] = useState<"catalog" | "custom">("catalog");
486− const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp");
487− const [name, setName] = useState("");
488− const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
489− const [endpoint, setEndpoint] = useState("");
490− const [baseUrl, setBaseUrl] = useState("");
491− const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto");
492− const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId");
493− const [submitting, setSubmitting] = useState(false);
494− const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]);
495− const [catalogQuery, setCatalogQuery] = useState("");
496− const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular");
497− const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null);
498−
499− const visibleCatalogItems = useMemo(() => {
500− const query = catalogQuery.trim().toLowerCase();
501− const filtered = HARD_CODED_CATALOG_ITEMS.filter((item) => {
502− if (!query) return true;
503− return [
504− item.name,
505− item.providerName,
506− item.summary,
507− item.categories ?? "",
508− ].some((value) => value.toLowerCase().includes(query));
509− });
510−
511− return [...filtered].sort((a, b) => {
512− if (catalogSort === "recent") {
513− return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime();
514− }
515− return a.rank - b.rank;
516− });
517− }, [catalogQuery, catalogSort]);
518−
519− const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]);
520−
521− const reserveSourceName = (sourceName: string) => {
522− setLocallyReservedNames((current) =>
523− current.includes(sourceName)
524− ? current
525− : [...current, sourceName]
526− );
527− };
528−
529− const getUniqueAutoSourceName = (candidate: string) => {
530− return withUniqueSourceName(candidate, getTakenSourceNames());
531− };
532−
533− const handleEndpointChange = (value: string) => {
534− setEndpoint(value);
535− if (!nameManuallyEdited) {
536− const inferred = inferNameFromUrl(value);
537− if (inferred) setName(inferred);
538− }
539− };
540−
541− const handleNameChange = (value: string) => {
542− setName(value);
543− setNameManuallyEdited(true);
544− };
545−
546− const resetDialogState = () => {
547− setView("catalog");
548− setType("mcp");
549− setName("");
550− setEndpoint("");
551− setBaseUrl("");
552− setMcpTransport("auto");
553− setMcpActorQueryParamKey("userId");
554− setNameManuallyEdited(false);
555− setLocallyReservedNames([]);
556− setCatalogQuery("");
557− setCatalogSort("popular");
558− setAddingCatalogId(null);
559− };
560−
561− const handleOpenChange = (isOpen: boolean) => {
562− setOpen(isOpen);
563− if (!isOpen) {
564− resetDialogState();
565− }
566− };
567−
568− const addSource = async (
569− sourceName: string,
570− sourceType: "mcp" | "openapi" | "graphql",
571− config: Record<string, unknown>,
572− ) => {
573− if (!context) return;
574− const created = await upsertToolSource({
575− workspaceId: context.workspaceId,
576− sessionId: context.sessionId,
577− name: sourceName,
578− type: sourceType,
579− config,
580− });
581− onSourceAdded?.(created as ToolSourceRecord);
582− toast.success(`Source "${sourceName}" added — loading tools…`);
583− };
584−
585− const handleCatalogAdd = async (item: CatalogCollectionItem) => {
586− if (!item.specUrl.trim()) {
587− toast.error("Missing OpenAPI spec URL for this API source");
588− return;
589− }
590−
591− setAddingCatalogId(item.id);
592− try {
593− const sourceName = getUniqueAutoSourceName(catalogSourceName(item));
594− await addSource(sourceName, "openapi", {
595− spec: item.specUrl,
596− });
597− reserveSourceName(sourceName);
598− } catch (error) {
599− toast.error(error instanceof Error ? error.message : "Failed to add API source");
600− } finally {
601− setAddingCatalogId(null);
602− }
603− };
604−
605− const handleCustomSubmit = async () => {
606− if (!context || !name.trim() || !endpoint.trim()) return;
607−
608− const takenNames = [...getTakenSourceNames()].map((entry) => entry.toLowerCase());
609− if (takenNames.includes(name.trim().toLowerCase())) {
610− toast.error(`Source name "${name.trim()}" already exists`);
611− return;
612− }
613−
614− setSubmitting(true);
615− try {
616− const config: Record<string, unknown> =
617− type === "mcp"
618− ? {
619− url: endpoint,
620− ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}),
621− ...(mcpActorQueryParamKey.trim() && context.actorId
622− ? { queryParams: { [mcpActorQueryParamKey.trim()]: context.actorId } }
623− : {}),
624− }
625− : type === "graphql"
626− ? { endpoint: endpoint }
627− : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) };
628− await addSource(name.trim(), type, config);
629− reserveSourceName(name.trim());
630− resetDialogState();
631− setOpen(false);
632− } catch (err) {
633− toast.error(err instanceof Error ? err.message : "Failed to add source");
634− } finally {
635− setSubmitting(false);
636− }
637− };
638−
639− return (
640− <Dialog open={open} onOpenChange={handleOpenChange}>
641− <DialogTrigger asChild>
642− <Button size="sm" className="h-8 text-xs">
643− <Plus className="h-3.5 w-3.5 mr-1.5" />
644− Add Source
645− </Button>
646− </DialogTrigger>
647− <DialogContent className="bg-card border-border sm:max-w-md p-0 gap-0">
648− <DialogHeader className="px-5 pt-5 pb-0">
649− <DialogTitle className="text-sm font-medium">
650− Add Tool Source
651− </DialogTitle>
652− </DialogHeader>
653−
654− <div className="p-5 space-y-4">
655− {view === "catalog" ? (
656− <div className="space-y-3">
657− <div className="flex items-center gap-2 flex-wrap">
658− <Input
659− value={catalogQuery}
660− onChange={(event) => setCatalogQuery(event.target.value)}
661− placeholder="Search APIs"
662− className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]"
663− />
664− <Select
665− value={catalogSort}
666− onValueChange={(value) => setCatalogSort(value as "popular" | "recent")}
667− >
668− <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0">
669− <SelectValue />
670− </SelectTrigger>
671− <SelectContent>
672− <SelectItem value="popular" className="text-xs">Popular</SelectItem>
673− <SelectItem value="recent" className="text-xs">Recent</SelectItem>
674− </SelectContent>
675− </Select>
676− </div>
677−
678− <p className="text-[11px] text-muted-foreground">
679− Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}.
680− </p>
681−
682− <Separator />
683−
684− <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1">
685− <button
686− type="button"
687− onClick={() => setView("custom")}
688− className="w-full max-w-full overflow-hidden text-left px-3 py-2 rounded-md border border-border/70 bg-muted/40 hover:bg-accent/30 transition-colors"
689− >
690− <div className="flex items-center gap-2">
691− <span className="inline-flex h-6 w-6 items-center justify-center rounded bg-muted">
692− <Plus className="h-3.5 w-3.5 text-muted-foreground" />
693− </span>
694− <div className="min-w-0 w-0 flex-1 overflow-hidden">
695− <p className="text-xs font-medium">Add custom source</p>
696− <p className="text-[10px] text-muted-foreground">MCP, OpenAPI, or GraphQL endpoint</p>
697− </div>
698− </div>
699− </button>
700−
701− {visibleCatalogItems.map((item) => (
702− <div
703− key={item.id}
704− className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50"
705− >
706− {item.logoUrl && (
707− <img
708− src={item.logoUrl}
709− alt=""
710− className="w-5 h-5 rounded shrink-0 mt-0.5 object-contain"
711− loading="lazy"
712− />
713− )}
714− <div className="flex-1 min-w-0 w-0 overflow-hidden">
715− <p className="text-xs font-medium truncate">{item.name}</p>
716− <p className="text-[10px] text-muted-foreground truncate">
717− {item.providerName}
718− {item.version ? ` · v${item.version}` : ""}
719− </p>
720− {item.summary && (
721− <div
722− className="mt-0.5 w-full min-w-0 max-w-full overflow-hidden text-[10px] text-muted-foreground/90 leading-relaxed break-words [overflow-wrap:anywhere] [&_*]:max-w-full [&_*]:min-w-0 [&_*]:break-words [&_a]:break-all [&_a]:whitespace-normal [&_code]:break-all [&_code]:whitespace-pre-wrap [&_p]:m-0 [&_pre]:max-w-full [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:break-words line-clamp-2"
723− style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}
724− >
725− <Streamdown controls={false}>{item.summary}</Streamdown>
726− </div>
727− )}
728− </div>
729− <div className="flex items-center gap-1 shrink-0">
730− <Button
731− type="button"
732− size="sm"
733− className="h-7 px-2 text-[11px]"
734− onClick={() => void handleCatalogAdd(item)}
735− disabled={Boolean(addingCatalogId)}
736− >
737− {addingCatalogId === item.id ? "Adding..." : "Add"}
738− </Button>
739− </div>
740− </div>
741− ))}
742−
743− {visibleCatalogItems.length === 0 && (
744− <p className="text-[11px] text-muted-foreground px-1 py-1">
745− No collections found for this query.
746− </p>
747− )}
748− </div>
749− </div>
750− ) : (
751− <div className="space-y-3">
752− <Button
753− type="button"
754− variant="ghost"
755− size="sm"
756− className="h-7 px-2 text-xs"
757− onClick={() => setView("catalog")}
758− >
759− <ChevronRight className="h-3.5 w-3.5 mr-1 rotate-180" />
760− Back to API list
761− </Button>
762−
763− <div className="space-y-1.5">
764− <Label className="text-xs text-muted-foreground">Type</Label>
765− <Select
766− value={type}
767− onValueChange={(value) => setType(value as "mcp" | "openapi" | "graphql")}
768− >
769− <SelectTrigger className="h-8 text-xs bg-background">
770− <SelectValue />
771− </SelectTrigger>
772− <SelectContent>
773− <SelectItem value="mcp" className="text-xs">MCP Server</SelectItem>
774− <SelectItem value="openapi" className="text-xs">OpenAPI Spec</SelectItem>
775− <SelectItem value="graphql" className="text-xs">GraphQL</SelectItem>
776− </SelectContent>
777− </Select>
778− </div>
779−
780− <div className="space-y-1.5">
781− <Label className="text-xs text-muted-foreground">
782− {type === "mcp" ? "Endpoint URL" : type === "graphql" ? "GraphQL Endpoint" : "Spec URL"}
783− </Label>
784− <Input
785− value={endpoint}
786− onChange={(event) => handleEndpointChange(event.target.value)}
787− placeholder={
788− type === "mcp"
789− ? "https://mcp-server.example.com/sse"
790− : type === "graphql"
791− ? "https://api.example.com/graphql"
792− : "https://api.example.com/openapi.json"
793− }
794− className="h-8 text-xs font-mono bg-background"
795− />
796− </div>
797−
798− <div className="space-y-1.5">
799− <Label className="text-xs text-muted-foreground">Name</Label>
800− <Input
801− value={name}
802− onChange={(event) => handleNameChange(event.target.value)}
803− placeholder="e.g. my-service"
804− className="h-8 text-xs font-mono bg-background"
805− />
806− </div>
807−
808− {type === "openapi" && (
809− <div className="space-y-1.5">
810− <Label className="text-xs text-muted-foreground">Base URL (optional)</Label>
811− <Input
812− value={baseUrl}
813− onChange={(event) => setBaseUrl(event.target.value)}
814− placeholder="https://api.example.com"
815− className="h-8 text-xs font-mono bg-background"
816− />
817− </div>
818− )}
819−
820− {type === "mcp" && (
821− <>
822− <div className="space-y-1.5">
823− <Label className="text-xs text-muted-foreground">Transport</Label>
824− <Select
825− value={mcpTransport}
826− onValueChange={(value) => setMcpTransport(value as "auto" | "streamable-http" | "sse")}
827− >
828− <SelectTrigger className="h-8 text-xs bg-background">
829− <SelectValue />
830− </SelectTrigger>
831− <SelectContent>
832− <SelectItem value="auto" className="text-xs">Auto (streamable, then SSE)</SelectItem>
833− <SelectItem value="streamable-http" className="text-xs">Streamable HTTP</SelectItem>
834− <SelectItem value="sse" className="text-xs">SSE</SelectItem>
835− </SelectContent>
836− </Select>
837− </div>
838−
839− <div className="space-y-1.5">
840− <Label className="text-xs text-muted-foreground">Anon actor query key (optional)</Label>
841− <Input
842− value={mcpActorQueryParamKey}
843− onChange={(event) => setMcpActorQueryParamKey(event.target.value)}
844− placeholder="userId"
845− className="h-8 text-xs font-mono bg-background"
846− />
847− </div>
848− </>
849− )}
850−
851− <Button
852− onClick={handleCustomSubmit}
853− disabled={submitting || !name.trim() || !endpoint.trim()}
854− className="w-full h-9"
855− size="sm"
856− >
857− {submitting ? "Adding..." : "Add Source"}
858− </Button>
859− </div>
860− )}
861− </div>
862− </DialogContent>
863− </Dialog>
864− );
865−}
866−
867−// ── Source Card ──
868−
869−function ConfigureSourceAuthDialog({
870− source,
871− inferredProfile,
872−}: {
873− source: ToolSourceRecord;
874− inferredProfile?: SourceAuthProfile;
875−}) {
876− const { context } = useSession();
877− const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
878− const [open, setOpen] = useState(false);
879− const [saving, setSaving] = useState(false);
880−
881− const currentAuth = readSourceAuth(source, inferredProfile);
882− const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type;
883− const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>(editableInitialAuthType);
884− const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace");
885− const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key");
886−
887− const configurable = source.type === "openapi" || source.type === "graphql";
888−
889− const resetFromSource = () => {
890− const auth = readSourceAuth(source, inferredProfile);
891− setAuthType(auth.type === "mixed" ? "bearer" : auth.type);
892− setAuthMode(auth.mode ?? "workspace");
893− setApiKeyHeader(auth.header ?? "x-api-key");
894− };
895−
896− const handleOpenChange = (isOpen: boolean) => {
897− setOpen(isOpen);
898− if (isOpen) {
899− resetFromSource();
900− }
901− };
902−
903− const handleSave = async () => {
904− if (!context || !configurable) return;
905− setSaving(true);
906− try {
907− const authConfig: Record<string, unknown> =
908− authType === "none"
909− ? { type: "none" }
910− : authType === "apiKey"
911− ? { type: "apiKey", mode: authMode, header: apiKeyHeader.trim() || "x-api-key" }
912− : { type: authType, mode: authMode };
913−
914− await upsertToolSource({
915− id: source.id,
916− workspaceId: context.workspaceId,
917− sessionId: context.sessionId,
918− name: source.name,
919− type: source.type,
920− config: {
921− ...source.config,
922− auth: authConfig,
923− },
924− });
925−
926− toast.success(`Updated auth for ${source.name}`);
927− setOpen(false);
928− } catch (err) {
929− toast.error(err instanceof Error ? err.message : "Failed to update auth");
930− } finally {
931− setSaving(false);
932− }
933− };
934−
935− if (!configurable) {
936− return null;
937− }
938−
939− return (
940− <Dialog open={open} onOpenChange={handleOpenChange}>
941− <DialogTrigger asChild>
942− <Button variant="outline" size="sm" className="h-7 text-[11px]">
943− <Pencil className="h-3 w-3 mr-1.5" />
944− Auth
945− </Button>
946− </DialogTrigger>
947− <DialogContent className="bg-card border-border sm:max-w-md">
948− <DialogHeader>
949− <DialogTitle className="text-sm font-medium">Configure Source Auth</DialogTitle>
950− </DialogHeader>
951− <div className="space-y-3">
952− <div className="space-y-1.5">
953− <Label className="text-xs text-muted-foreground">Source</Label>
954− <Input value={source.name} readOnly className="h-8 text-xs font-mono bg-background" />
955− </div>
956−
957− <div className="space-y-1.5">
958− <Label className="text-xs text-muted-foreground">Auth Type</Label>
959− <Select value={authType} onValueChange={(value) => setAuthType(value as Exclude<SourceAuthType, "mixed">)}>
960− <SelectTrigger className="h-8 text-xs bg-background">
961− <SelectValue />
962− </SelectTrigger>
963− <SelectContent>
964− <SelectItem value="none" className="text-xs">None</SelectItem>
965− <SelectItem value="bearer" className="text-xs">Bearer token</SelectItem>
966− <SelectItem value="apiKey" className="text-xs">API key header</SelectItem>
967− <SelectItem value="basic" className="text-xs">Basic auth</SelectItem>
968− </SelectContent>
969− </Select>
970− </div>
971−
972− {currentAuth.inferred && (
973− <p className="text-[11px] text-muted-foreground">
974− Suggested from spec inference. Save to pin an explicit auth config.
975− </p>
976− )}
977−
978− {authType !== "none" && (
979− <>
980− <div className="space-y-1.5">
981− <Label className="text-xs text-muted-foreground">Credential Scope</Label>
982− <Select value={authMode} onValueChange={(value) => setAuthMode(value as SourceAuthMode)}>
983− <SelectTrigger className="h-8 text-xs bg-background">
984− <SelectValue />
985− </SelectTrigger>
986− <SelectContent>
987− <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
988− <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem>
989− </SelectContent>
990− </Select>
991− </div>
992−
993− {authType === "apiKey" && (
994− <div className="space-y-1.5">
995− <Label className="text-xs text-muted-foreground">Header Name</Label>
996− <Input
997− value={apiKeyHeader}
998− onChange={(e) => setApiKeyHeader(e.target.value)}
999− placeholder="x-api-key"
1000− className="h-8 text-xs font-mono bg-background"
1001− />
1002− </div>
1003− )}
1004−
1005− <p className="text-[11px] text-muted-foreground">
1006− Save this first, then add a connection in the Connections tab using source key
1007− <code className="ml-1">{sourceKeyForSource(source)}</code>.
1008− </p>
1009− </>
1010− )}
1011−
1012− <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm">
1013− {saving ? "Saving..." : "Save Auth"}
1014− </Button>
1015− </div>
1016− </DialogContent>
1017− </Dialog>
1018− );
1019−}
1020−
1021−function SourceCard({
1022− source,
1023− quality,
1024− qualityLoading,
1025− credentialStats,
1026− sourceAuthProfiles,
1027− selected = false,
1028− onFocusSource,
1029−}: {
1030− source: ToolSourceRecord;
1031− quality?: OpenApiSourceQuality;
1032− qualityLoading?: boolean;
1033− credentialStats: { workspaceCount: number; actorCount: number };
1034− sourceAuthProfiles: Record<string, SourceAuthProfile>;
1035− selected?: boolean;
1036− onFocusSource?: (sourceName: string) => void;
1037−}) {
1038− const { context } = useSession();
1039− const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
1040− const [deleting, setDeleting] = useState(false);
1041− const [detailsOpen, setDetailsOpen] = useState(false);
1042−
1043− const handleDelete = async () => {
1044− if (!context) return;
1045− setDeleting(true);
1046− try {
1047− await deleteToolSource({
1048− workspaceId: context.workspaceId,
1049− sessionId: context.sessionId,
1050− sourceId: source.id,
1051− });
1052− toast.success(`Removed "${source.name}"`);
1053− } catch (err) {
1054− toast.error(err instanceof Error ? err.message : "Failed to delete");
1055− } finally {
1056− setDeleting(false);
1057− }
1058− };
1059−
1060− const TypeIcon = source.type === "mcp" ? Server : Globe;
1061− const favicon = getSourceFavicon(source);
1062− const sourceKey = sourceKeyForSource(source) ?? "";
1063− const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]);
1064− const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]);
1065− const hasAuthConfigured = auth.type !== "none";
1066− const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount;
1067− const prettyName = displaySourceName(source.name);
1068− const compactEndpoint = compactEndpointLabel(source);
1069− const showTypeSummary = source.type === "openapi" && (quality || qualityLoading);
1070−
1071− return (
1072− <div
1073− className={cn(
1074− "group flex items-start gap-3 rounded-lg border border-border/60 bg-gradient-to-b from-muted/45 to-muted/20 px-3 py-3",
1075− selected && "border-primary/35 bg-primary/5",
1076− )}
1077− >
1078− <div className="mt-0.5 h-9 w-9 rounded-md bg-muted/80 flex items-center justify-center shrink-0 overflow-hidden">
1079− {favicon ? (
1080− <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" />
1081− ) : (
1082− <TypeIcon className="h-4 w-4 text-muted-foreground" />
1083− )}
1084− </div>
1085− <div className="flex-1 min-w-0">
1086− <div className="flex items-center gap-2 min-w-0">
1087− <span className="text-sm font-medium truncate" title={source.name}>
1088− {prettyName}
1089− </span>
1090− <Badge
1091− variant="outline"
1092− className="text-[9px] uppercase tracking-wide"
1093− >
1094− {source.type}
1095− </Badge>
1096− {!source.enabled && (
1097− <Badge
1098− variant="outline"
1099− className="text-[9px] uppercase tracking-wide text-terminal-red border-terminal-red/30"
1100− >
1101− disabled
1102− </Badge>
1103− )}
1104− {authBadge && (
1105− <Badge
1106− variant="outline"
1107− className="text-[9px] uppercase tracking-wide text-primary border-primary/30"
1108− >
1109− {authBadge}
1110− </Badge>
1111− )}
1112− {hasAuthConfigured && (
1113− <Badge
1114− variant="outline"
1115− className={cn(
1116− "text-[9px] uppercase tracking-wide",
1117− totalCredentials > 0
1118− ? "text-terminal-green border-terminal-green/30"
1119− : "text-terminal-amber border-terminal-amber/30",
1120− )}
1121− >
1122− {totalCredentials > 0 ? "connections ready" : "connection needed"}
1123− </Badge>
1124− )}
1125− </div>
1126− <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}>
1127− {compactEndpoint}
1128− </span>
1129− {showTypeSummary && (
1130− <div className="mt-1.5 flex items-center gap-2">
1131− {quality ? (
1132− <Badge
1133− variant="outline"
1134− className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))}
1135− >
1136− {formatQualityPercent(quality.overallQuality)} {qualitySummaryLabel(quality)}
1137− </Badge>
1138− ) : (
1139− <Badge variant="outline" className="text-[9px] uppercase tracking-wide text-muted-foreground">
1140− analyzing type quality
1141− </Badge>
1142− )}
1143− </div>
1144− )}
1145− {source.type === "openapi" && (
1146− <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
1147− <div className="mt-1.5">
1148− <CollapsibleTrigger asChild>
1149− <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground">
1150− <ChevronRight
1151− className={cn("mr-1 h-3 w-3 transition-transform", detailsOpen && "rotate-90")}
1152− />
1153− {detailsOpen ? "Hide details" : "View details"}
1154− </Button>
1155− </CollapsibleTrigger>
1156− </div>
1157− <CollapsibleContent className="mt-1.5">
1158− <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2">
1159− {quality && (
1160− <div className="space-y-1.5 text-[10px]">
1161− <div className="flex items-center justify-between">
1162− <span className="text-muted-foreground">Type quality</span>
1163− <span className={cn("font-medium", qualityToneClass(quality))}>
1164− {formatQualityPercent(quality.overallQuality)}
1165− </span>
1166− </div>
1167− <div className="flex items-center justify-between text-muted-foreground">
1168− <span>Args quality</span>
1169− <span>{formatQualityPercent(quality.argsQuality)}</span>
1170− </div>
1171− <div className="flex items-center justify-between text-muted-foreground">
1172− <span>Returns quality</span>
1173− <span>{formatQualityPercent(quality.returnsQuality)}</span>
1174− </div>
1175− <div className="flex items-center justify-between text-muted-foreground">
1176− <span>Inferred returns</span>
1177− <span>{quality.unknownReturnsCount}</span>
1178− </div>
1179− </div>
1180− )}
1181− {!quality && qualityLoading && (
1182− <div className="flex items-center justify-between text-[10px] text-muted-foreground">
1183− <span>Analyzing OpenAPI typing</span>
1184− <span className="inline-flex items-center gap-1 text-[10px]">
1185− <span className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-pulse" />
1186− in progress
1187− </span>
1188− </div>
1189− )}
1190− {!quality && !qualityLoading && (
1191− <div className="text-[10px] text-muted-foreground">Type quality data unavailable.</div>
1192− )}
1193− </div>
1194− </CollapsibleContent>
1195− </Collapsible>
1196− )}
1197− </div>
1198− <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
1199− {onFocusSource ? (
1200− <Button
1201− variant={selected ? "default" : "outline"}
1202− size="sm"
1203− className="h-7 text-[11px]"
1204− onClick={() => onFocusSource(source.name)}
1205− >
1206− {selected ? "Viewing" : "View tools"}
1207− </Button>
1208− ) : null}
1209− <ConfigureSourceAuthDialog source={source} inferredProfile={sourceAuthProfiles[sourceKey]} />
1210− <Button
1211− variant="ghost"
1212− size="icon"
1213− className="h-7 w-7 text-muted-foreground hover:text-terminal-red"
1214− onClick={handleDelete}
1215− disabled={deleting}
1216− >
1217− <Trash2 className="h-3.5 w-3.5" />
1218− </Button>
1219− </div>
1220− </div>
1221− );
1222−}
1223−
1224−type SourceOption = { source: ToolSourceRecord; key: string; label: string };
1225−
1226−function sourceAuthForKey(
1227− sourceOptions: SourceOption[],
1228− key: string,
1229− inferredProfiles: Record<string, SourceAuthProfile> = {},
1230−): {
1231− type: SourceAuthType;
1232− mode?: SourceAuthMode;
1233− header?: string;
1234− inferred?: boolean;
1235−} {
1236− const match = sourceOptions.find((entry) => entry.key === key);
1237− if (!match) {
1238− return { type: "bearer" };
1239− }
1240− return readSourceAuth(match.source, inferredProfiles[key]);
1241−}
1242−
1243−function sourceOptionLabel(source: ToolSourceRecord): string {
1244− return `${source.name} (${source.type})`;
1245−}
1246−
1247−function providerLabel(provider: "local-convex" | "workos-vault"): string {
1248− return provider === "workos-vault" ? "encrypted" : "local";
1249−}
1250−
1251−function connectionDisplayName(
1252− sources: ToolSourceRecord[],
1253− connection: {
1254− scope: CredentialScope;
1255− sourceKeys: Set<string>;
1256− actorId?: string;
1257− },
1258−): string {
1259− const sourceNames = [...connection.sourceKeys]
1260− .map((sourceKey) => sourceForCredentialKey(sources, sourceKey))
1261− .filter((source): source is ToolSourceRecord => Boolean(source))
1262− .map((source) => displaySourceName(source.name));
1263−
1264− const primary = sourceNames[0] ?? "API";
1265− const extraCount = Math.max(sourceNames.length - 1, 0);
1266− const base = extraCount > 0 ? `${primary} +${extraCount}` : primary;
1267−
1268− if (connection.scope === "actor") {
1269− if (connection.actorId) {
1270− return `${base} personal (${connection.actorId})`;
1271− }
1272− return `${base} personal`;
1273− }
1274−
1275− return `${base} workspace`;
1276−}
1277−
1278−function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } {
1279− const lines = text
1280− .split("\n")
1281− .map((line) => line.trim())
1282− .filter((line) => line.length > 0);
1283− if (lines.length === 0) {
1284− return { value: {} };
1285− }
1286−
1287− const headers: Record<string, string> = {};
1288− for (const line of lines) {
1289− const separator = line.indexOf(":");
1290− if (separator <= 0) {
1291− return { error: `Invalid header line: ${line}` };
1292− }
1293− const name = line.slice(0, separator).trim();
1294− const value = line.slice(separator + 1).trim();
1295− if (!name || !value) {
1296− return { error: `Invalid header line: ${line}` };
1297− }
1298− headers[name] = value;
1299− }
1300−
1301− return { value: headers };
1302−}
1303−
1304−function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {
1305− const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {};
1306− return Object.entries(headers)
1307− .map(([key, value]) => `${key}: ${String(value)}`)
1308− .join("\n");
1309−}
1310−
1311−function CredentialsPanel({
1312− sources,
1313− credentials,
1314− sourceAuthProfiles,
1315− loading,
1316− focusSourceKey,
1317− onFocusHandled,
1318−}: {
1319− sources: ToolSourceRecord[];
1320− credentials: CredentialRecord[];
1321− sourceAuthProfiles: Record<string, SourceAuthProfile>;
1322− loading: boolean;
1323− focusSourceKey?: string | null;
1324− onFocusHandled?: () => void;
1325−}) {
1326− const { context, clientConfig } = useSession();
1327− const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
1328− const [open, setOpen] = useState(false);
1329− const [saving, setSaving] = useState(false);
1330− const [editing, setEditing] = useState<CredentialRecord | null>(null);
1331− const [sourceKey, setSourceKey] = useState("");
1332− const [scope, setScope] = useState<CredentialScope>("workspace");
1333− const [actorId, setActorId] = useState("");
1334− const [connectionMode, setConnectionMode] = useState<"new" | "existing">("new");
1335− const [existingConnectionId, setExistingConnectionId] = useState("");
1336− const [tokenValue, setTokenValue] = useState("");
1337− const [apiKeyValue, setApiKeyValue] = useState("");
1338− const [basicUsername, setBasicUsername] = useState("");
1339− const [basicPassword, setBasicPassword] = useState("");
1340− const [customHeadersText, setCustomHeadersText] = useState("");
1341−
1342− const storageCopy = clientConfig?.authProviderMode === "workos"
1343− ? "Stored encrypted"
1344− : "Stored locally on this machine";
1345−
1346− const sourceOptions = sources
1347− .map((source) => {
1348− const key = sourceKeyForSource(source);
1349− return {
1350− source,
1351− key,
1352− label: sourceOptionLabel(source),
1353− };
1354− })
1355− .filter((entry): entry is SourceOption => entry.key !== null);
1356−
1357− const connectionOptions = useMemo(() => {
1358− const grouped = new Map<string, {
1359− id: string;
1360− scope: CredentialScope;
1361− actorId?: string;
1362− provider: "local-convex" | "workos-vault";
1363− sourceKeys: Set<string>;
1364− updatedAt: number;
1365− }>();
1366−
1367− for (const credential of credentials) {
1368− const existing = grouped.get(credential.id);
1369− if (existing) {
1370− existing.sourceKeys.add(credential.sourceKey);
1371− existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt);
1372− } else {
1373− grouped.set(credential.id, {
1374− id: credential.id,
1375− scope: credential.scope,
1376− actorId: credential.actorId,
1377− provider: credential.provider,
1378− sourceKeys: new Set([credential.sourceKey]),
1379− updatedAt: credential.updatedAt,
1380− });
1381− }
1382− }
1383−
1384− return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt);
1385− }, [credentials]);
1386−
1387− const representativeCredentialByConnection = useMemo(() => {
1388− const map = new Map<string, CredentialRecord>();
1389− for (const credential of credentials) {
1390− if (!map.has(credential.id)) {
1391− map.set(credential.id, credential);
1392− }
1393− }
1394− return map;
1395− }, [credentials]);
1396−
1397− const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey, sourceAuthProfiles);
1398− const compatibleConnectionOptions = connectionOptions.filter((connection) => {
1399− if (connection.scope !== scope) {
1400− return false;
1401− }
1402− if (scope === "actor") {
1403− return connection.actorId === actorId.trim();
1404− }
1405− return true;
1406− });
1407− const selectedAuthBadge = selectedAuth.type === "none"
1408− ? "none"
1409− : selectedAuth.type === "mixed"
1410− ? "mixed"
1411− : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`;
1412−
1413− useEffect(() => {
1414− if (!existingConnectionId) {
1415− return;
1416− }
1417− if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) {
1418− setExistingConnectionId("");
1419− }
1420− }, [compatibleConnectionOptions, existingConnectionId]);
1421−
1422− const resetForm = () => {
1423− const defaultSourceKey = sourceOptions[0]?.key ?? "";
1424− setSourceKey(defaultSourceKey);
1425− const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey, sourceAuthProfiles);
1426− setScope(defaultAuth.mode ?? "workspace");
1427− setActorId(context?.actorId ?? "");
1428− setConnectionMode("new");
1429− setExistingConnectionId("");
1430− setTokenValue("");
1431− setApiKeyValue("");
1432− setBasicUsername("");
1433− setBasicPassword("");
1434− setCustomHeadersText("");
1435− setEditing(null);
1436− };
1437−
1438− useEffect(() => {
1439− if (!focusSourceKey) {
1440− return;
1441− }
1442− resetForm();
1443− setSourceKey(focusSourceKey);
1444− const auth = sourceAuthForKey(sourceOptions, focusSourceKey, sourceAuthProfiles);
1445− setScope(auth.mode ?? "workspace");
1446− setOpen(true);
1447− onFocusHandled?.();
1448− }, [focusSourceKey, onFocusHandled, sourceAuthProfiles, sourceOptions]);
1449−
1450− const openForCreate = () => {
1451− resetForm();
1452− setOpen(true);
1453− };
1454−
1455− const openForEdit = (credential: CredentialRecord) => {
1456− setEditing(credential);
1457− setSourceKey(credential.sourceKey);
1458− setScope(credential.scope);
1459− setActorId(credential.actorId ?? context?.actorId ?? "");
1460− setConnectionMode("new");
1461− setExistingConnectionId(credential.id);
1462− setTokenValue("");
1463− setApiKeyValue("");
1464− setBasicUsername("");
1465− setBasicPassword("");
1466− setCustomHeadersText(formatHeaderOverrides(credential.overridesJson));
1467− setOpen(true);
1468− };
1469−
1470− const handleSourceKeyChange = (nextSourceKey: string) => {
1471− setSourceKey(nextSourceKey);
1472− const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles);
1473− if (!editing) {
1474− setScope(auth.mode ?? "workspace");
1475− }
1476− };
1477−
1478− const handleSave = async () => {
1479− if (!context) return;
1480− if (!sourceKey.trim()) {
1481− toast.error("Source key is required");
1482− return;
1483− }
1484− if (scope === "actor" && !actorId.trim()) {
1485− toast.error("Actor ID is required for actor-scoped credentials");
1486− return;
1487− }
1488−
1489− const parsedHeaders = parseHeaderOverrides(customHeadersText);
1490− if (!parsedHeaders.value) {
1491− toast.error(parsedHeaders.error ?? "Invalid header overrides");
1492− return;
1493− }
1494−
1495− const linkExisting = !editing && connectionMode === "existing";
1496− if (linkExisting && !existingConnectionId) {
1497− toast.error("Select an existing connection to link");
1498− return;
1499− }
1500− if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) {
1501− toast.error("Selected connection does not match this scope");
1502− return;
1503− }
1504−
1505− if (selectedAuth.type === "none") {
1506− toast.error("This source does not require auth");
1507− return;
1508− }
1509−
1510− if (selectedAuth.type === "mixed" && !linkExisting && !editing) {
1511− toast.error("Mixed-auth sources must link to an existing connection");
1512− return;
1513− }
1514−
1515− const secretJson: Record<string, unknown> = {};
1516− if (!linkExisting) {
1517− if (selectedAuth.type === "basic") {
1518− const hasUsername = basicUsername.trim().length > 0;
1519− const hasPassword = basicPassword.trim().length > 0;
1520− if (hasUsername || hasPassword) {
1521− if (!hasUsername || !hasPassword) {
1522− toast.error("Username and password are required for basic auth");
1523− return;
1524− }
1525− secretJson.username = basicUsername;
1526− secretJson.password = basicPassword;
1527− }
1528− } else if (selectedAuth.type === "apiKey") {
1529− if (apiKeyValue.trim()) {
1530− secretJson.value = apiKeyValue.trim();
1531− }
1532− } else if (selectedAuth.type === "bearer") {
1533− if (tokenValue.trim()) {
1534− secretJson.token = tokenValue.trim();
1535− }
1536− }
1537− }
1538−
1539− if (Object.keys(parsedHeaders.value).length > 0) {
1540− secretJson.__headers = parsedHeaders.value;
1541− }
1542−
1543− if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) {
1544− if (selectedAuth.type === "basic") {
1545− toast.error("Username and password are required");
1546− } else if (selectedAuth.type === "apiKey") {
1547− toast.error("API key value is required");
1548− } else {
1549− toast.error("Token is required");
1550− }
1551− return;
1552− }
1553−
1554− setSaving(true);
1555− try {
1556− await upsertCredential({
1557− ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}),
1558− workspaceId: context.workspaceId,
1559− sessionId: context.sessionId,
1560− sourceKey: sourceKey.trim(),
1561− scope,
1562− ...(scope === "actor" ? { actorId: actorId.trim() } : {}),
1563− secretJson,
1564− });
1565−
1566− toast.success(editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved");
1567− setOpen(false);
1568− resetForm();
1569− } catch (err) {
1570− toast.error(err instanceof Error ? err.message : "Failed to save connection");
1571− } finally {
1572− setSaving(false);
1573− }
1574− };
1575−
1576− return (
1577− <Card className="bg-card border-border">
1578− <CardHeader className="pb-3">
1579− <div className="flex items-center justify-between">
1580− <CardTitle className="text-sm font-medium flex items-center gap-2">
1581− <KeyRound className="h-4 w-4 text-muted-foreground" />
1582− Connections
1583− </CardTitle>
1584− <Button size="sm" className="h-8 text-xs" onClick={openForCreate}>
1585− <Plus className="h-3.5 w-3.5 mr-1.5" />
1586− Add Connection
1587− </Button>
1588− </div>
1589− </CardHeader>
1590− <CardContent className="pt-0 space-y-3">
1591− {loading ? (
1592− <div className="space-y-2">
1593− {Array.from({ length: 3 }).map((_, i) => (
1594− <Skeleton key={i} className="h-14" />
1595− ))}
1596− </div>
1597− ) : connectionOptions.length === 0 ? (
1598− <div className="flex flex-col items-center justify-center py-10 gap-2">
1599− <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
1600− <KeyRound className="h-5 w-5 text-muted-foreground/50" />
1601− </div>
1602− <p className="text-sm text-muted-foreground">No connections configured</p>
1603− <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">
1604− Add a source, then create or link a reusable connection.
1605− </p>
1606− </div>
1607− ) : (
1608− <div className="space-y-2">
1609− {connectionOptions.map((connection) => {
1610− const representative = representativeCredentialByConnection.get(connection.id);
1611− if (!representative) return null;
1612− const firstSource = sourceForCredentialKey(sources, representative.sourceKey);
1613− const favicon = firstSource ? getSourceFavicon(firstSource) : null;
1614−
1615− return (
1616− <div
1617− key={connection.id}
1618− className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40"
1619− >
1620− <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden">
1621− {favicon ? (
1622− <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" />
1623− ) : (
1624− <KeyRound className="h-4 w-4 text-muted-foreground" />
1625− )}
1626− </div>
1627− <div className="flex-1 min-w-0">
1628− <div className="flex items-center gap-1.5 mb-1 flex-wrap">
1629− <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span>
1630− <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1631− {connection.scope}
1632− </Badge>
1633− <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1634− {providerLabel(connection.provider)}
1635− </Badge>
1636− {connection.scope === "actor" && connection.actorId && (
1637− <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
1638− {connection.actorId}
1639− </span>
1640− )}
1641− </div>
1642− <p className="text-[11px] text-muted-foreground">
1643− Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} • {storageCopy}
1644− </p>
1645− <p className="text-[11px] text-muted-foreground mt-0.5">
1646− ID: <span className="font-mono">{connection.id}</span> •
1647− {" "}
1648− Updated {new Date(connection.updatedAt).toLocaleString()}
1649− </p>
1650− </div>
1651− <Button
1652− variant="outline"
1653− size="sm"
1654− className="h-7 text-[11px]"
1655− onClick={() => openForEdit(representative)}
1656− >
1657− Edit
1658− </Button>
1659− </div>
1660− );
1661− })}
1662− </div>
1663− )}
1664− </CardContent>
1665−
1666− <Dialog open={open} onOpenChange={setOpen}>
1667− <DialogContent className="bg-card border-border sm:max-w-lg">
1668− <DialogHeader>
1669− <DialogTitle className="text-sm font-medium">
1670− {editing ? "Edit Connection" : "Add Connection"}
1671− </DialogTitle>
1672− </DialogHeader>
1673− <div className="space-y-3">
1674− <div className="space-y-1.5">
1675− <Label className="text-xs text-muted-foreground">Source</Label>
1676− {sourceOptions.length > 0 ? (
1677− <Select value={sourceKey} onValueChange={handleSourceKeyChange}>
1678− <SelectTrigger className="h-8 text-xs bg-background">
1679− <SelectValue />
1680− </SelectTrigger>
1681− <SelectContent>
1682− {sourceOptions.map((entry) => (
1683− <SelectItem key={entry.key} value={entry.key} className="text-xs">
1684− {entry.label}
1685− </SelectItem>
1686− ))}
1687− </SelectContent>
1688− </Select>
1689− ) : (
1690− <Input
1691− value={sourceKey}
1692− onChange={(e) => handleSourceKeyChange(e.target.value)}
1693− placeholder="source:<source-id>"
1694− className="h-8 text-xs font-mono bg-background"
1695− />
1696− )}
1697− {sourceKey && (
1698− <p className="text-[10px] text-muted-foreground font-mono">key: {sourceKey}</p>
1699− )}
1700− </div>
1701−
1702− <div className="flex items-center gap-2 flex-wrap">
1703− <span className="text-[11px] text-muted-foreground">Detected auth</span>
1704− <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1705− {selectedAuthBadge}
1706− </Badge>
1707− {selectedAuth.inferred && (
1708− <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1709− inferred
1710− </Badge>
1711− )}
1712− {selectedAuth.type === "apiKey" && selectedAuth.header && (
1713− <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
1714− header: {selectedAuth.header}
1715− </span>
1716− )}
1717− </div>
1718−
1719− <div className="grid grid-cols-2 gap-3">
1720− <div className="space-y-1.5">
1721− <Label className="text-xs text-muted-foreground">Scope</Label>
1722− <Select value={scope} onValueChange={(value) => setScope(value as CredentialScope)}>
1723− <SelectTrigger className="h-8 text-xs bg-background">
1724− <SelectValue />
1725− </SelectTrigger>
1726− <SelectContent>
1727− <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
1728− <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem>
1729− </SelectContent>
1730− </Select>
1731− </div>
1732− <div className="space-y-1.5">
1733− <Label className="text-xs text-muted-foreground">Storage</Label>
1734− <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" />
1735− </div>
1736− </div>
1737−
1738− {scope === "actor" && (
1739− <div className="space-y-1.5">
1740− <Label className="text-xs text-muted-foreground">Actor ID</Label>
1741− <Input
1742− value={actorId}
1743− onChange={(e) => setActorId(e.target.value)}
1744− placeholder="actor_123"
1745− className="h-8 text-xs font-mono bg-background"
1746− />
1747− </div>
1748− )}
1749−
1750− {!editing && connectionOptions.length > 0 && (
1751− <div className="space-y-1.5">
1752− <Label className="text-xs text-muted-foreground">Connection Mode</Label>
1753− <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as "new" | "existing") }>
1754− <SelectTrigger className="h-8 text-xs bg-background">
1755− <SelectValue />
1756− </SelectTrigger>
1757− <SelectContent>
1758− <SelectItem value="new" className="text-xs">Create new connection</SelectItem>
1759− <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem>
1760− </SelectContent>
1761− </Select>
1762− </div>
1763− )}
1764−
1765− {!editing && connectionMode === "existing" && (
1766− <div className="space-y-1.5">
1767− <Label className="text-xs text-muted-foreground">Existing Connection</Label>
1768− <Select value={existingConnectionId} onValueChange={setExistingConnectionId}>
1769− <SelectTrigger className="h-8 text-xs bg-background">
1770− <SelectValue placeholder="Select a connection" />
1771− </SelectTrigger>
1772− <SelectContent>
1773− {compatibleConnectionOptions.map((connection) => (
1774− <SelectItem key={connection.id} value={connection.id} className="text-xs">
1775− {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"})
1776− </SelectItem>
1777− ))}
1778− </SelectContent>
1779− </Select>
1780− {compatibleConnectionOptions.length === 0 && (
1781− <p className="text-[10px] text-muted-foreground">No compatible existing connections for this scope.</p>
1782− )}
1783− </div>
1784− )}
1785−
1786− {(editing || connectionMode === "new") && (
1787− <>
1788− {editing && (
1789− <p className="text-[11px] text-muted-foreground">
1790− Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it.
1791− </p>
1792− )}
1793−
1794− {selectedAuth.type === "none" ? (
1795− <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p>
1796− ) : selectedAuth.type === "mixed" ? (
1797− <p className="text-[11px] text-terminal-amber">
1798− This source has mixed auth requirements. Link an existing connection for now.
1799− </p>
1800− ) : selectedAuth.type === "apiKey" ? (
1801− <div className="space-y-1.5">
1802− <Label className="text-xs text-muted-foreground">API Key Value</Label>
1803− <Input
1804− value={apiKeyValue}
1805− onChange={(e) => setApiKeyValue(e.target.value)}
1806− placeholder="sk_live_..."
1807− className="h-8 text-xs font-mono bg-background"
1808− />
1809− </div>
1810− ) : selectedAuth.type === "basic" ? (
1811− <div className="grid grid-cols-2 gap-3">
1812− <div className="space-y-1.5">
1813− <Label className="text-xs text-muted-foreground">Username</Label>
1814− <Input
1815− value={basicUsername}
1816− onChange={(e) => setBasicUsername(e.target.value)}
1817− className="h-8 text-xs font-mono bg-background"
1818− />
1819− </div>
1820− <div className="space-y-1.5">
1821− <Label className="text-xs text-muted-foreground">Password</Label>
1822− <Input
1823− type="password"
1824− value={basicPassword}
1825− onChange={(e) => setBasicPassword(e.target.value)}
1826− className="h-8 text-xs font-mono bg-background"
1827− />
1828− </div>
1829− </div>
1830− ) : (
1831− <div className="space-y-1.5">
1832− <Label className="text-xs text-muted-foreground">Bearer Token</Label>
1833− <Input
1834− type="password"
1835− value={tokenValue}
1836− onChange={(e) => setTokenValue(e.target.value)}
1837− placeholder="ghp_..."
1838− className="h-8 text-xs font-mono bg-background"
1839− />
1840− </div>
1841− )}
1842− </>
1843− )}
1844−
1845− <div className="space-y-1.5">
1846− <Label className="text-xs text-muted-foreground">Custom Headers (optional)</Label>
1847− <Textarea
1848− value={customHeadersText}
1849− onChange={(e) => setCustomHeadersText(e.target.value)}
1850− rows={4}
1851− placeholder="x-tenant-id: acme\nx-env: staging"
1852− className="text-xs font-mono bg-background"
1853− />
1854− </div>
1855−
1856− <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm">
1857− {saving ? "Saving..." : editing ? "Update Connection" : connectionMode === "existing" ? "Link Connection" : "Save Connection"}
1858− </Button>
1859− </div>
1860− </DialogContent>
1861− </Dialog>
1862− </Card>
1863− );
1864−}
25+import {
26+ credentialStatsForSource,
27+ sourceKeyForSource,
28+ toolSourceLabelForSource,
29+} from "@/lib/tools-source-helpers";
30+import { workspaceQueryArgs } from "@/lib/workspace-query-args";
186531
186632 // ── Tool Inventory (legacy removed — replaced by ToolExplorer) ──
186733
@@ -1875,14 +41,14 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 7 unmodified lines
187541
187642 const sources = useQuery(
187743 convexApi.workspace.listToolSources,
1878− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
44+ workspaceQueryArgs(context),
187945 );
188046 const sourceItems: ToolSourceRecord[] = sources ?? [];
188147 const sourcesLoading = !!context && sources === undefined;
188248
188349 const credentials = useQuery(
188450 convexApi.workspace.listCredentials,
1885− context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
51+ workspaceQueryArgs(context),
188652 );
188753 const credentialItems: CredentialRecord[] = credentials ?? [];
188854 const credentialsLoading = !!context && credentials === undefined;
executor/apps/web/src/components/workspace-selector.tsx+351−0
@@ -0,0 +1,351 @@
1+"use client";
2+
3+import { useMemo, useState, type FormEvent } from "react";
4+import { useNavigate } from "react-router";
5+import { useMutation } from "convex/react";
6+import { ChevronsUpDown, Plus, Check, Settings } from "lucide-react";
7+import Image from "next/image";
8+import { Button } from "@/components/ui/button";
9+import {
10+ DropdownMenu,
11+ DropdownMenuContent,
12+ DropdownMenuItem,
13+ DropdownMenuSeparator,
14+ DropdownMenuTrigger,
15+} from "@/components/ui/dropdown-menu";
16+import {
17+ Dialog,
18+ DialogContent,
19+ DialogDescription,
20+ DialogFooter,
21+ DialogHeader,
22+ DialogTitle,
23+} from "@/components/ui/dialog";
24+import { Input } from "@/components/ui/input";
25+import { useSession } from "@/lib/session-context";
26+import { convexApi } from "@/lib/convex-api";
27+import { cn } from "@/lib/utils";
28+
29+export function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) {
30+ const navigate = useNavigate();
31+ const createOrganizationMutation = useMutation(convexApi.organizations.create);
32+ const {
33+ context,
34+ mode,
35+ workspaces,
36+ switchWorkspace,
37+ creatingWorkspace,
38+ createWorkspace,
39+ } = useSession();
40+ const [createOpen, setCreateOpen] = useState(false);
41+ const [newWorkspaceName, setNewWorkspaceName] = useState("");
42+ const [newWorkspaceIcon, setNewWorkspaceIcon] = useState<File | null>(null);
43+ const [createScope, setCreateScope] = useState<"current_org" | "new_org">("current_org");
44+ const [createTargetOrganizationId, setCreateTargetOrganizationId] = useState<(typeof workspaces)[number]["organizationId"]>(null);
45+ const [createTargetOrganizationName, setCreateTargetOrganizationName] = useState<string | null>(null);
46+ const [creatingOrganization, setCreatingOrganization] = useState(false);
47+ const [createError, setCreateError] = useState<string | null>(null);
48+
49+ const activeWorkspace = context
50+ ? workspaces.find((workspace) => workspace.id === context.workspaceId)
51+ : null;
52+ const supportsOrganizationManagement = mode !== "guest";
53+ const activeOrganizationLabel = activeWorkspace?.organizationName ?? "Organization";
54+ const activeWorkspaceLabel = activeWorkspace?.name ?? (mode === "guest" ? "Anonymous Workspace" : "Select workspace");
55+ const activeWorkspaceInitial = (activeWorkspaceLabel[0] ?? "W").toUpperCase();
56+
57+ const workspaceGroups = useMemo(() => {
58+ if (!supportsOrganizationManagement) {
59+ return [];
60+ }
61+
62+ const groups: Array<{
63+ key: string;
64+ organizationId: (typeof workspaces)[number]["organizationId"];
65+ organizationName: string;
66+ workspaces: Array<(typeof workspaces)[number]>;
67+ }> = [];
68+ const byKey = new Map<string, number>();
69+
70+ for (const workspace of workspaces) {
71+ const key = workspace.organizationId ? `org:${workspace.organizationId}` : `workspace:${workspace.id}`;
72+ const existingIndex = byKey.get(key);
73+ if (existingIndex === undefined) {
74+ byKey.set(key, groups.length);
75+ groups.push({
76+ key,
77+ organizationId: workspace.organizationId,
78+ organizationName: workspace.organizationName,
79+ workspaces: [workspace],
80+ });
81+ continue;
82+ }
83+
84+ groups[existingIndex].workspaces.push(workspace);
85+ }
86+
87+ return groups;
88+ }, [supportsOrganizationManagement, workspaces]);
89+
90+ const openCreateWorkspaceForOrganization = (
91+ organizationId: (typeof workspaces)[number]["organizationId"],
92+ organizationName: string,
93+ ) => {
94+ setCreateError(null);
95+ setNewWorkspaceName("");
96+ setNewWorkspaceIcon(null);
97+ setCreateScope("current_org");
98+ setCreateTargetOrganizationId(organizationId);
99+ setCreateTargetOrganizationName(organizationName);
100+ setCreateOpen(true);
101+ };
102+
103+ const openCreateOrganization = () => {
104+ setCreateError(null);
105+ setNewWorkspaceName("");
106+ setNewWorkspaceIcon(null);
107+ setCreateScope("new_org");
108+ setCreateTargetOrganizationId(null);
109+ setCreateTargetOrganizationName(null);
110+ setCreateOpen(true);
111+ };
112+
113+ const handleCreateWorkspace = async (event?: FormEvent<HTMLFormElement>) => {
114+ event?.preventDefault();
115+
116+ const trimmed = newWorkspaceName.trim();
117+ if (trimmed.length < 2) {
118+ setCreateError("Workspace name must be at least 2 characters.");
119+ return;
120+ }
121+
122+ try {
123+ if (createScope === "new_org") {
124+ setCreatingOrganization(true);
125+ const created = await createOrganizationMutation({ name: trimmed });
126+ switchWorkspace(created.workspace.id);
127+ } else {
128+ if (!createTargetOrganizationId) {
129+ throw new Error("Select an organization first");
130+ }
131+
132+ await createWorkspace(trimmed, newWorkspaceIcon, createTargetOrganizationId);
133+ }
134+
135+ setCreateError(null);
136+ setNewWorkspaceName("");
137+ setNewWorkspaceIcon(null);
138+ setCreateOpen(false);
139+ } catch (cause) {
140+ const fallback = createScope === "new_org" ? "Failed to create organization" : "Failed to create workspace";
141+ setCreateError(cause instanceof Error ? cause.message : fallback);
142+ } finally {
143+ setCreatingOrganization(false);
144+ }
145+ };
146+
147+ const triggerClassName = inHeader
148+ ? "h-full w-full justify-between rounded-none border-0 bg-transparent px-3 text-[12px] font-medium shadow-none hover:bg-accent/40"
149+ : "h-8 w-full justify-between text-[11px]";
150+ const isCreating = creatingWorkspace || creatingOrganization;
151+
152+ return (
153+ <>
154+ <DropdownMenu>
155+ <DropdownMenuTrigger asChild>
156+ <Button
157+ variant={inHeader ? "ghost" : "outline"}
158+ size="sm"
159+ className={triggerClassName}
160+ >
161+ <span className="flex items-center gap-2 min-w-0">
162+ {activeWorkspace?.iconUrl ? (
163+ <Image
164+ src={activeWorkspace.iconUrl}
165+ alt={activeWorkspaceLabel}
166+ width={16}
167+ height={16}
168+ className="h-4 w-4 rounded-sm border border-border object-cover"
169+ unoptimized
170+ />
171+ ) : (
172+ <span className="h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground">
173+ {activeWorkspaceInitial}
174+ </span>
175+ )}
176+ <span className="min-w-0">
177+ {supportsOrganizationManagement ? (
178+ <>
179+ <span className="truncate block text-[10px] text-muted-foreground">{activeOrganizationLabel}</span>
180+ <span className="truncate block">{activeWorkspaceLabel}</span>
181+ </>
182+ ) : (
183+ <span className="truncate block">{activeWorkspaceLabel}</span>
184+ )}
185+ </span>
186+ </span>
187+ <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
188+ </Button>
189+ </DropdownMenuTrigger>
190+ <DropdownMenuContent align="end" className="w-72">
191+ {supportsOrganizationManagement
192+ ? (
193+ <>
194+ {workspaceGroups.map((group, groupIndex) => {
195+ const settingsWorkspace = group.workspaces.find((workspace) => workspace.id === context?.workspaceId)
196+ ?? group.workspaces[0]
197+ ?? null;
198+
199+ return (
200+ <div key={group.key}>
201+ {groupIndex > 0 ? <DropdownMenuSeparator /> : null}
202+ <div className="px-2 py-1.5 flex items-center justify-between gap-2">
203+ <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground truncate">
204+ {group.organizationName}
205+ </span>
206+ {settingsWorkspace ? (
207+ <button
208+ type="button"
209+ className="inline-flex h-5 w-5 items-center justify-center rounded-sm border border-border text-muted-foreground hover:bg-accent hover:text-foreground"
210+ onClick={() => {
211+ switchWorkspace(settingsWorkspace.id);
212+ navigate("/organization?tab=members");
213+ }}
214+ aria-label={`Open ${group.organizationName} settings`}
215+ title="Organization settings"
216+ >
217+ <Settings className="h-3 w-3" />
218+ </button>
219+ ) : null}
220+ </div>
221+ {group.workspaces.map((workspace) => {
222+ const isActive = workspace.id === context?.workspaceId;
223+ return (
224+ <DropdownMenuItem
225+ key={workspace.id}
226+ onSelect={() => switchWorkspace(workspace.id)}
227+ className="text-xs"
228+ >
229+ <Check className={cn("mr-2 h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} />
230+ {workspace.iconUrl ? (
231+ <Image
232+ src={workspace.iconUrl}
233+ alt={workspace.name}
234+ width={16}
235+ height={16}
236+ className="mr-2 h-4 w-4 rounded-sm border border-border object-cover"
237+ unoptimized
238+ />
239+ ) : (
240+ <span className="mr-2 h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground">
241+ {(workspace.name[0] ?? "W").toUpperCase()}
242+ </span>
243+ )}
244+ <span className="min-w-0 truncate">{workspace.name}</span>
245+ </DropdownMenuItem>
246+ );
247+ })}
248+
249+ {group.organizationId ? (
250+ <DropdownMenuItem
251+ onSelect={() => openCreateWorkspaceForOrganization(group.organizationId, group.organizationName)}
252+ className="text-xs"
253+ >
254+ <Plus className="mr-2 h-3.5 w-3.5" />
255+ New workspace
256+ </DropdownMenuItem>
257+ ) : null}
258+ </div>
259+ );
260+ })}
261+
262+ {workspaces.length === 0 ? (
263+ <DropdownMenuItem disabled className="text-xs">
264+ No workspaces
265+ </DropdownMenuItem>
266+ ) : null}
267+ </>
268+ )
269+ : (
270+ <DropdownMenuItem disabled className="text-xs">
271+ Guest workspace
272+ </DropdownMenuItem>
273+ )}
274+ {supportsOrganizationManagement ? (
275+ <>
276+ <DropdownMenuSeparator />
277+ <DropdownMenuItem onSelect={openCreateOrganization} className="text-xs">
278+ <Plus className="mr-2 h-3.5 w-3.5" />
279+ New organization
280+ </DropdownMenuItem>
281+
282+ </>
283+ ) : null}
284+ </DropdownMenuContent>
285+ </DropdownMenu>
286+
287+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
288+ <DialogContent>
289+ <form className="space-y-4" onSubmit={handleCreateWorkspace}>
290+ <DialogHeader>
291+ <DialogTitle>{createScope === "new_org" ? "Create organization" : "Create workspace"}</DialogTitle>
292+ <DialogDescription>
293+ {createScope === "new_org"
294+ ? "Create a new organization with a default workspace."
295+ : `Create a workspace inside ${createTargetOrganizationName ?? "this organization"}.`}
296+ </DialogDescription>
297+ </DialogHeader>
298+ <div className="space-y-2">
299+ <Input
300+ value={newWorkspaceName}
301+ onChange={(event) => {
302+ setCreateError(null);
303+ setNewWorkspaceName(event.target.value);
304+ }}
305+ placeholder={createScope === "new_org" ? "Acme" : "Marketing"}
306+ maxLength={64}
307+ />
308+ {createScope === "current_org" ? (
309+ <>
310+ <Input
311+ type="file"
312+ accept="image/*"
313+ onChange={(event) => {
314+ setCreateError(null);
315+ setNewWorkspaceIcon(event.target.files?.[0] ?? null);
316+ }}
317+ />
318+ {newWorkspaceIcon ? (
319+ <p className="text-[11px] text-muted-foreground truncate">
320+ Icon: {newWorkspaceIcon.name}
321+ </p>
322+ ) : null}
323+ </>
324+ ) : null}
325+ {createError ? (
326+ <p className="text-xs text-destructive">{createError}</p>
327+ ) : null}
328+ </div>
329+ <DialogFooter>
330+ <Button
331+ type="button"
332+ variant="outline"
333+ onClick={() => setCreateOpen(false)}
334+ disabled={isCreating}
335+ >
336+ Cancel
337+ </Button>
338+ <Button type="submit" disabled={isCreating}>
339+ {isCreating
340+ ? "Creating..."
341+ : createScope === "new_org"
342+ ? "Create organization"
343+ : "Create workspace"}
344+ </Button>
345+ </DialogFooter>
346+ </form>
347+ </DialogContent>
348+ </Dialog>
349+ </>
350+ );
351+}
executor/apps/web/src/lib/approval-input-format.ts+79−0
@@ -0,0 +1,79 @@
1+export type FormattedApprovalInput = {
2+ content: string;
3+ language: "json" | "text";
4+};
5+
6+export type FormatApprovalInputOptions = {
7+ hideSerializedNull?: boolean;
8+ hideSerializedEmptyObject?: boolean;
9+};
10+
11+function shouldHideSerialized(
12+ serialized: string,
13+ options: FormatApprovalInputOptions,
14+): boolean {
15+ if (options.hideSerializedNull && serialized === "null") {
16+ return true;
17+ }
18+
19+ if (options.hideSerializedEmptyObject && serialized === "{}") {
20+ return true;
21+ }
22+
23+ return false;
24+}
25+
26+export function formatApprovalInput(
27+ input: unknown,
28+ options: FormatApprovalInputOptions = {},
29+): FormattedApprovalInput | null {
30+ if (input === null || input === undefined) {
31+ return null;
32+ }
33+
34+ if (typeof input === "string") {
35+ const trimmed = input.trim();
36+ if (!trimmed) {
37+ return null;
38+ }
39+
40+ try {
41+ const serialized = JSON.stringify(JSON.parse(trimmed), null, 2);
42+ if (shouldHideSerialized(serialized, options)) {
43+ return null;
44+ }
45+
46+ return {
47+ content: serialized,
48+ language: "json",
49+ };
50+ } catch {
51+ return {
52+ content: trimmed,
53+ language: "text",
54+ };
55+ }
56+ }
57+
58+ try {
59+ const serialized = JSON.stringify(input, null, 2);
60+ if (shouldHideSerialized(serialized, options)) {
61+ return null;
62+ }
63+
64+ return {
65+ content: serialized,
66+ language: "json",
67+ };
68+ } catch {
69+ const fallback = String(input).trim();
70+ if (!fallback) {
71+ return null;
72+ }
73+
74+ return {
75+ content: fallback,
76+ language: "text",
77+ };
78+ }
79+}
executor/apps/web/src/lib/catalog-collections.ts+112−0
@@ -0,0 +1,112 @@
1+export interface CatalogCollectionItem {
2+ id: string;
3+ name: string;
4+ summary: string;
5+ specUrl: string;
6+ originUrl?: string;
7+ providerName: string;
8+ logoUrl?: string;
9+ categories?: string;
10+ version?: string;
11+ rank: number;
12+ addedAt: string;
13+}
14+
15+export const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [
16+ {
17+ id: "github-rest",
18+ name: "GitHub REST API",
19+ summary: "Manage repositories, pull requests, issues, and org settings.",
20+ specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
21+ originUrl: "https://docs.github.com/en/rest",
22+ providerName: "GitHub",
23+ categories: "developer-tools",
24+ version: "latest",
25+ rank: 1,
26+ addedAt: "2026-01-10",
27+ },
28+ {
29+ id: "stripe-api",
30+ name: "Stripe API",
31+ summary: "Create payments, manage customers, and handle billing workflows.",
32+ specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
33+ originUrl: "https://docs.stripe.com/api",
34+ providerName: "Stripe",
35+ categories: "payments",
36+ version: "2026-01",
37+ rank: 2,
38+ addedAt: "2026-01-08",
39+ },
40+ {
41+ id: "openai-api",
42+ name: "OpenAI API",
43+ summary: "Generate text, run reasoning models, and process multimodal inputs.",
44+ specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
45+ originUrl: "https://platform.openai.com/docs/api-reference",
46+ providerName: "OpenAI",
47+ categories: "ai",
48+ version: "latest",
49+ rank: 3,
50+ addedAt: "2026-01-06",
51+ },
52+ {
53+ id: "cloudflare-api",
54+ name: "Cloudflare API",
55+ summary: "Control zones, DNS records, workers, and edge configuration.",
56+ specUrl: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml",
57+ originUrl: "https://api.cloudflare.com/",
58+ providerName: "Cloudflare",
59+ categories: "infrastructure",
60+ version: "latest",
61+ rank: 4,
62+ addedAt: "2026-01-04",
63+ },
64+ {
65+ id: "vercel-api",
66+ name: "Vercel API",
67+ summary: "Manage deployments, projects, domains, and team resources.",
68+ specUrl: "https://openapi.vercel.sh",
69+ originUrl: "https://vercel.com/docs/rest-api",
70+ providerName: "Vercel",
71+ categories: "developer-tools",
72+ version: "latest",
73+ rank: 5,
74+ addedAt: "2025-12-18",
75+ },
76+ {
77+ id: "slack-api",
78+ name: "Slack API",
79+ summary: "Work with channels, messages, users, and workspace automation.",
80+ specUrl: "https://api.slack.com/specs/openapi/v2/slack_web.json",
81+ originUrl: "https://api.slack.com/web",
82+ providerName: "Slack",
83+ categories: "communications",
84+ version: "v2",
85+ rank: 6,
86+ addedAt: "2025-12-10",
87+ },
88+ {
89+ id: "sentry-api",
90+ name: "Sentry API",
91+ summary: "Query issues, releases, projects, and alerting configuration.",
92+ specUrl: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json",
93+ originUrl: "https://docs.sentry.io/api/",
94+ providerName: "Sentry",
95+ categories: "observability",
96+ version: "latest",
97+ rank: 7,
98+ addedAt: "2025-11-30",
99+ },
100+ {
101+ id: "jira-cloud-api",
102+ name: "Jira Cloud Platform",
103+ summary: "Manage projects, issues, workflows, and Jira metadata.",
104+ specUrl: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json",
105+ originUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/",
106+ providerName: "Atlassian",
107+ categories: "project-management",
108+ version: "v3",
109+ rank: 8,
110+ addedAt: "2025-11-15",
111+ },
112+];
executor/apps/web/src/lib/credentials-source-helpers.ts+90−0
@@ -0,0 +1,90 @@
1+import { displaySourceName } from "@/lib/tool-source-utils";
2+import { readSourceAuth, sourceForCredentialKey, type SourceAuthMode, type SourceAuthType } from "@/lib/tools-source-helpers";
3+import type { CredentialScope, SourceAuthProfile, ToolSourceRecord } from "@/lib/types";
4+
5+export type SourceOption = { source: ToolSourceRecord; key: string; label: string };
6+
7+export function sourceAuthForKey(
8+ sourceOptions: SourceOption[],
9+ key: string,
10+ inferredProfiles: Record<string, SourceAuthProfile> = {},
11+): {
12+ type: SourceAuthType;
13+ mode?: SourceAuthMode;
14+ header?: string;
15+ inferred?: boolean;
16+} {
17+ const match = sourceOptions.find((entry) => entry.key === key);
18+ if (!match) {
19+ return { type: "bearer" };
20+ }
21+ return readSourceAuth(match.source, inferredProfiles[key]);
22+}
23+
24+export function sourceOptionLabel(source: ToolSourceRecord): string {
25+ return `${source.name} (${source.type})`;
26+}
27+
28+export function providerLabel(provider: "local-convex" | "workos-vault"): string {
29+ return provider === "workos-vault" ? "encrypted" : "local";
30+}
31+
32+export function connectionDisplayName(
33+ sources: ToolSourceRecord[],
34+ connection: {
35+ scope: CredentialScope;
36+ sourceKeys: Set<string>;
37+ actorId?: string;
38+ },
39+): string {
40+ const sourceNames = [...connection.sourceKeys]
41+ .map((sourceKey) => sourceForCredentialKey(sources, sourceKey))
42+ .filter((source): source is ToolSourceRecord => Boolean(source))
43+ .map((source) => displaySourceName(source.name));
44+
45+ const primary = sourceNames[0] ?? "API";
46+ const extraCount = Math.max(sourceNames.length - 1, 0);
47+ const base = extraCount > 0 ? `${primary} +${extraCount}` : primary;
48+
49+ if (connection.scope === "actor") {
50+ if (connection.actorId) {
51+ return `${base} personal (${connection.actorId})`;
52+ }
53+ return `${base} personal`;
54+ }
55+
56+ return `${base} workspace`;
57+}
58+
59+export function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } {
60+ const lines = text
61+ .split("\n")
62+ .map((line) => line.trim())
63+ .filter((line) => line.length > 0);
64+ if (lines.length === 0) {
65+ return { value: {} };
66+ }
67+
68+ const headers: Record<string, string> = {};
69+ for (const line of lines) {
70+ const separator = line.indexOf(":");
71+ if (separator <= 0) {
72+ return { error: `Invalid header line: ${line}` };
73+ }
74+ const name = line.slice(0, separator).trim();
75+ const value = line.slice(separator + 1).trim();
76+ if (!name || !value) {
77+ return { error: `Invalid header line: ${line}` };
78+ }
79+ headers[name] = value;
80+ }
81+
82+ return { value: headers };
83+}
84+
85+export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {
86+ const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {};
87+ return Object.entries(headers)
88+ .map(([key, value]) => `${key}: ${String(value)}`)
89+ .join("\n");
90+}
executor/apps/web/src/lib/session-context-derivation.ts+138−0
@@ -0,0 +1,138 @@
1+import type { Id } from "@executor/convex/_generated/dataModel";
2+import type { AnonymousContext } from "@/lib/types";
3+
4+export interface WorkosAccount {
5+ _id: Id<"accounts">;
6+ provider: "workos" | "anonymous";
7+ providerAccountId: string;
8+ email: string;
9+ name: string;
10+ avatarUrl?: string | null;
11+}
12+
13+export interface WorkspaceListItem {
14+ id: Id<"workspaces">;
15+ organizationId: Id<"organizations">;
16+ organizationName: string;
17+ organizationSlug: string;
18+ name: string;
19+ slug: string;
20+ iconUrl?: string | null;
21+ createdAt: number;
22+}
23+
24+export interface SessionWorkspaceOption {
25+ id: Id<"workspaces">;
26+ docId: Id<"workspaces"> | null;
27+ name: string;
28+ organizationId: Id<"organizations"> | null;
29+ organizationName: string;
30+ organizationSlug: string;
31+ iconUrl?: string | null;
32+}
33+
34+export function resolveActiveWorkspaceId(
35+ workspaces: WorkspaceListItem[] | undefined,
36+ activeWorkspaceId: Id<"workspaces"> | null,
37+ accountStoredWorkspace: Id<"workspaces"> | null,
38+) {
39+ if (!workspaces || workspaces.length === 0) {
40+ return activeWorkspaceId;
41+ }
42+
43+ if (activeWorkspaceId && workspaces.some((workspace) => workspace.id === activeWorkspaceId)) {
44+ return activeWorkspaceId;
45+ }
46+
47+ if (
48+ accountStoredWorkspace
49+ && workspaces.some((workspace) => workspace.id === accountStoredWorkspace)
50+ ) {
51+ return accountStoredWorkspace;
52+ }
53+
54+ return workspaces[0]?.id ?? null;
55+}
56+
57+export function buildAccountWorkspaceContext(
58+ account: WorkosAccount | null | undefined,
59+ workspaces: WorkspaceListItem[] | undefined,
60+ resolvedActiveWorkspaceId: Id<"workspaces"> | null,
61+ storedSessionId: string | null,
62+ guestSessionId: string | null,
63+) {
64+ if (!account || !workspaces || workspaces.length === 0) {
65+ return null;
66+ }
67+
68+ const activeWorkspace =
69+ workspaces.find((workspace) => workspace.id === resolvedActiveWorkspaceId)
70+ ?? workspaces[0]
71+ ?? null;
72+ if (!activeWorkspace) {
73+ return null;
74+ }
75+
76+ const sessionId = account.provider === "workos"
77+ ? `workos_${account._id}`
78+ : (storedSessionId ?? guestSessionId ?? null);
79+ if (!sessionId) {
80+ return null;
81+ }
82+
83+ const actorId = account.provider === "workos" ? account._id : account.providerAccountId;
84+
85+ return {
86+ sessionId,
87+ workspaceId: activeWorkspace.id,
88+ actorId,
89+ clientId: "web",
90+ accountId: account._id,
91+ userId: account._id,
92+ createdAt: Date.now(),
93+ lastSeenAt: Date.now(),
94+ } satisfies AnonymousContext;
95+}
96+
97+export function buildWorkspaceOptions(
98+ mode: "guest" | "workos" | "anonymous",
99+ workspaces: WorkspaceListItem[] | undefined,
100+ guestContext: AnonymousContext | null,
101+) {
102+ if (mode !== "guest" && workspaces) {
103+ return workspaces.map((workspace): SessionWorkspaceOption => {
104+ return {
105+ id: workspace.id,
106+ docId: workspace.id,
107+ name: workspace.name,
108+ organizationId: workspace.organizationId,
109+ organizationName: workspace.organizationName,
110+ organizationSlug: workspace.organizationSlug,
111+ iconUrl: workspace.iconUrl,
112+ };
113+ });
114+ }
115+
116+ if (guestContext) {
117+ return [
118+ {
119+ id: guestContext.workspaceId,
120+ docId: null,
121+ name: "Anonymous Workspace",
122+ organizationId: null,
123+ organizationName: "Anonymous Organization",
124+ organizationSlug: "anonymous-organization",
125+ },
126+ ];
127+ }
128+
129+ return [];
130+}
131+
132+export function queryErrorMessage(error: unknown, fallback: string) {
133+ if (error instanceof Error) {
134+ return error.message;
135+ }
136+
137+ return error ? fallback : null;
138+}
executor/apps/web/src/lib/session-context.tsx+43−147
@@ -13,6 +13,23 @@ import { useQuery as useTanstackQuery } from "@tanstack/react-query"; · 12 unmodified lines
1313 import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";
1414 import { useWorkosAuthState } from "@/lib/convex-provider";
1515 import { convexApi } from "@/lib/convex-api";
16+import {
17+ buildAccountWorkspaceContext,
18+ buildWorkspaceOptions,
19+ queryErrorMessage,
20+ resolveActiveWorkspaceId,
21+ type WorkosAccount,
22+ type WorkspaceListItem,
23+} from "@/lib/session-context-derivation";
24+import {
25+ clearSessionStorage,
26+ persistActiveWorkspaceId,
27+ persistSessionId,
28+ persistWorkspaceForAccount,
29+ readStoredActiveWorkspaceId,
30+ readStoredSessionId,
31+ readWorkspaceByAccount,
32+} from "@/lib/session-storage";
1633 import type { AnonymousContext } from "./types";
1734 import type { Id } from "@executor/convex/_generated/dataModel";
1835
@@ -65,26 +82,6 @@ interface SessionState { · 46 unmodified lines
6582 resetWorkspace: () => Promise<void>;
6683 }
6784
68−interface WorkosAccount {
69− _id: Id<"accounts">;
70− provider: "workos" | "anonymous";
71− providerAccountId: string;
72− email: string;
73− name: string;
74− avatarUrl?: string | null;
75−}
76−
77−interface WorkspaceListItem {
78− id: Id<"workspaces">;
79− organizationId: Id<"organizations">;
80− organizationName: string;
81− organizationSlug: string;
82− name: string;
83− slug: string;
84− iconUrl?: string | null;
85− createdAt: number;
86−}
87−
8885 const SessionContext = createContext<SessionState>({
8986 context: null,
9087 loading: true,
@@ -104,41 +101,13 @@ const SessionContext = createContext<SessionState>({ · 13 unmodified lines
104101 resetWorkspace: async () => {},
105102 });
106103
107−const SESSION_KEY = "executor_session_id";
108−const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id";
109−const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account";
110−
111−function readWorkspaceByAccount() {
112− const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY);
113− if (!raw) return {} as Record<string, Id<"workspaces">>;
114− try {
115− const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>;
116− return parsed ?? {};
117− } catch {
118− return {};
119− }
120−}
121−
122−function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) {
123− localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value));
124−}
125−
126104 export function SessionProvider({ children }: { children: ReactNode }) {
127105 const { loading: workosAuthLoading, authenticated: workosAuthenticated } = useWorkosAuthState();
128106 const bootstrapAnonymousSession = useMutation(convexApi.workspace.bootstrapAnonymousSession);
129− const [storedSessionId, setStoredSessionId] = useState<string | null>(() => {
130− if (typeof window === "undefined") {
131− return null;
132− }
133− return localStorage.getItem(SESSION_KEY);
134− });
135− const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>(() => {
136− if (typeof window === "undefined") {
137− return null;
138− }
139− const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY);
140− return stored as Id<"workspaces"> | null;
141− });
107+ const [storedSessionId, setStoredSessionId] = useState<string | null>(readStoredSessionId);
108+ const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>(
109+ readStoredActiveWorkspaceId,
110+ );
142111 const [runtimeError, setRuntimeError] = useState<string | null>(null);
143112
144113 const clientConfig = useConvexQuery(convexApi.app.getClientConfig, {});
@@ -161,7 +130,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 16 unmodified lines
161130 }
162131
163132 const context = await bootstrapAnonymousSession({ sessionId: storedSessionId });
164− localStorage.setItem(SESSION_KEY, context.sessionId);
133+ persistSessionId(context.sessionId);
165134 if (context.sessionId !== storedSessionId) {
166135 setStoredSessionId(context.sessionId);
167136 }
@@ -185,21 +154,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 17 unmodified lines
185154 );
186155
187156 const resolvedActiveWorkspaceId = useMemo(() => {
188− if (!workspaces || workspaces.length === 0) {
189− return activeWorkspaceId;
190− }
191−
192− if (activeWorkspaceId && workspaces.some((workspace) => workspace.id === activeWorkspaceId)) {
193− return activeWorkspaceId;
194− }
195−
196157 const accountId = account?._id ?? null;
197158 const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null;
198− if (accountStoredWorkspace && workspaces.some((workspace) => workspace.id === accountStoredWorkspace)) {
199− return accountStoredWorkspace;
200− }
201−
202− return workspaces[0]?.id ?? null;
159+ return resolveActiveWorkspaceId(workspaces, activeWorkspaceId, accountStoredWorkspace);
203160 }, [workspaces, activeWorkspaceId, account]);
204161
205162 const bootstrapWorkosAccountQuery = useTanstackQuery({
@@ -219,8 +176,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 13 unmodified lines
219176 });
220177
221178 const resetWorkspace = useCallback(async () => {
222− localStorage.removeItem(SESSION_KEY);
223− localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
179+ clearSessionStorage();
224180 setStoredSessionId(null);
225181 setActiveWorkspaceId(null);
226182 setManualGuestContext(null);
@@ -236,8 +192,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 9 unmodified lines
236192 setCreatingAnonymousOrganization(true);
237193 try {
238194 const context = await bootstrapAnonymousSession({});
239− localStorage.setItem(SESSION_KEY, context.sessionId);
240− localStorage.setItem(ACTIVE_WORKSPACE_KEY, context.workspaceId);
195+ persistSessionId(context.sessionId);
196+ persistActiveWorkspaceId(context.workspaceId);
241197 setStoredSessionId(context.sessionId);
242198 setActiveWorkspaceId(context.workspaceId);
243199 setManualGuestContext(context);
@@ -252,15 +208,10 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 8 unmodified lines
252208
253209 const switchWorkspace = useCallback((workspaceId: Id<"workspaces">) => {
254210 setActiveWorkspaceId(workspaceId);
255− localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId);
211+ persistActiveWorkspaceId(workspaceId);
256212
257213 if (account) {
258− const accountId = account._id;
259− const byAccount = readWorkspaceByAccount();
260− writeWorkspaceByAccount({
261− ...byAccount,
262− [accountId]: workspaceId,
263− });
214+ persistWorkspaceForAccount(account._id, workspaceId);
264215 }
265216 }, [account]);
266217
@@ -323,37 +274,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 56 unmodified lines
323274 ]);
324275
325276 const accountWorkspaceContext = useMemo<AnonymousContext | null>(() => {
326− if (!account || !workspaces || workspaces.length === 0) {
327− return null;
328− }
329−
330− const activeWorkspace =
331− workspaces.find((workspace) => workspace.id === resolvedActiveWorkspaceId)
332− ?? workspaces[0]
333− ?? null;
334− if (!activeWorkspace) {
335− return null;
336− }
337−
338− const sessionId = account.provider === "workos"
339− ? `workos_${account._id}`
340− : (storedSessionId ?? guestContext?.sessionId ?? null);
341− if (!sessionId) {
342− return null;
343− }
344−
345− const actorId = account.provider === "workos" ? account._id : account.providerAccountId;
346−
347− return {
348− sessionId,
349− workspaceId: activeWorkspace.id,
350− actorId,
351− clientId: "web",
352− accountId: account._id,
353− userId: account._id,
354− createdAt: Date.now(),
355− lastSeenAt: Date.now(),
356− };
277+ return buildAccountWorkspaceContext(
278+ account,
279+ workspaces,
280+ resolvedActiveWorkspaceId,
281+ storedSessionId,
282+ guestContext?.sessionId ?? null,
283+ );
357284 }, [account, guestContext?.sessionId, resolvedActiveWorkspaceId, storedSessionId, workspaces]);
358285
359286 // When WorkOS is enabled, don't fall back to guest context while WorkOS
@@ -372,17 +299,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 12 unmodified lines
372299 const context = accountWorkspaceContext ?? ((workosStillLoading || shouldBlockGuestFallback) ? null : guestContext);
373300
374301 const bootstrapSessionError =
375− storedSessionId && bootstrapSessionQuery.error instanceof Error
376− ? bootstrapSessionQuery.error.message
377− : storedSessionId && bootstrapSessionQuery.error
378− ? "Failed to bootstrap session"
379− : null;
380− const bootstrapWorkosError =
381− bootstrapWorkosAccountQuery.error instanceof Error
382− ? bootstrapWorkosAccountQuery.error.message
383− : bootstrapWorkosAccountQuery.error
384− ? "Failed to bootstrap WorkOS account"
385− : null;
302+ storedSessionId
303+ ? queryErrorMessage(bootstrapSessionQuery.error, "Failed to bootstrap session")
304+ : null;
305+ const bootstrapWorkosError = queryErrorMessage(
306+ bootstrapWorkosAccountQuery.error,
307+ "Failed to bootstrap WorkOS account",
308+ );
386309 const error = runtimeError ?? bootstrapSessionError ?? bootstrapWorkosError;
387310
388311 const effectiveLoading = !context && !error && (
@@ -392,34 +315,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 3 unmodified lines
392315 || bootstrapWorkosAccountQuery.isFetching
393316 );
394317 const workspaceOptions = useMemo(() => {
395− if (mode !== "guest" && workspaces) {
396− return workspaces.map((workspace): SessionState["workspaces"][number] => {
397− return {
398− id: workspace.id,
399− docId: workspace.id,
400− name: workspace.name,
401− organizationId: workspace.organizationId,
402− organizationName: workspace.organizationName,
403− organizationSlug: workspace.organizationSlug,
404− iconUrl: workspace.iconUrl,
405− };
406− });
407− }
408−
409− if (guestContext) {
410− return [
411− {
412− id: guestContext.workspaceId,
413− docId: null,
414− name: "Anonymous Workspace",
415− organizationId: null,
416− organizationName: "Anonymous Organization",
417− organizationSlug: "anonymous-organization",
418− },
419− ];
420− }
421−
422− return [];
318+ return buildWorkspaceOptions(mode, workspaces, guestContext);
423319 }, [mode, workspaces, guestContext]);
424320
425321 return (
executor/apps/web/src/lib/session-storage.ts+60−0
@@ -0,0 +1,60 @@
1+import type { Id } from "@executor/convex/_generated/dataModel";
2+
3+const SESSION_KEY = "executor_session_id";
4+const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id";
5+const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account";
6+
7+export function readStoredSessionId() {
8+ if (typeof window === "undefined") {
9+ return null;
10+ }
11+
12+ return localStorage.getItem(SESSION_KEY);
13+}
14+
15+export function readStoredActiveWorkspaceId() {
16+ if (typeof window === "undefined") {
17+ return null;
18+ }
19+
20+ return localStorage.getItem(ACTIVE_WORKSPACE_KEY) as Id<"workspaces"> | null;
21+}
22+
23+export function persistSessionId(sessionId: string) {
24+ localStorage.setItem(SESSION_KEY, sessionId);
25+}
26+
27+export function persistActiveWorkspaceId(workspaceId: Id<"workspaces">) {
28+ localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId);
29+}
30+
31+export function clearSessionStorage() {
32+ localStorage.removeItem(SESSION_KEY);
33+ localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
34+}
35+
36+export function readWorkspaceByAccount() {
37+ const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY);
38+ if (!raw) {
39+ return {} as Record<string, Id<"workspaces">>;
40+ }
41+
42+ try {
43+ const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>;
44+ return parsed ?? {};
45+ } catch {
46+ return {};
47+ }
48+}
49+
50+export function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) {
51+ localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value));
52+}
53+
54+export function persistWorkspaceForAccount(accountId: string, workspaceId: Id<"workspaces">) {
55+ const byAccount = readWorkspaceByAccount();
56+ writeWorkspaceByAccount({
57+ ...byAccount,
58+ [accountId]: workspaceId,
59+ });
60+}
executor/apps/web/src/lib/tool-explorer-grouping.ts+156−0
@@ -0,0 +1,156 @@
1+import { sourceLabel, sourceType } from "@/lib/tool-source-utils";
2+import type { ToolDescriptor } from "@/lib/types";
3+
4+export interface ToolGroup {
5+ key: string;
6+ label: string;
7+ type: "source" | "namespace";
8+ sourceType?: string;
9+ childCount: number;
10+ approvalCount: number;
11+ children: ToolGroup[] | ToolDescriptor[];
12+}
13+
14+export function toolNamespace(path: string): string {
15+ const parts = path.split(".");
16+ if (parts.length >= 2) return parts.slice(0, -1).join(".");
17+ return parts[0];
18+}
19+
20+function trimLeadingNamespace(path: string, prefix: string): string {
21+ const dottedPrefix = `${prefix}.`;
22+ if (path.startsWith(dottedPrefix)) {
23+ return path.slice(dottedPrefix.length);
24+ }
25+ return path;
26+}
27+
28+export function toolOperation(path: string): string {
29+ const parts = path.split(".");
30+ return parts[parts.length - 1];
31+}
32+
33+export function buildSourceTree(tools: ToolDescriptor[]): ToolGroup[] {
34+ const bySource = new Map<string, ToolDescriptor[]>();
35+ for (const tool of tools) {
36+ const src = sourceLabel(tool.source);
37+ let list = bySource.get(src);
38+ if (!list) {
39+ list = [];
40+ bySource.set(src, list);
41+ }
42+ list.push(tool);
43+ }
44+
45+ return Array.from(bySource.entries())
46+ .sort((a, b) => b[1].length - a[1].length)
47+ .map(([src, srcTools]) => {
48+ const sType = srcTools[0] ? sourceType(srcTools[0].source) : "local";
49+
50+ const byNs = new Map<string, ToolDescriptor[]>();
51+ for (const tool of srcTools) {
52+ const ns = toolNamespace(tool.path);
53+ let list = byNs.get(ns);
54+ if (!list) {
55+ list = [];
56+ byNs.set(ns, list);
57+ }
58+ list.push(tool);
59+ }
60+
61+ const nsGroups: ToolGroup[] = Array.from(byNs.entries())
62+ .sort((a, b) => a[0].localeCompare(b[0]))
63+ .map(([ns, nsTools]) => ({
64+ key: `source:${src}:ns:${ns}`,
65+ label: trimLeadingNamespace(ns, src),
66+ type: "namespace" as const,
67+ childCount: nsTools.length,
68+ approvalCount: nsTools.filter((t) => t.approval === "required")
69+ .length,
70+ children: [...nsTools].sort((a, b) =>
71+ a.path.localeCompare(b.path),
72+ ),
73+ }));
74+
75+ return {
76+ key: `source:${src}`,
77+ label: src,
78+ type: "source" as const,
79+ sourceType: sType,
80+ childCount: srcTools.length,
81+ approvalCount: srcTools.filter((t) => t.approval === "required")
82+ .length,
83+ children: nsGroups,
84+ };
85+ });
86+}
87+
88+export function buildNamespaceTree(tools: ToolDescriptor[]): ToolGroup[] {
89+ const byNs = new Map<string, ToolDescriptor[]>();
90+ for (const tool of tools) {
91+ const ns = toolNamespace(tool.path);
92+ let list = byNs.get(ns);
93+ if (!list) {
94+ list = [];
95+ byNs.set(ns, list);
96+ }
97+ list.push(tool);
98+ }
99+
100+ return Array.from(byNs.entries())
101+ .sort((a, b) => a[0].localeCompare(b[0]))
102+ .map(([ns, nsTools]) => ({
103+ key: `ns:${ns}`,
104+ label: ns,
105+ type: "namespace" as const,
106+ childCount: nsTools.length,
107+ approvalCount: nsTools.filter((t) => t.approval === "required").length,
108+ children: [...nsTools].sort((a, b) => a.path.localeCompare(b.path)),
109+ }));
110+}
111+
112+export function buildApprovalTree(tools: ToolDescriptor[]): ToolGroup[] {
113+ const gated = tools.filter((t) => t.approval === "required");
114+ const auto = tools.filter((t) => t.approval !== "required");
115+ const groups: ToolGroup[] = [];
116+
117+ if (gated.length > 0) {
118+ groups.push({
119+ key: "approval:required",
120+ label: "Approval Required",
121+ type: "namespace",
122+ childCount: gated.length,
123+ approvalCount: gated.length,
124+ children: [...gated].sort((a, b) => a.path.localeCompare(b.path)),
125+ });
126+ }
127+ if (auto.length > 0) {
128+ groups.push({
129+ key: "approval:auto",
130+ label: "Auto-approved",
131+ type: "namespace",
132+ childCount: auto.length,
133+ approvalCount: 0,
134+ children: [...auto].sort((a, b) => a.path.localeCompare(b.path)),
135+ });
136+ }
137+
138+ return groups;
139+}
140+
141+export function collectGroupKeys(groups: ToolGroup[]): Set<string> {
142+ const keys = new Set<string>();
143+ const stack = [...groups];
144+
145+ while (stack.length > 0) {
146+ const group = stack.pop();
147+ if (!group) continue;
148+
149+ keys.add(group.key);
150+ if (group.children.length > 0 && "key" in group.children[0]) {
151+ stack.push(...(group.children as ToolGroup[]));
152+ }
153+ }
154+
155+ return keys;
156+}
executor/apps/web/src/lib/tool-source-utils.ts+37−0
@@ -0,0 +1,37 @@
1+export function sourceLabel(source?: string): string {
2+ if (!source) return "built-in";
3+ const idx = source.indexOf(":");
4+ return idx >= 0 ? source.slice(idx + 1) : source;
5+}
6+
7+export function sourceType(source?: string): string {
8+ if (!source) return "local";
9+ const idx = source.indexOf(":");
10+ return idx >= 0 ? source.slice(0, idx) : "local";
11+}
12+
13+export function displaySourceName(name: string): string {
14+ const parts = name.split(/[-_.]+/).filter(Boolean);
15+ if (parts.length === 0) return name;
16+
17+ const deduped = parts.filter((part, index, all) => {
18+ if (index === 0) return true;
19+ return part.toLowerCase() !== all[index - 1]?.toLowerCase();
20+ });
21+
22+ const tokenMap: Record<string, string> = {
23+ api: "API",
24+ oauth: "OAuth",
25+ graphql: "GraphQL",
26+ mcp: "MCP",
27+ github: "GitHub",
28+ };
29+
30+ return deduped
31+ .map((token) => {
32+ const lower = token.toLowerCase();
33+ if (tokenMap[lower]) return tokenMap[lower];
34+ return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`;
35+ })
36+ .join(" ");
37+}
executor/apps/web/src/lib/tools-source-helpers.ts+278−0
@@ -0,0 +1,278 @@
1+import { parse as parseDomain } from "tldts";
2+import type {
3+ CredentialRecord,
4+ OpenApiSourceQuality,
5+ SourceAuthProfile,
6+ ToolSourceRecord,
7+} from "@/lib/types";
8+
9+export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
10+export type SourceAuthMode = "workspace" | "actor";
11+
12+const RAW_HOSTS = new Set([
13+ "raw.githubusercontent.com",
14+ "cdn.jsdelivr.net",
15+ "unpkg.com",
16+ "raw.github.com",
17+]);
18+
19+export function faviconForUrl(url: string | undefined | null): string | null {
20+ if (!url) return null;
21+ try {
22+ const domain = new URL(url).hostname;
23+ return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
24+ } catch {
25+ return null;
26+ }
27+}
28+
29+export function getSourceFavicon(source: ToolSourceRecord): string | null {
30+ if (source.type === "mcp") {
31+ return faviconForUrl((source.config.url as string) ?? null);
32+ }
33+ if (source.type === "graphql") {
34+ return faviconForUrl((source.config.endpoint as string) ?? null);
35+ }
36+ const spec = source.config.spec as string | undefined;
37+ if (typeof spec === "string" && spec.startsWith("postman:")) {
38+ return null;
39+ }
40+ const baseUrl = source.config.baseUrl as string | undefined;
41+ const collectionUrl = source.config.collectionUrl as string | undefined;
42+ const specUrl = typeof spec === "string" && spec.startsWith("http") ? spec : null;
43+ return faviconForUrl(baseUrl ?? collectionUrl ?? specUrl);
44+}
45+
46+export function sourceEndpointLabel(source: ToolSourceRecord): string {
47+ if (source.type === "mcp") return (source.config.url as string) ?? "";
48+ if (source.type === "graphql") return (source.config.endpoint as string) ?? "";
49+
50+ const spec = source.config.spec;
51+ if (typeof spec === "string" && spec.startsWith("postman:")) {
52+ const uid = spec.slice("postman:".length).trim();
53+ if (uid.length > 0) {
54+ return `catalog:${uid}`;
55+ }
56+ return "catalog:collection";
57+ }
58+
59+ return (source.config.spec as string) ?? "";
60+}
61+
62+export function sourceKeyForSource(source: ToolSourceRecord): string | null {
63+ if (source.type === "openapi") return `source:${source.id}`;
64+ if (source.type === "graphql") return `source:${source.id}`;
65+ return null;
66+}
67+
68+export function toolSourceLabelForSource(source: ToolSourceRecord): string {
69+ return `${source.type}:${source.name}`;
70+}
71+
72+export function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): ToolSourceRecord | null {
73+ const prefix = "source:";
74+ if (!sourceKey.startsWith(prefix)) return null;
75+ const sourceId = sourceKey.slice(prefix.length);
76+ if (!sourceId) return null;
77+ return sources.find((source) => source.id === sourceId) ?? null;
78+}
79+
80+export function normalizeSourceAuthProfile(profile: SourceAuthProfile | undefined): {
81+ type: SourceAuthType;
82+ mode?: SourceAuthMode;
83+ header?: string;
84+ inferred?: boolean;
85+} {
86+ if (!profile) {
87+ return { type: "none" };
88+ }
89+
90+ const type = profile.type === "mixed"
91+ ? "mixed"
92+ : profile.type === "basic"
93+ ? "basic"
94+ : profile.type === "apiKey"
95+ ? "apiKey"
96+ : profile.type === "bearer"
97+ ? "bearer"
98+ : "none";
99+
100+ const mode = profile.mode === "actor" ? "actor" : profile.mode === "workspace" ? "workspace" : undefined;
101+ const header = typeof profile.header === "string" && profile.header.trim().length > 0
102+ ? profile.header.trim()
103+ : undefined;
104+
105+ return {
106+ type,
107+ ...(mode ? { mode } : {}),
108+ ...(header ? { header } : {}),
109+ inferred: Boolean(profile.inferred),
110+ };
111+}
112+
113+export function readSourceAuth(
114+ source: ToolSourceRecord,
115+ inferredProfile?: SourceAuthProfile,
116+): {
117+ type: SourceAuthType;
118+ mode?: SourceAuthMode;
119+ header?: string;
120+ inferred?: boolean;
121+} {
122+ if (source.type !== "openapi" && source.type !== "graphql") {
123+ return { type: "none" };
124+ }
125+
126+ const inferred = normalizeSourceAuthProfile(inferredProfile);
127+
128+ const auth = source.config.auth as Record<string, unknown> | undefined;
129+ const type =
130+ auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic", "mixed"].includes(auth.type)
131+ ? (auth.type as SourceAuthType)
132+ : inferred.type;
133+
134+ const mode =
135+ auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor")
136+ ? (auth.mode as SourceAuthMode)
137+ : inferred.mode;
138+
139+ const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0
140+ ? auth.header.trim()
141+ : inferred.header;
142+
143+ return {
144+ type,
145+ ...(mode ? { mode } : {}),
146+ ...(header ? { header } : {}),
147+ inferred: auth?.type === undefined ? inferred.inferred : false,
148+ };
149+}
150+
151+export function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null {
152+ const auth = readSourceAuth(source, inferredProfile);
153+ if (auth.type === "none") return null;
154+ if (auth.type === "mixed") return "Mixed auth";
155+ const mode = auth.mode ?? "workspace";
156+ const authLabel =
157+ auth.type === "apiKey"
158+ ? "API Key"
159+ : auth.type === "bearer"
160+ ? "Bearer"
161+ : auth.type === "basic"
162+ ? "Basic"
163+ : "Auth";
164+ return `${authLabel} · ${mode === "actor" ? "user" : "workspace"}`;
165+}
166+
167+export function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): {
168+ workspaceCount: number;
169+ actorCount: number;
170+} {
171+ const sourceKey = sourceKeyForSource(source);
172+ if (!sourceKey) {
173+ return { workspaceCount: 0, actorCount: 0 };
174+ }
175+
176+ let workspaceCount = 0;
177+ let actorCount = 0;
178+ for (const credential of credentials) {
179+ if (credential.sourceKey !== sourceKey) continue;
180+ if (credential.scope === "workspace") workspaceCount += 1;
181+ if (credential.scope === "actor") actorCount += 1;
182+ }
183+
184+ return { workspaceCount, actorCount };
185+}
186+
187+export function formatQualityPercent(value: number): string {
188+ return `${Math.round(value * 100)}%`;
189+}
190+
191+export function qualityToneClass(quality: OpenApiSourceQuality): string {
192+ if (quality.overallQuality >= 0.95) {
193+ return "text-terminal-green";
194+ }
195+ if (quality.overallQuality >= 0.85) {
196+ return "text-terminal-amber";
197+ }
198+ return "text-terminal-red";
199+}
200+
201+export function qualitySummaryLabel(quality: OpenApiSourceQuality): string {
202+ if (quality.overallQuality >= 0.95) {
203+ return "strong typing";
204+ }
205+ if (quality.overallQuality >= 0.85) {
206+ return "mostly typed";
207+ }
208+ return "needs type cleanup";
209+}
210+
211+export function compactEndpointLabel(source: ToolSourceRecord): string {
212+ const endpoint = sourceEndpointLabel(source);
213+ if (endpoint.startsWith("catalog:")) return endpoint;
214+ try {
215+ const parsed = new URL(endpoint);
216+ const path = parsed.pathname === "/" ? "" : parsed.pathname;
217+ return `${parsed.hostname}${path}`;
218+ } catch {
219+ return endpoint;
220+ }
221+}
222+
223+export function inferNameFromUrl(url: string): string {
224+ try {
225+ const u = new URL(url);
226+ const parsed = parseDomain(url);
227+
228+ if (RAW_HOSTS.has(u.hostname)) {
229+ const segments = u.pathname.split("/").filter(Boolean);
230+ if (segments.length > 0) return segments[0].toLowerCase();
231+ }
232+
233+ if (parsed.domainWithoutSuffix) {
234+ return parsed.domainWithoutSuffix;
235+ }
236+
237+ if (parsed.domain) {
238+ return parsed.domain.split(".")[0];
239+ }
240+
241+ return u.hostname.replace(/\./g, "-");
242+ } catch {
243+ return "";
244+ }
245+}
246+
247+export function sanitizeSourceName(value: string): string {
248+ const slug = value
249+ .toLowerCase()
250+ .replace(/[^a-z0-9]+/g, "-")
251+ .replace(/-+/g, "-")
252+ .replace(/^-|-$/g, "")
253+ .slice(0, 48);
254+ return slug || "source";
255+}
256+
257+export function withUniqueSourceName(baseName: string, takenNames: Set<string>): string {
258+ const loweredTaken = new Set([...takenNames].map((name) => name.toLowerCase()));
259+ const candidate = sanitizeSourceName(baseName);
260+ if (!loweredTaken.has(candidate.toLowerCase())) {
261+ return candidate;
262+ }
263+
264+ let suffix = 2;
265+ while (true) {
266+ const next = `${candidate}-${suffix}`;
267+ if (!loweredTaken.has(next.toLowerCase())) {
268+ return next;
269+ }
270+ suffix += 1;
271+ }
272+}
273+
274+export function catalogSourceName(item: { providerName: string; name: string }): string {
275+ const owner = sanitizeSourceName(item.providerName || "catalog");
276+ const title = sanitizeSourceName(item.name);
277+ return sanitizeSourceName(`${owner}-${title}`);
278+}
executor/apps/web/src/lib/workspace-query-args.ts+7−0
@@ -0,0 +1,7 @@
1+import type { AnonymousContext } from "@/lib/types";
2+
3+export function workspaceQueryArgs(context: AnonymousContext | null | undefined) {
4+ return context
5+ ? { workspaceId: context.workspaceId, sessionId: context.sessionId }
6+ : "skip";
7+}
executor/packages/convex/auth-accounts.ts+48−0
@@ -0,0 +1,48 @@
1+import { getAccountByWorkosId } from "./auth-db-queries";
2+import type { DbCtx } from "./auth-types";
3+
4+type UpsertWorkosAccountArgs = {
5+ workosUserId: string;
6+ email: string;
7+ fullName: string;
8+ firstName?: string;
9+ lastName?: string;
10+ avatarUrl?: string;
11+ now: number;
12+ includeLastLoginAt: boolean;
13+};
14+
15+export async function upsertWorkosAccount(ctx: DbCtx, args: UpsertWorkosAccountArgs) {
16+ let account = await getAccountByWorkosId(ctx, args.workosUserId);
17+ const patchData = {
18+ email: args.email,
19+ name: args.fullName,
20+ firstName: args.firstName,
21+ lastName: args.lastName,
22+ avatarUrl: args.avatarUrl,
23+ status: "active" as const,
24+ updatedAt: args.now,
25+ ...(args.includeLastLoginAt ? { lastLoginAt: args.now } : null),
26+ };
27+
28+ if (account) {
29+ await ctx.db.patch(account._id, patchData);
30+ return await ctx.db.get(account._id);
31+ }
32+
33+ const accountId = await ctx.db.insert("accounts", {
34+ provider: "workos",
35+ providerAccountId: args.workosUserId,
36+ email: args.email,
37+ name: args.fullName,
38+ firstName: args.firstName,
39+ lastName: args.lastName,
40+ avatarUrl: args.avatarUrl,
41+ status: "active",
42+ createdAt: args.now,
43+ updatedAt: args.now,
44+ ...(args.includeLastLoginAt ? { lastLoginAt: args.now } : null),
45+ });
46+
47+ return await ctx.db.get(accountId);
48+}
executor/packages/convex/auth-bootstrap.ts+148−0
@@ -0,0 +1,148 @@
1+import type { MutationCtx } from "./_generated/server";
2+import { upsertWorkosAccount } from "./auth-accounts";
3+import { getOrganizationByWorkosOrgId } from "./auth-db-queries";
4+import { getAuthKitUserProfile, resolveIdentityProfile } from "./auth-identity";
5+import {
6+ ensureWorkspaceMembership,
7+ mapOrganizationRoleToWorkspaceRole,
8+ markPendingInvitesAcceptedByEmail,
9+ upsertOrganizationMembership,
10+} from "./auth-memberships";
11+import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./auth-personal-workspace";
12+import type { AccountId } from "./auth-types";
13+
14+async function seedHintedOrganizationMembership(
15+ ctx: MutationCtx,
16+ args: {
17+ accountId: AccountId;
18+ hintedWorkosOrgId?: string;
19+ email: string;
20+ now: number;
21+ },
22+) {
23+ const activeOrgMembership = await ctx.db
24+ .query("organizationMembers")
25+ .withIndex("by_account", (q) => q.eq("accountId", args.accountId))
26+ .filter((q) => q.eq(q.field("status"), "active"))
27+ .first();
28+
29+ if (activeOrgMembership || !args.hintedWorkosOrgId) {
30+ return;
31+ }
32+
33+ const hintedOrganization = await getOrganizationByWorkosOrgId(ctx, args.hintedWorkosOrgId);
34+ if (!hintedOrganization) {
35+ return;
36+ }
37+
38+ await upsertOrganizationMembership(ctx, {
39+ organizationId: hintedOrganization._id,
40+ accountId: args.accountId,
41+ role: "member",
42+ status: "active",
43+ billable: true,
44+ now: args.now,
45+ });
46+
47+ await markPendingInvitesAcceptedByEmail(ctx, {
48+ organizationId: hintedOrganization._id,
49+ email: args.email,
50+ acceptedAt: args.now,
51+ });
52+}
53+
54+async function syncWorkspaceMembershipsForOrganizations(ctx: MutationCtx, args: { accountId: AccountId; now: number }) {
55+ const activeOrganizationMemberships = await ctx.db
56+ .query("organizationMembers")
57+ .withIndex("by_account", (q) => q.eq("accountId", args.accountId))
58+ .filter((q) => q.eq(q.field("status"), "active"))
59+ .collect();
60+
61+ for (const membership of activeOrganizationMemberships) {
62+ const orgWorkspaces = await ctx.db
63+ .query("workspaces")
64+ .withIndex("by_organization_created", (q) => q.eq("organizationId", membership.organizationId))
65+ .collect();
66+
67+ const workspaceRole = mapOrganizationRoleToWorkspaceRole(membership.role);
68+ for (const workspace of orgWorkspaces) {
69+ await ensureWorkspaceMembership(ctx, {
70+ workspaceId: workspace._id,
71+ accountId: args.accountId,
72+ role: workspaceRole,
73+ now: args.now,
74+ });
75+ }
76+ }
77+}
78+
79+async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { accountId: AccountId }) {
80+ const activeWorkspaceMembership = await ctx.db
81+ .query("workspaceMembers")
82+ .withIndex("by_account", (q) => q.eq("accountId", args.accountId))
83+ .filter((q) => q.eq(q.field("status"), "active"))
84+ .first();
85+
86+ return Boolean(activeWorkspaceMembership);
87+}
88+
89+export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) {
90+ const identity = await ctx.auth.getUserIdentity();
91+ if (!identity) return null;
92+
93+ const now = Date.now();
94+ const subject = identity.subject;
95+ const authKitProfile = await getAuthKitUserProfile(ctx, subject);
96+ const identityProfile = resolveIdentityProfile({
97+ identity: identity as Record<string, unknown> & { subject: string },
98+ authKitProfile,
99+ });
100+
101+ const account = await upsertWorkosAccount(ctx, {
102+ workosUserId: subject,
103+ email: identityProfile.email,
104+ fullName: identityProfile.fullName,
105+ firstName: identityProfile.firstName,
106+ lastName: identityProfile.lastName,
107+ avatarUrl: identityProfile.avatarUrl,
108+ now,
109+ includeLastLoginAt: true,
110+ });
111+ if (!account) return null;
112+
113+ await refreshGeneratedPersonalWorkspaceNames(ctx, account._id, {
114+ email: identityProfile.email,
115+ firstName: identityProfile.firstName,
116+ fullName: identityProfile.fullName,
117+ workosUserId: subject,
118+ now,
119+ });
120+
121+ await seedHintedOrganizationMembership(ctx, {
122+ accountId: account._id,
123+ hintedWorkosOrgId: identityProfile.hintedWorkosOrgId,
124+ email: identityProfile.email,
125+ now,
126+ });
127+
128+ await syncWorkspaceMembershipsForOrganizations(ctx, {
129+ accountId: account._id,
130+ now,
131+ });
132+
133+ const hasActiveWorkspaceMembership = await ensureAtLeastOneWorkspaceMembership(ctx, {
134+ accountId: account._id,
135+ });
136+
137+ if (!hasActiveWorkspaceMembership) {
138+ await ensurePersonalWorkspace(ctx, account._id, {
139+ email: identityProfile.email,
140+ firstName: identityProfile.firstName,
141+ fullName: identityProfile.fullName,
142+ workosUserId: subject,
143+ now,
144+ });
145+ }
146+
147+ return account;
148+}
executor/packages/convex/auth-db-queries.ts+41−0
@@ -0,0 +1,41 @@
1+import type { Doc } from "./_generated/dataModel";
2+import type { AccountId, DbCtx, OrganizationId } from "./auth-types";
3+
4+export async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) {
5+ return await ctx.db
6+ .query("accounts")
7+ .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", workosUserId))
8+ .unique();
9+}
10+
11+export async function getWorkspaceByWorkosOrgId(ctx: DbCtx, workosOrgId: string) {
12+ return await ctx.db
13+ .query("workspaces")
14+ .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId))
15+ .unique();
16+}
17+
18+export async function getOrganizationByWorkosOrgId(ctx: DbCtx, workosOrgId: string) {
19+ return await ctx.db
20+ .query("organizations")
21+ .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId))
22+ .unique();
23+}
24+
25+export async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: OrganizationId) {
26+ return await ctx.db
27+ .query("workspaces")
28+ .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))
29+ .first();
30+}
31+
32+export 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-event-handlers.ts+335−0
@@ -0,0 +1,335 @@
1+import { type AuthKit } from "@convex-dev/workos-authkit";
2+import type { DataModel, Doc } from "./_generated/dataModel";
3+import type { MutationCtx } from "./_generated/server";
4+import { upsertWorkosAccount } from "./auth-accounts";
5+import {
6+ getAccountByWorkosId,
7+ getFirstWorkspaceByOrganizationId,
8+ getOrganizationByWorkosOrgId,
9+ getWorkspaceByWorkosOrgId,
10+} from "./auth-db-queries";
11+import {
12+ deriveOrganizationMembershipState,
13+ deriveWorkspaceMembershipRole,
14+ markPendingInvitesAcceptedByEmail,
15+ upsertOrganizationMembership,
16+} from "./auth-memberships";
17+import { ensureUniqueOrganizationSlug } from "./auth-naming";
18+
19+type WorkosMembershipEventData = {
20+ id: string;
21+ user_id?: string;
22+ userId?: string;
23+ organization_id?: string;
24+ organizationId?: string;
25+ role?: { slug?: string };
26+ status?: string;
27+};
28+
29+async function resolveMembershipRecord(
30+ ctx: Pick<MutationCtx, "db">,
31+ data: WorkosMembershipEventData,
32+) {
33+ let membership = await ctx.db
34+ .query("workspaceMembers")
35+ .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id))
36+ .unique();
37+
38+ let account: Doc<"accounts"> | null = null;
39+ let workspace: Doc<"workspaces"> | null = null;
40+
41+ if (!membership) {
42+ const workosUserId = data.user_id ?? data.userId;
43+ const workosOrgId = data.organization_id ?? data.organizationId;
44+ if (!workosUserId || !workosOrgId) {
45+ return { membership: null, account: null, workspace: null };
46+ }
47+
48+ [account, workspace] = await Promise.all([
49+ getAccountByWorkosId(ctx, workosUserId),
50+ getWorkspaceByWorkosOrgId(ctx, workosOrgId),
51+ ]);
52+ if (!account || !workspace) {
53+ return { membership: null, account: null, workspace: null };
54+ }
55+
56+ const workspaceId = workspace._id;
57+ const accountId = account._id;
58+
59+ membership = await ctx.db
60+ .query("workspaceMembers")
61+ .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))
62+ .unique();
63+ if (!membership) {
64+ return { membership: null, account: null, workspace: null };
65+ }
66+ } else {
67+ account = await ctx.db.get(membership.accountId);
68+ workspace = await ctx.db.get(membership.workspaceId);
69+ }
70+
71+ if (!account || !workspace) {
72+ return { membership: null, account: null, workspace: null };
73+ }
74+
75+ return { membership, account, workspace };
76+}
77+
78+export const workosEventHandlers = {
79+ "user.created": async (ctx, event) => {
80+ const now = Date.now();
81+ const data = event.data;
82+ const fullName = [data.firstName, data.lastName].filter(Boolean).join(" ") || data.email;
83+
84+ await upsertWorkosAccount(ctx, {
85+ workosUserId: data.id,
86+ email: data.email,
87+ fullName,
88+ firstName: data.firstName ?? undefined,
89+ lastName: data.lastName ?? undefined,
90+ avatarUrl: data.profilePictureUrl ?? undefined,
91+ now,
92+ includeLastLoginAt: true,
93+ });
94+ },
95+
96+ "user.updated": async (ctx, event) => {
97+ const account = await getAccountByWorkosId(ctx, event.data.id);
98+ if (!account) return;
99+
100+ const fullName = [event.data.firstName, event.data.lastName].filter(Boolean).join(" ") || event.data.email;
101+ await ctx.db.patch(account._id, {
102+ email: event.data.email,
103+ name: fullName,
104+ firstName: event.data.firstName ?? undefined,
105+ lastName: event.data.lastName ?? undefined,
106+ avatarUrl: event.data.profilePictureUrl ?? undefined,
107+ status: "active",
108+ updatedAt: Date.now(),
109+ });
110+ },
111+
112+ "user.deleted": async (ctx, event) => {
113+ const account = await getAccountByWorkosId(ctx, event.data.id);
114+ if (!account) return;
115+
116+ const memberships = await ctx.db
117+ .query("workspaceMembers")
118+ .withIndex("by_account", (q) => q.eq("accountId", account._id))
119+ .collect();
120+ for (const membership of memberships) {
121+ await ctx.db.delete(membership._id);
122+ }
123+
124+ await ctx.db.delete(account._id);
125+ },
126+
127+ "organization.created": async (ctx, event) => {
128+ const now = Date.now();
129+ let organization = await getOrganizationByWorkosOrgId(ctx, event.data.id);
130+ if (organization) {
131+ await ctx.db.patch(organization._id, {
132+ name: event.data.name,
133+ status: "active",
134+ updatedAt: now,
135+ });
136+ organization = await ctx.db.get(organization._id);
137+ } else {
138+ const slug = await ensureUniqueOrganizationSlug(ctx, event.data.name);
139+ const organizationId = await ctx.db.insert("organizations", {
140+ workosOrgId: event.data.id,
141+ slug,
142+ name: event.data.name,
143+ status: "active",
144+ createdAt: now,
145+ updatedAt: now,
146+ });
147+ organization = await ctx.db.get(organizationId);
148+ }
149+
150+ if (!organization) {
151+ return;
152+ }
153+
154+ const existingWorkspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id);
155+ if (existingWorkspace) {
156+ await ctx.db.patch(existingWorkspace._id, {
157+ organizationId: organization._id,
158+ workosOrgId: event.data.id,
159+ updatedAt: now,
160+ });
161+ return;
162+ }
163+
164+ const organizationWorkspace = await getFirstWorkspaceByOrganizationId(ctx, organization._id);
165+ if (organizationWorkspace) {
166+ await ctx.db.patch(organizationWorkspace._id, {
167+ workosOrgId: event.data.id,
168+ updatedAt: now,
169+ });
170+ return;
171+ }
172+
173+ await ctx.db.insert("workspaces", {
174+ workosOrgId: event.data.id,
175+ organizationId: organization._id,
176+ slug: "default",
177+ name: "Default Workspace",
178+ createdByAccountId: organization.createdByAccountId,
179+ createdAt: now,
180+ updatedAt: now,
181+ });
182+ },
183+
184+ "organization.updated": async (ctx, event) => {
185+ const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id);
186+ if (organization) {
187+ await ctx.db.patch(organization._id, {
188+ name: event.data.name,
189+ updatedAt: Date.now(),
190+ });
191+ }
192+ },
193+
194+ "organization.deleted": async (ctx, event) => {
195+ const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id);
196+ if (organization) {
197+ await ctx.db.patch(organization._id, {
198+ status: "deleted",
199+ updatedAt: Date.now(),
200+ });
201+ }
202+
203+ const workspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id);
204+ if (!workspace) return;
205+
206+ const members = await ctx.db
207+ .query("workspaceMembers")
208+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
209+ .collect();
210+ for (const member of members) {
211+ await ctx.db.delete(member._id);
212+ }
213+
214+ await ctx.db.delete(workspace._id);
215+ },
216+
217+ "organization_membership.created": async (ctx, event) => {
218+ const now = Date.now();
219+ const data = event.data as WorkosMembershipEventData;
220+ const workosUserId = data.user_id ?? data.userId;
221+ const workosOrgId = data.organization_id ?? data.organizationId;
222+ if (!workosUserId || !workosOrgId) return;
223+
224+ const [account, workspace] = await Promise.all([
225+ getAccountByWorkosId(ctx, workosUserId),
226+ getWorkspaceByWorkosOrgId(ctx, workosOrgId),
227+ ]);
228+ if (!account || !workspace) return;
229+
230+ const existing = await ctx.db
231+ .query("workspaceMembers")
232+ .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
233+ .unique();
234+
235+ const role = deriveWorkspaceMembershipRole(data.role?.slug);
236+ const status = deriveOrganizationMembershipState(data.status);
237+
238+ await upsertOrganizationMembership(ctx, {
239+ organizationId: workspace.organizationId,
240+ accountId: account._id,
241+ role,
242+ status,
243+ billable: status === "active",
244+ now,
245+ });
246+
247+ if (status === "active") {
248+ await markPendingInvitesAcceptedByEmail(ctx, {
249+ organizationId: workspace.organizationId,
250+ email: account.email,
251+ acceptedAt: now,
252+ });
253+ }
254+
255+ if (existing) {
256+ await ctx.db.patch(existing._id, {
257+ workosOrgMembershipId: event.data.id,
258+ role,
259+ status,
260+ updatedAt: now,
261+ });
262+ return;
263+ }
264+
265+ await ctx.db.insert("workspaceMembers", {
266+ workspaceId: workspace._id,
267+ accountId: account._id,
268+ workosOrgMembershipId: event.data.id,
269+ role,
270+ status,
271+ createdAt: now,
272+ updatedAt: now,
273+ });
274+ },
275+
276+ "organization_membership.updated": async (ctx, event) => {
277+ const now = Date.now();
278+ const data = event.data as WorkosMembershipEventData;
279+ const { membership, account, workspace } = await resolveMembershipRecord(ctx, data);
280+
281+ if (!membership || !account || !workspace) {
282+ return;
283+ }
284+
285+ const role = deriveWorkspaceMembershipRole(data.role?.slug);
286+ const status = deriveOrganizationMembershipState(data.status);
287+
288+ await upsertOrganizationMembership(ctx, {
289+ organizationId: workspace.organizationId,
290+ accountId: account._id,
291+ role,
292+ status,
293+ billable: status === "active",
294+ now,
295+ });
296+
297+ if (status === "active") {
298+ await markPendingInvitesAcceptedByEmail(ctx, {
299+ organizationId: workspace.organizationId,
300+ email: account.email,
301+ acceptedAt: now,
302+ });
303+ }
304+
305+ await ctx.db.patch(membership._id, {
306+ workosOrgMembershipId: data.id,
307+ role,
308+ status,
309+ updatedAt: now,
310+ });
311+ },
312+
313+ "organization_membership.deleted": async (ctx, event) => {
314+ const now = Date.now();
315+ const membership = await ctx.db
316+ .query("workspaceMembers")
317+ .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id))
318+ .unique();
319+ if (!membership) return;
320+
321+ const workspace = await ctx.db.get(membership.workspaceId);
322+ if (workspace?.organizationId) {
323+ await upsertOrganizationMembership(ctx, {
324+ organizationId: workspace.organizationId,
325+ accountId: membership.accountId,
326+ role: membership.role,
327+ status: "removed",
328+ billable: false,
329+ now,
330+ });
331+ }
332+
333+ await ctx.db.delete(membership._id);
334+ },
335+} satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>;
executor/packages/convex/auth-identity.ts+70−0
@@ -0,0 +1,70 @@
1+import { components } from "./_generated/api";
2+import type { RunQueryCtx } from "./auth-types";
3+
4+type WorkosProfile = {
5+ email?: string;
6+ firstName?: string | null;
7+ lastName?: string | null;
8+ profilePictureUrl?: string | null;
9+};
10+
11+export function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {
12+ for (const key of keys) {
13+ const value = identity[key];
14+ if (typeof value === "string" && value.trim().length > 0) {
15+ return value.trim();
16+ }
17+ }
18+ return undefined;
19+}
20+
21+export async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) {
22+ try {
23+ return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, {
24+ id: workosUserId,
25+ });
26+ } catch {
27+ return null;
28+ }
29+}
30+
31+export function resolveIdentityProfile(args: {
32+ identity: Record<string, unknown> & { subject: string };
33+ authKitProfile: WorkosProfile | null;
34+}) {
35+ const { identity, authKitProfile } = args;
36+
37+ const email =
38+ authKitProfile?.email
39+ ?? getIdentityString(identity, ["email", "https://workos.com/email", "upn"])
40+ ?? `${identity.subject}@workos.executor.local`;
41+
42+ const firstName = authKitProfile?.firstName
43+ ?? getIdentityString(identity, ["given_name", "first_name", "https://workos.com/first_name"]);
44+
45+ const lastName = authKitProfile?.lastName
46+ ?? getIdentityString(identity, ["family_name", "last_name", "https://workos.com/last_name"]);
47+
48+ const fullName =
49+ (getIdentityString(identity, ["name", "https://workos.com/name"]) ?? [firstName, lastName].filter(Boolean).join(" "))
50+ || email;
51+
52+ const avatarUrl =
53+ (authKitProfile?.profilePictureUrl ?? undefined)
54+ ?? getIdentityString(identity, ["picture", "avatar_url", "https://workos.com/profile_picture_url"]);
55+
56+ const hintedWorkosOrgId = getIdentityString(identity, [
57+ "org_id",
58+ "organization_id",
59+ "https://workos.com/organization_id",
60+ ]);
61+
62+ return {
63+ email,
64+ firstName,
65+ lastName,
66+ fullName,
67+ avatarUrl,
68+ hintedWorkosOrgId,
69+ };
70+}
executor/packages/convex/auth-memberships.ts+133−0
@@ -0,0 +1,133 @@
1+import type { Doc } from "./_generated/dataModel";
2+import type {
3+ AccountId,
4+ DbCtx,
5+ OrganizationId,
6+ OrganizationMemberStatus,
7+ OrganizationRole,
8+ WorkspaceId,
9+ WorkspaceMemberRole,
10+} from "./auth-types";
11+
12+export function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): WorkspaceMemberRole {
13+ if (role === "owner") {
14+ return "owner";
15+ }
16+ if (role === "admin") {
17+ return "admin";
18+ }
19+ return "member";
20+}
21+
22+export async function upsertOrganizationMembership(
23+ ctx: DbCtx,
24+ args: {
25+ organizationId: OrganizationId;
26+ accountId: AccountId;
27+ role: OrganizationRole;
28+ status: OrganizationMemberStatus;
29+ billable: boolean;
30+ invitedByAccountId?: AccountId;
31+ now: number;
32+ },
33+) {
34+ const existing = await ctx.db
35+ .query("organizationMembers")
36+ .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
37+ .unique();
38+
39+ if (existing) {
40+ await ctx.db.patch(existing._id, {
41+ role: args.role,
42+ status: args.status,
43+ billable: args.billable,
44+ invitedByAccountId: args.invitedByAccountId,
45+ joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,
46+ updatedAt: args.now,
47+ });
48+ return;
49+ }
50+
51+ await ctx.db.insert("organizationMembers", {
52+ organizationId: args.organizationId,
53+ accountId: args.accountId,
54+ role: args.role,
55+ status: args.status,
56+ billable: args.billable,
57+ invitedByAccountId: args.invitedByAccountId,
58+ joinedAt: args.status === "active" ? args.now : undefined,
59+ createdAt: args.now,
60+ updatedAt: args.now,
61+ });
62+}
63+
64+export async function ensureWorkspaceMembership(
65+ ctx: DbCtx,
66+ args: {
67+ workspaceId: WorkspaceId;
68+ accountId: AccountId;
69+ role: WorkspaceMemberRole;
70+ now: number;
71+ },
72+) {
73+ const existing = await ctx.db
74+ .query("workspaceMembers")
75+ .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId))
76+ .unique();
77+
78+ if (existing) {
79+ if (existing.status === "active") {
80+ await ctx.db.patch(existing._id, {
81+ role: args.role,
82+ updatedAt: args.now,
83+ });
84+ }
85+ return;
86+ }
87+
88+ await ctx.db.insert("workspaceMembers", {
89+ workspaceId: args.workspaceId,
90+ accountId: args.accountId,
91+ role: args.role,
92+ status: "active",
93+ createdAt: args.now,
94+ updatedAt: args.now,
95+ });
96+}
97+
98+export async function markPendingInvitesAcceptedByEmail(
99+ ctx: DbCtx,
100+ args: {
101+ organizationId: OrganizationId;
102+ email?: string;
103+ acceptedAt: number;
104+ },
105+) {
106+ if (!args.email) {
107+ return;
108+ }
109+
110+ const normalizedEmail = args.email.toLowerCase();
111+ const pendingInvites = await ctx.db
112+ .query("invites")
113+ .withIndex("by_org_email_status", (q) =>
114+ q.eq("organizationId", args.organizationId).eq("email", normalizedEmail).eq("status", "pending"),
115+ )
116+ .collect();
117+
118+ for (const invite of pendingInvites) {
119+ await ctx.db.patch(invite._id, {
120+ status: "accepted",
121+ acceptedAt: args.acceptedAt,
122+ updatedAt: args.acceptedAt,
123+ });
124+ }
125+}
126+
127+export function deriveWorkspaceMembershipRole(workosRoleSlug?: string): Doc<"workspaceMembers">["role"] {
128+ return workosRoleSlug === "admin" ? "admin" : "member";
129+}
130+
131+export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus {
132+ return status === "active" ? "active" : "pending";
133+}
executor/packages/convex/auth-naming.ts+89−0
@@ -0,0 +1,89 @@
1+import { slugify } from "../core/src/identity";
2+import { ensureUniqueSlug } from "../core/src/slug";
3+import type { DbCtx } from "./auth-types";
4+
5+function titleCaseWords(input: string): string {
6+ return input
7+ .split(" ")
8+ .filter((segment) => segment.length > 0)
9+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
10+ .join(" ");
11+}
12+
13+function escapeRegex(value: string): string {
14+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15+}
16+
17+export function isGeneratedPersonalOrganizationName(name: string, workosUserId: string): boolean {
18+ if (/workspace$/i.test(name)) {
19+ return true;
20+ }
21+
22+ if (/^user(?:[\s_][a-z0-9]+)?'s organization$/i.test(name)) {
23+ return true;
24+ }
25+
26+ return new RegExp(`^${escapeRegex(workosUserId)}'s organization$`, "i").test(name);
27+}
28+
29+export function isGeneratedPersonalWorkspaceName(name: string, workosUserId: string): boolean {
30+ if (/^my'?s workspace$/i.test(name)) {
31+ return true;
32+ }
33+
34+ if (/^user(?:[\s_][a-z0-9]+)?'s workspace$/i.test(name)) {
35+ return true;
36+ }
37+
38+ return new RegExp(`^${escapeRegex(workosUserId)}'s workspace$`, "i").test(name);
39+}
40+
41+function deriveOwnerLabel(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }): string {
42+ const firstName = args.firstName?.trim();
43+ if (firstName && !/^my$/i.test(firstName)) {
44+ return firstName;
45+ }
46+
47+ const fullName = args.fullName?.trim();
48+ if (fullName && !fullName.includes("@")) {
49+ return fullName;
50+ }
51+
52+ const emailLocalPart = args.email.split("@")[0]?.trim();
53+ if (emailLocalPart) {
54+ const normalized = emailLocalPart
55+ .replace(/[._-]+/g, " ")
56+ .replace(/[^a-zA-Z0-9 ]+/g, " ")
57+ .replace(/\s+/g, " ")
58+ .trim();
59+ if (normalized.length > 0) {
60+ return titleCaseWords(normalized);
61+ }
62+ }
63+
64+ return `User ${args.workosUserId.slice(-6)}`;
65+}
66+
67+export function derivePersonalNames(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }) {
68+ const ownerLabel = deriveOwnerLabel(args);
69+ return {
70+ organizationName: `${ownerLabel}'s Organization`,
71+ workspaceName: `${ownerLabel}'s Workspace`,
72+ };
73+}
74+
75+export function buildPersonalWorkspaceSlugSeed(email: string, workosUserId: string): string {
76+ const baseSlug = slugify(email.split("@")[0] ?? workosUserId, "workspace");
77+ return `${baseSlug}-${workosUserId.slice(-6)}`;
78+}
79+
80+export async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> {
81+ const baseSlug = slugify(baseName, "workspace");
82+ return await ensureUniqueSlug(baseSlug, async (candidate) => {
83+ const collision = await ctx.db
84+ .query("organizations")
85+ .withIndex("by_slug", (q) => q.eq("slug", candidate))
86+ .unique();
87+ return collision !== null;
88+ });
89+}
executor/packages/convex/auth-personal-workspace.ts+180−0
@@ -0,0 +1,180 @@
1+import type { Id } from "./_generated/dataModel";
2+import { upsertOrganizationMembership } from "./auth-memberships";
3+import {
4+ buildPersonalWorkspaceSlugSeed,
5+ derivePersonalNames,
6+ ensureUniqueOrganizationSlug,
7+ isGeneratedPersonalOrganizationName,
8+ isGeneratedPersonalWorkspaceName,
9+} from "./auth-naming";
10+import type { AccountId, DbCtx } from "./auth-types";
11+
12+type PersonalWorkspaceOptions = {
13+ email: string;
14+ firstName?: string;
15+ fullName?: string;
16+ workosUserId: string;
17+ now: number;
18+};
19+
20+async function renameGeneratedPersonalWorkspaceArtifacts(
21+ ctx: DbCtx,
22+ args: {
23+ accountId: AccountId;
24+ workspaceId: Id<"workspaces">;
25+ organizationId: Id<"organizations">;
26+ workspaceName: string;
27+ organizationName: string;
28+ workosUserId: string;
29+ now: number;
30+ },
31+) {
32+ const workspace = await ctx.db.get(args.workspaceId);
33+ if (!workspace || workspace.createdByAccountId !== args.accountId) {
34+ return;
35+ }
36+
37+ const organization = await ctx.db.get(args.organizationId);
38+ if (!organization || organization.createdByAccountId !== args.accountId) {
39+ return;
40+ }
41+
42+ if (
43+ isGeneratedPersonalOrganizationName(organization.name, args.workosUserId)
44+ && organization.name !== args.organizationName
45+ ) {
46+ await ctx.db.patch(organization._id, {
47+ name: args.organizationName,
48+ updatedAt: args.now,
49+ });
50+ }
51+
52+ if (isGeneratedPersonalWorkspaceName(workspace.name, args.workosUserId) && workspace.name !== args.workspaceName) {
53+ await ctx.db.patch(workspace._id, {
54+ name: args.workspaceName,
55+ updatedAt: args.now,
56+ });
57+ }
58+}
59+
60+export async function ensurePersonalWorkspace(ctx: DbCtx, accountId: AccountId, opts: PersonalWorkspaceOptions) {
61+ const personalNames = derivePersonalNames({
62+ firstName: opts.firstName,
63+ fullName: opts.fullName,
64+ email: opts.email,
65+ workosUserId: opts.workosUserId,
66+ });
67+
68+ const memberships = await ctx.db
69+ .query("workspaceMembers")
70+ .withIndex("by_account", (q) => q.eq("accountId", accountId))
71+ .collect();
72+
73+ for (const membership of memberships) {
74+ const workspace = await ctx.db.get(membership.workspaceId);
75+ if (!workspace || workspace.createdByAccountId !== accountId) {
76+ continue;
77+ }
78+
79+ await renameGeneratedPersonalWorkspaceArtifacts(ctx, {
80+ accountId,
81+ workspaceId: workspace._id,
82+ organizationId: workspace.organizationId,
83+ workspaceName: personalNames.workspaceName,
84+ organizationName: personalNames.organizationName,
85+ workosUserId: opts.workosUserId,
86+ now: opts.now,
87+ });
88+
89+ await upsertOrganizationMembership(ctx, {
90+ organizationId: workspace.organizationId,
91+ accountId,
92+ role: "owner",
93+ status: "active",
94+ billable: true,
95+ now: opts.now,
96+ });
97+
98+ return {
99+ workspace: await ctx.db.get(workspace._id),
100+ membership,
101+ };
102+ }
103+
104+ const organizationSlug = await ensureUniqueOrganizationSlug(ctx, personalNames.organizationName);
105+ const organizationId = await ctx.db.insert("organizations", {
106+ slug: organizationSlug,
107+ name: personalNames.organizationName,
108+ status: "active",
109+ createdByAccountId: accountId,
110+ createdAt: opts.now,
111+ updatedAt: opts.now,
112+ });
113+
114+ const workspaceId = await ctx.db.insert("workspaces", {
115+ organizationId,
116+ slug: buildPersonalWorkspaceSlugSeed(opts.email, opts.workosUserId),
117+ name: personalNames.workspaceName,
118+ createdByAccountId: accountId,
119+ createdAt: opts.now,
120+ updatedAt: opts.now,
121+ });
122+
123+ await upsertOrganizationMembership(ctx, {
124+ organizationId,
125+ accountId,
126+ role: "owner",
127+ status: "active",
128+ billable: true,
129+ now: opts.now,
130+ });
131+
132+ const membershipId = await ctx.db.insert("workspaceMembers", {
133+ workspaceId,
134+ accountId,
135+ role: "owner",
136+ status: "active",
137+ createdAt: opts.now,
138+ updatedAt: opts.now,
139+ });
140+
141+ return {
142+ workspace: await ctx.db.get(workspaceId),
143+ membership: await ctx.db.get(membershipId),
144+ };
145+}
146+
147+export async function refreshGeneratedPersonalWorkspaceNames(
148+ ctx: DbCtx,
149+ accountId: AccountId,
150+ opts: PersonalWorkspaceOptions,
151+) {
152+ const personalNames = derivePersonalNames({
153+ firstName: opts.firstName,
154+ fullName: opts.fullName,
155+ email: opts.email,
156+ workosUserId: opts.workosUserId,
157+ });
158+
159+ const memberships = await ctx.db
160+ .query("workspaceMembers")
161+ .withIndex("by_account", (q) => q.eq("accountId", accountId))
162+ .collect();
163+
164+ for (const membership of memberships) {
165+ const workspace = await ctx.db.get(membership.workspaceId);
166+ if (!workspace) {
167+ continue;
168+ }
169+
170+ await renameGeneratedPersonalWorkspaceArtifacts(ctx, {
171+ accountId,
172+ workspaceId: workspace._id,
173+ organizationId: workspace.organizationId,
174+ workspaceName: personalNames.workspaceName,
175+ organizationName: personalNames.organizationName,
176+ workosUserId: opts.workosUserId,
177+ now: opts.now,
178+ });
179+ }
180+}
executor/packages/convex/auth-types.ts+13−0
@@ -0,0 +1,13 @@
1+import type { Doc, Id } from "./_generated/dataModel";
2+import type { MutationCtx } from "./_generated/server";
3+
4+export type DbCtx = Pick<MutationCtx, "db">;
5+export type RunQueryCtx = Pick<MutationCtx, "runQuery">;
6+
7+export type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
8+export type OrganizationMemberStatus = "active" | "pending" | "removed";
9+export type WorkspaceMemberRole = Doc<"workspaceMembers">["role"];
10+
11+export type OrganizationId = Id<"organizations">;
12+export type WorkspaceId = Id<"workspaces">;
13+export type AccountId = Id<"accounts">;
executor/packages/convex/auth.ts+5−894
@@ -1,26 +1,9 @@
11 import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";
22 import { components, internal } from "./_generated/api";
3−import type { DataModel, Doc, Id } from "./_generated/dataModel";
4−import type { MutationCtx } from "./_generated/server";
3+import type { DataModel } from "./_generated/dataModel";
54 import { internalMutation, mutation } from "./_generated/server";
6−import { slugify } from "../core/src/identity";
7−import { ensureUniqueSlug } from "../core/src/slug";
8−
9−type DbCtx = Pick<MutationCtx, "db">;
10−type RunQueryCtx = Pick<MutationCtx, "runQuery">;
11−type WorkosEventCtx = Pick<MutationCtx, "db" | "runQuery">;
12−type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
13−type OrganizationMemberStatus = "active" | "pending" | "removed";
14−
15−function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): Doc<"workspaceMembers">["role"] {
16− if (role === "owner") {
17− return "owner";
18− }
19− if (role === "admin") {
20− return "admin";
21− }
22− return "member";
23−}
5+import { bootstrapCurrentWorkosAccountImpl } from "./auth-bootstrap";
6+import { workosEventHandlers } from "./auth-event-handlers";
247
258 const workosEnabled = Boolean(
269 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,
@@ -49,718 +32,7 @@ export const authKit = · 22 unmodified lines
4932 registerRoutes: () => {},
5033 } as Pick<AuthKit<DataModel>, "registerRoutes">);
5134
52−function titleCaseWords(input: string): string {
53− return input
54− .split(" ")
55− .filter((segment) => segment.length > 0)
56− .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
57− .join(" ");
58−}
59−
60−function escapeRegex(value: string): string {
61− return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62−}
63−
64−function isGeneratedPersonalOrganizationName(name: string, workosUserId: string): boolean {
65− if (/workspace$/i.test(name)) {
66− return true;
67− }
68−
69− if (/^user(?:[\s_][a-z0-9]+)?'s organization$/i.test(name)) {
70− return true;
71− }
72−
73− return new RegExp(`^${escapeRegex(workosUserId)}'s organization$`, "i").test(name);
74−}
75−
76−function isGeneratedPersonalWorkspaceName(name: string, workosUserId: string): boolean {
77− if (/^my'?s workspace$/i.test(name)) {
78− return true;
79− }
80−
81− if (/^user(?:[\s_][a-z0-9]+)?'s workspace$/i.test(name)) {
82− return true;
83− }
84−
85− return new RegExp(`^${escapeRegex(workosUserId)}'s workspace$`, "i").test(name);
86−}
87−
88−function deriveOwnerLabel(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }): string {
89− const firstName = args.firstName?.trim();
90− if (firstName && !/^my$/i.test(firstName)) {
91− return firstName;
92− }
93−
94− const fullName = args.fullName?.trim();
95− if (fullName && !fullName.includes("@")) {
96− return fullName;
97− }
98−
99− const emailLocalPart = args.email.split("@")[0]?.trim();
100− if (emailLocalPart) {
101− const normalized = emailLocalPart
102− .replace(/[._-]+/g, " ")
103− .replace(/[^a-zA-Z0-9 ]+/g, " ")
104− .replace(/\s+/g, " ")
105− .trim();
106− if (normalized.length > 0) {
107− return titleCaseWords(normalized);
108− }
109− }
110−
111− return `User ${args.workosUserId.slice(-6)}`;
112−}
113−
114−function derivePersonalNames(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }) {
115− const ownerLabel = deriveOwnerLabel(args);
116− return {
117− organizationName: `${ownerLabel}'s Organization`,
118− workspaceName: `${ownerLabel}'s Workspace`,
119− };
120−}
121−
122−async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) {
123− return await ctx.db
124− .query("accounts")
125− .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", workosUserId))
126− .unique();
127−}
128−
129−async function getWorkspaceByWorkosOrgId(ctx: DbCtx, workosOrgId: string) {
130− return await ctx.db
131− .query("workspaces")
132− .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId))
133− .unique();
134−}
135−
136−async function getOrganizationByWorkosOrgId(ctx: DbCtx, workosOrgId: string) {
137− return await ctx.db
138− .query("organizations")
139− .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId))
140− .unique();
141−}
142−
143−async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: Id<"organizations">) {
144− return await ctx.db
145− .query("workspaces")
146− .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))
147− .first();
148−}
149−
150−async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> {
151− const baseSlug = slugify(baseName, "workspace");
152− return await ensureUniqueSlug(baseSlug, async (candidate) => {
153− const collision = await ctx.db
154− .query("organizations")
155− .withIndex("by_slug", (q) => q.eq("slug", candidate))
156− .unique();
157− return collision !== null;
158− });
159−}
160−
161−async function upsertOrganizationMembership(
162− ctx: DbCtx,
163− args: {
164− organizationId: Id<"organizations">;
165− accountId: Id<"accounts">;
166− role: OrganizationRole;
167− status: OrganizationMemberStatus;
168− billable: boolean;
169− invitedByAccountId?: Id<"accounts">;
170− now: number;
171− },
172−) {
173− const existing = await ctx.db
174− .query("organizationMembers")
175− .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
176− .unique();
177−
178− if (existing) {
179− await ctx.db.patch(existing._id, {
180− role: args.role,
181− status: args.status,
182− billable: args.billable,
183− invitedByAccountId: args.invitedByAccountId,
184− joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,
185− updatedAt: args.now,
186− });
187− return;
188− }
189−
190− await ctx.db.insert("organizationMembers", {
191− organizationId: args.organizationId,
192− accountId: args.accountId,
193− role: args.role,
194− status: args.status,
195− billable: args.billable,
196− invitedByAccountId: args.invitedByAccountId,
197− joinedAt: args.status === "active" ? args.now : undefined,
198− createdAt: args.now,
199− updatedAt: args.now,
200− });
201−}
202−
203−async function ensureWorkspaceMembership(
204− ctx: DbCtx,
205− args: {
206− workspaceId: Id<"workspaces">;
207− accountId: Id<"accounts">;
208− role: Doc<"workspaceMembers">["role"];
209− now: number;
210− },
211−) {
212− const existing = await ctx.db
213− .query("workspaceMembers")
214− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId))
215− .unique();
216−
217− if (existing) {
218− if (existing.status === "active") {
219− await ctx.db.patch(existing._id, {
220− role: args.role,
221− updatedAt: args.now,
222− });
223− }
224− return;
225− }
226−
227− await ctx.db.insert("workspaceMembers", {
228− workspaceId: args.workspaceId,
229− accountId: args.accountId,
230− role: args.role,
231− status: "active",
232− createdAt: args.now,
233− updatedAt: args.now,
234− });
235−}
236−
237−async function markPendingInvitesAcceptedByEmail(
238− ctx: DbCtx,
239− args: {
240− organizationId: Id<"organizations">;
241− email?: string;
242− acceptedAt: number;
243− },
244−) {
245− if (!args.email) {
246− return;
247− }
248−
249− const normalizedEmail = args.email.toLowerCase();
250− const pendingInvites = await ctx.db
251− .query("invites")
252− .withIndex("by_org_email_status", (q) =>
253− q.eq("organizationId", args.organizationId).eq("email", normalizedEmail).eq("status", "pending"),
254− )
255− .collect();
256−
257− for (const invite of pendingInvites) {
258− await ctx.db.patch(invite._id, {
259− status: "accepted",
260− acceptedAt: args.acceptedAt,
261− updatedAt: args.acceptedAt,
262− });
263− }
264−}
265−
266−async function ensurePersonalWorkspace(
267− ctx: DbCtx,
268− accountId: Id<"accounts">,
269− opts: { email: string; firstName?: string; fullName?: string; workosUserId: string; now: number },
270−) {
271− const personalNames = derivePersonalNames({
272− firstName: opts.firstName,
273− fullName: opts.fullName,
274− email: opts.email,
275− workosUserId: opts.workosUserId,
276− });
277−
278− const memberships = await ctx.db
279− .query("workspaceMembers")
280− .withIndex("by_account", (q) => q.eq("accountId", accountId))
281− .collect();
282−
283− for (const membership of memberships) {
284− const workspace = await ctx.db.get(membership.workspaceId);
285− if (workspace && workspace.createdByAccountId === accountId) {
286− const organization = await ctx.db.get(workspace.organizationId);
287−
288− if (organization && organization.createdByAccountId === accountId) {
289− const shouldRenameOrganization = isGeneratedPersonalOrganizationName(organization.name, opts.workosUserId);
290− if (shouldRenameOrganization && organization.name !== personalNames.organizationName) {
291− await ctx.db.patch(organization._id, {
292− name: personalNames.organizationName,
293− updatedAt: opts.now,
294− });
295− }
296− }
297−
298− const shouldRenameWorkspace = isGeneratedPersonalWorkspaceName(workspace.name, opts.workosUserId);
299− if (shouldRenameWorkspace && workspace.name !== personalNames.workspaceName) {
300− await ctx.db.patch(workspace._id, {
301− name: personalNames.workspaceName,
302− updatedAt: opts.now,
303− });
304− }
305−
306− await upsertOrganizationMembership(ctx, {
307− organizationId: workspace.organizationId,
308− accountId,
309− role: "owner",
310− status: "active",
311− billable: true,
312− now: opts.now,
313− });
314− const refreshedWorkspace = await ctx.db.get(workspace._id);
315− return { workspace: refreshedWorkspace, membership };
316− }
317− }
318−
319− const organizationSlug = await ensureUniqueOrganizationSlug(ctx, personalNames.organizationName);
320− const organizationId = await ctx.db.insert("organizations", {
321− slug: organizationSlug,
322− name: personalNames.organizationName,
323− status: "active",
324− createdByAccountId: accountId,
325− createdAt: opts.now,
326− updatedAt: opts.now,
327− });
328−
329− const baseSlug = slugify(opts.email.split("@")[0] ?? opts.workosUserId, "workspace");
330− const workspaceId = await ctx.db.insert("workspaces", {
331− organizationId,
332− slug: `${baseSlug}-${opts.workosUserId.slice(-6)}`,
333− name: personalNames.workspaceName,
334− createdByAccountId: accountId,
335− createdAt: opts.now,
336− updatedAt: opts.now,
337− });
338−
339− await upsertOrganizationMembership(ctx, {
340− organizationId,
341− accountId,
342− role: "owner",
343− status: "active",
344− billable: true,
345− now: opts.now,
346− });
347−
348− const userId = await ctx.db.insert("workspaceMembers", {
349− workspaceId,
350− accountId,
351− role: "owner",
352− status: "active",
353− createdAt: opts.now,
354− updatedAt: opts.now,
355− });
356−
357− return {
358− workspace: await ctx.db.get(workspaceId),
359− membership: await ctx.db.get(userId),
360− };
361−}
362−
363−async function refreshGeneratedPersonalWorkspaceNames(
364− ctx: DbCtx,
365− accountId: Id<"accounts">,
366− opts: { email: string; firstName?: string; fullName?: string; workosUserId: string; now: number },
367−) {
368− const personalNames = derivePersonalNames({
369− firstName: opts.firstName,
370− fullName: opts.fullName,
371− email: opts.email,
372− workosUserId: opts.workosUserId,
373− });
374−
375− const memberships = await ctx.db
376− .query("workspaceMembers")
377− .withIndex("by_account", (q) => q.eq("accountId", accountId))
378− .collect();
379−
380− for (const membership of memberships) {
381− const workspace = await ctx.db.get(membership.workspaceId);
382− if (!workspace || workspace.createdByAccountId !== accountId) {
383− continue;
384− }
385−
386− const organization = await ctx.db.get(workspace.organizationId);
387− if (!organization || organization.createdByAccountId !== accountId) {
388− continue;
389− }
390−
391− if (
392− isGeneratedPersonalOrganizationName(organization.name, opts.workosUserId)
393− && organization.name !== personalNames.organizationName
394− ) {
395− await ctx.db.patch(organization._id, {
396− name: personalNames.organizationName,
397− updatedAt: opts.now,
398− });
399− }
400−
401− if (
402− isGeneratedPersonalWorkspaceName(workspace.name, opts.workosUserId)
403− && workspace.name !== personalNames.workspaceName
404− ) {
405− await ctx.db.patch(workspace._id, {
406− name: personalNames.workspaceName,
407− updatedAt: opts.now,
408− });
409− }
410− }
411−}
412−
413−function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {
414− for (const key of keys) {
415− const value = identity[key];
416− if (typeof value === "string" && value.trim().length > 0) {
417− return value.trim();
418− }
419− }
420− return undefined;
421−}
422−
423−async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) {
424− try {
425− return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, {
426− id: workosUserId,
427− });
428− } catch {
429− return null;
430− }
431−}
432−
433−const workosEventHandlers = {
434− "user.created": async (ctx, event) => {
435− const now = Date.now();
436− const data = event.data;
437− const fullName = [data.firstName, data.lastName].filter(Boolean).join(" ") || data.email;
438−
439− let account = await getAccountByWorkosId(ctx, data.id);
440− if (account) {
441− await ctx.db.patch(account._id, {
442− email: data.email,
443− name: fullName,
444− firstName: data.firstName ?? undefined,
445− lastName: data.lastName ?? undefined,
446− avatarUrl: data.profilePictureUrl ?? undefined,
447− status: "active",
448− updatedAt: now,
449− lastLoginAt: now,
450− });
451− account = await ctx.db.get(account._id);
452− } else {
453− const accountId = await ctx.db.insert("accounts", {
454− provider: "workos",
455− providerAccountId: data.id,
456− email: data.email,
457− name: fullName,
458− firstName: data.firstName ?? undefined,
459− lastName: data.lastName ?? undefined,
460− avatarUrl: data.profilePictureUrl ?? undefined,
461− status: "active",
462− createdAt: now,
463− updatedAt: now,
464− lastLoginAt: now,
465− });
466− account = await ctx.db.get(accountId);
467− }
468−
469− if (!account) return;
470− },
471−
472− "user.updated": async (ctx, event) => {
473− const account = await getAccountByWorkosId(ctx, event.data.id);
474− if (!account) return;
475−
476− const fullName = [event.data.firstName, event.data.lastName].filter(Boolean).join(" ") || event.data.email;
477− await ctx.db.patch(account._id, {
478− email: event.data.email,
479− name: fullName,
480− firstName: event.data.firstName ?? undefined,
481− lastName: event.data.lastName ?? undefined,
482− avatarUrl: event.data.profilePictureUrl ?? undefined,
483− status: "active",
484− updatedAt: Date.now(),
485− });
486− },
487−
488− "user.deleted": async (ctx, event) => {
489− const account = await getAccountByWorkosId(ctx, event.data.id);
490− if (!account) return;
491−
492− const memberships = await ctx.db
493− .query("workspaceMembers")
494− .withIndex("by_account", (q) => q.eq("accountId", account._id))
495− .collect();
496− for (const membership of memberships) {
497− await ctx.db.delete(membership._id);
498− }
499−
500− await ctx.db.delete(account._id);
501− },
502−
503− "organization.created": async (ctx, event) => {
504− const now = Date.now();
505− let organization = await getOrganizationByWorkosOrgId(ctx, event.data.id);
506− if (organization) {
507− await ctx.db.patch(organization._id, {
508− name: event.data.name,
509− status: "active",
510− updatedAt: now,
511− });
512− organization = await ctx.db.get(organization._id);
513− } else {
514− const slug = await ensureUniqueOrganizationSlug(ctx, event.data.name);
515− const organizationId = await ctx.db.insert("organizations", {
516− workosOrgId: event.data.id,
517− slug,
518− name: event.data.name,
519− status: "active",
520− createdAt: now,
521− updatedAt: now,
522− });
523− organization = await ctx.db.get(organizationId);
524− }
525−
526− if (!organization) {
527− return;
528− }
529−
530− const existingWorkspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id);
531− if (existingWorkspace) {
532− await ctx.db.patch(existingWorkspace._id, {
533− organizationId: organization._id,
534− workosOrgId: event.data.id,
535− updatedAt: now,
536− });
537− return;
538− }
539−
540− const organizationWorkspace = await getFirstWorkspaceByOrganizationId(ctx, organization._id);
541− if (organizationWorkspace) {
542− await ctx.db.patch(organizationWorkspace._id, {
543− workosOrgId: event.data.id,
544− updatedAt: now,
545− });
546− return;
547− }
548−
549− await ctx.db.insert("workspaces", {
550− workosOrgId: event.data.id,
551− organizationId: organization._id,
552− slug: "default",
553− name: "Default Workspace",
554− createdByAccountId: organization.createdByAccountId,
555− createdAt: now,
556− updatedAt: now,
557− });
558− },
559−
560− "organization.updated": async (ctx, event) => {
561− const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id);
562− if (organization) {
563− await ctx.db.patch(organization._id, {
564− name: event.data.name,
565− updatedAt: Date.now(),
566− });
567− }
568− },
569−
570− "organization.deleted": async (ctx, event) => {
571− const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id);
572− if (organization) {
573− await ctx.db.patch(organization._id, {
574− status: "deleted",
575− updatedAt: Date.now(),
576− });
577− }
578−
579− const workspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id);
580− if (!workspace) return;
581−
582− const members = await ctx.db
583− .query("workspaceMembers")
584− .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
585− .collect();
586− for (const member of members) {
587− await ctx.db.delete(member._id);
588− }
589−
590− await ctx.db.delete(workspace._id);
591− },
592−
593− "organization_membership.created": async (ctx, event) => {
594− const now = Date.now();
595− const data = event.data as {
596− id: string;
597− user_id?: string;
598− userId?: string;
599− organization_id?: string;
600− organizationId?: string;
601− role?: { slug?: string };
602− status?: string;
603− };
604− const workosUserId = data.user_id ?? data.userId;
605− const workosOrgId = data.organization_id ?? data.organizationId;
606− if (!workosUserId || !workosOrgId) return;
607−
608− const [account, workspace] = await Promise.all([
609− getAccountByWorkosId(ctx, workosUserId),
610− getWorkspaceByWorkosOrgId(ctx, workosOrgId),
611− ]);
612− if (!account || !workspace) return;
613−
614− const organizationId = workspace.organizationId;
615−
616− const existing = await ctx.db
617− .query("workspaceMembers")
618− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
619− .unique();
620−
621− const workosRole = data.role?.slug ?? "member";
622− const role = workosRole === "admin" ? "admin" : "member";
623− const status = data.status === "active" ? "active" : "pending";
624−
625− await upsertOrganizationMembership(ctx, {
626− organizationId,
627− accountId: account._id,
628− role,
629− status,
630− billable: status === "active",
631− now,
632− });
633−
634− if (status === "active") {
635− await markPendingInvitesAcceptedByEmail(ctx, {
636− organizationId,
637− email: account.email,
638− acceptedAt: now,
639− });
640− }
641−
642− if (existing) {
643− await ctx.db.patch(existing._id, {
644− workosOrgMembershipId: event.data.id,
645− role,
646− status,
647− updatedAt: now,
648− });
649− return;
650− }
651−
652− await ctx.db.insert("workspaceMembers", {
653− workspaceId: workspace._id,
654− accountId: account._id,
655− workosOrgMembershipId: event.data.id,
656− role,
657− status,
658− createdAt: now,
659− updatedAt: now,
660− });
661− },
662−
663− "organization_membership.updated": async (ctx, event) => {
664− const now = Date.now();
665− const data = event.data as {
666− id: string;
667− user_id?: string;
668− userId?: string;
669− organization_id?: string;
670− organizationId?: string;
671− role?: { slug?: string };
672− status?: string;
673− };
674−
675− let membership = await ctx.db
676− .query("workspaceMembers")
677− .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id))
678− .unique();
679−
680− let account: Doc<"accounts"> | null = null;
681− let workspace: Doc<"workspaces"> | null = null;
682−
683− if (!membership) {
684− const workosUserId = data.user_id ?? data.userId;
685− const workosOrgId = data.organization_id ?? data.organizationId;
686− if (!workosUserId || !workosOrgId) return;
687− [account, workspace] = await Promise.all([
688− getAccountByWorkosId(ctx, workosUserId),
689− getWorkspaceByWorkosOrgId(ctx, workosOrgId),
690− ]);
691− if (!account || !workspace) return;
692− const workspaceId = workspace._id;
693− const accountId = account._id;
694− membership = await ctx.db
695− .query("workspaceMembers")
696− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))
697− .unique();
698− if (!membership) return;
699− } else {
700− account = await ctx.db.get(membership.accountId);
701− workspace = await ctx.db.get(membership.workspaceId);
702− }
703−
704− if (!account || !workspace) {
705− return;
706− }
707−
708− const organizationId = workspace.organizationId;
709−
710− const workosRole = data.role?.slug ?? "member";
711− const status = data.status === "active" ? "active" : "pending";
712− await upsertOrganizationMembership(ctx, {
713− organizationId,
714− accountId: account._id,
715− role: workosRole === "admin" ? "admin" : "member",
716− status,
717− billable: status === "active",
718− now,
719− });
720−
721− if (status === "active") {
722− await markPendingInvitesAcceptedByEmail(ctx, {
723− organizationId,
724− email: account.email,
725− acceptedAt: now,
726− });
727− }
728−
729− await ctx.db.patch(membership._id, {
730− workosOrgMembershipId: data.id,
731− role: workosRole === "admin" ? "admin" : "member",
732− status,
733− updatedAt: now,
734− });
735− },
736−
737− "organization_membership.deleted": async (ctx, event) => {
738− const now = Date.now();
739− const membership = await ctx.db
740− .query("workspaceMembers")
741− .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id))
742− .unique();
743− if (!membership) return;
744−
745− const workspace = await ctx.db.get(membership.workspaceId);
746− if (workspace?.organizationId) {
747− await upsertOrganizationMembership(ctx, {
748− organizationId: workspace.organizationId,
749− accountId: membership.accountId,
750− role: membership.role,
751− status: "removed",
752− billable: false,
753− now,
754− });
755− }
756−
757− await ctx.db.delete(membership._id);
758− },
759−} satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>;
760−
761−const authKitEvents = workosEnabled && authKitInstance
762− ? authKitInstance.events(workosEventHandlers)
763− : null;
35+const authKitEvents = workosEnabled && authKitInstance ? authKitInstance.events(workosEventHandlers) : null;
76436
76537 export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({
76638 args: {},
@@ -769,166 +41,5 @@ export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ · 2 unmodified lines
76941
77042 export const bootstrapCurrentWorkosAccount = mutation({
77143 args: {},
772− handler: async (ctx) => {
773− const identity = await ctx.auth.getUserIdentity();
774− if (!identity) return null;
775−
776− const now = Date.now();
777− const identityRecord = identity as Record<string, unknown>;
778− const subject = identity.subject;
779− const authKitProfile = await getAuthKitUserProfile(ctx, subject);
780− const email =
781− authKitProfile?.email
782− ??
783− getIdentityString(identityRecord, [
784− "email",
785− "https://workos.com/email",
786− "upn",
787− ]) ?? `${subject}@workos.executor.local`;
788−
789− const firstName =
790− authKitProfile?.firstName
791− ?? getIdentityString(identityRecord, [
792− "given_name",
793− "first_name",
794− "https://workos.com/first_name",
795− ]);
796− const lastName =
797− authKitProfile?.lastName
798− ?? getIdentityString(identityRecord, [
799− "family_name",
800− "last_name",
801− "https://workos.com/last_name",
802− ]);
803− const fullName =
804− (getIdentityString(identityRecord, [
805− "name",
806− "https://workos.com/name",
807− ]) ?? [firstName, lastName].filter(Boolean).join(" "))
808− || email;
809− const avatarUrl =
810− (authKitProfile?.profilePictureUrl ?? undefined)
811− ?? getIdentityString(identityRecord, [
812− "picture",
813− "avatar_url",
814− "https://workos.com/profile_picture_url",
815− ]);
816−
817− let account = await ctx.db
818− .query("accounts")
819− .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", subject))
820− .unique();
821−
822− if (account) {
823− await ctx.db.patch(account._id, {
824− email,
825− name: fullName,
826− firstName,
827− lastName,
828− avatarUrl,
829− status: "active",
830− updatedAt: now,
831− lastLoginAt: now,
832− });
833− account = await ctx.db.get(account._id);
834− } else {
835− const accountId = await ctx.db.insert("accounts", {
836− provider: "workos",
837− providerAccountId: subject,
838− email,
839− name: fullName,
840− firstName,
841− lastName,
842− avatarUrl,
843− status: "active",
844− createdAt: now,
845− updatedAt: now,
846− lastLoginAt: now,
847− });
848− account = await ctx.db.get(accountId);
849− }
850−
851− if (!account) return null;
852−
853− await refreshGeneratedPersonalWorkspaceNames(ctx, account._id, {
854− email,
855− firstName,
856− fullName,
857− workosUserId: subject,
858− now,
859− });
860−
861− const activeOrgMembership = await ctx.db
862− .query("organizationMembers")
863− .withIndex("by_account", (q) => q.eq("accountId", account._id))
864− .filter((q) => q.eq(q.field("status"), "active"))
865− .first();
866−
867− const hintedWorkosOrgId = getIdentityString(identityRecord, [
868− "org_id",
869− "organization_id",
870− "https://workos.com/organization_id",
871− ]);
872−
873− if (!activeOrgMembership && hintedWorkosOrgId) {
874− const hintedOrganization = await getOrganizationByWorkosOrgId(ctx, hintedWorkosOrgId);
875− if (hintedOrganization) {
876− await upsertOrganizationMembership(ctx, {
877− organizationId: hintedOrganization._id,
878− accountId: account._id,
879− role: "member",
880− status: "active",
881− billable: true,
882− now,
883− });
884−
885− await markPendingInvitesAcceptedByEmail(ctx, {
886− organizationId: hintedOrganization._id,
887− email,
888− acceptedAt: now,
889− });
890− }
891− }
892−
893− const activeOrganizationMemberships = await ctx.db
894− .query("organizationMembers")
895− .withIndex("by_account", (q) => q.eq("accountId", account._id))
896− .filter((q) => q.eq(q.field("status"), "active"))
897− .collect();
898−
899− for (const membership of activeOrganizationMemberships) {
900− const orgWorkspaces = await ctx.db
901− .query("workspaces")
902− .withIndex("by_organization_created", (q) => q.eq("organizationId", membership.organizationId))
903− .collect();
904−
905− const workspaceRole = mapOrganizationRoleToWorkspaceRole(membership.role);
906− for (const workspace of orgWorkspaces) {
907− await ensureWorkspaceMembership(ctx, {
908− workspaceId: workspace._id,
909− accountId: account._id,
910− role: workspaceRole,
911− now,
912− });
913− }
914− }
915−
916− const activeWorkspaceMembership = await ctx.db
917− .query("workspaceMembers")
918− .withIndex("by_account", (q) => q.eq("accountId", account._id))
919− .filter((q) => q.eq(q.field("status"), "active"))
920− .first();
921−
922− if (!activeWorkspaceMembership) {
923− await ensurePersonalWorkspace(ctx, account._id, {
924− email,
925− firstName,
926− fullName,
927− workosUserId: subject,
928− now,
929− });
930− }
931−
932− return account;
933− },
44+ handler: bootstrapCurrentWorkosAccountImpl,
93445 });
executor/packages/convex/credentialsNode.ts+1−7
@@ -4,19 +4,13 @@ import { WorkOS } from "@workos-inc/node"; · 3 unmodified lines
44 import { v } from "convex/values";
55 import { internal } from "./_generated/api";
66 import { action } from "./_generated/server";
7+import { asRecord } from "./lib/object";
78
89 const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
910 const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));
1011
1112 type SecretBackend = "local-convex" | "workos-vault";
1213
13−function asRecord(value: unknown): Record<string, unknown> {
14− if (value && typeof value === "object" && !Array.isArray(value)) {
15− return value as Record<string, unknown>;
16− }
17− return {};
18−}
19−
2014 function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string {
2115 if (scope !== "actor") return "";
2216 if (typeof actorId !== "string") return "";
executor/packages/convex/database-anonymous-session.ts+81−0
@@ -0,0 +1,81 @@
1+import { v } from "convex/values";
2+import { internalMutation } from "./_generated/server";
3+import { ensureAnonymousIdentity } from "./database-anonymous";
4+import { mapAnonymousContext } from "./database-readers";
5+
6+export const bootstrapAnonymousSession = internalMutation({
7+ args: { sessionId: v.optional(v.string()) },
8+ handler: async (ctx, args) => {
9+ const now = Date.now();
10+ const requestedSessionId = args.sessionId?.trim() || "";
11+ const allowRequestedSessionId = requestedSessionId?.startsWith("mcp_") ?? false;
12+
13+ if (requestedSessionId) {
14+ const sessionId = requestedSessionId;
15+ const existing = await ctx.db
16+ .query("anonymousSessions")
17+ .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
18+ .unique();
19+ if (existing) {
20+ const identity = await ensureAnonymousIdentity(ctx, {
21+ sessionId,
22+ workspaceId: existing.workspaceId,
23+ actorId: existing.actorId,
24+ timestamp: now,
25+ });
26+
27+ await ctx.db.patch(existing._id, {
28+ workspaceId: identity.workspaceId,
29+ accountId: identity.accountId,
30+ userId: identity.userId,
31+ lastSeenAt: now,
32+ });
33+
34+ const refreshed = await ctx.db
35+ .query("anonymousSessions")
36+ .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
37+ .unique();
38+ if (!refreshed) {
39+ throw new Error("Failed to refresh anonymous session");
40+ }
41+ return mapAnonymousContext(refreshed);
42+ }
43+ }
44+
45+ const generatedSessionId = allowRequestedSessionId
46+ ? `mcp_${crypto.randomUUID()}`
47+ : `anon_session_${crypto.randomUUID()}`;
48+ const sessionId = allowRequestedSessionId
49+ ? requestedSessionId as string
50+ : generatedSessionId;
51+ const actorId = `anon_${crypto.randomUUID()}`;
52+ const clientId = "web";
53+
54+ const identity = await ensureAnonymousIdentity(ctx, {
55+ sessionId,
56+ actorId,
57+ timestamp: now,
58+ });
59+
60+ await ctx.db.insert("anonymousSessions", {
61+ sessionId,
62+ workspaceId: identity.workspaceId,
63+ actorId,
64+ clientId,
65+ accountId: identity.accountId,
66+ userId: identity.userId,
67+ createdAt: now,
68+ lastSeenAt: now,
69+ });
70+
71+ const created = await ctx.db
72+ .query("anonymousSessions")
73+ .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
74+ .unique();
75+ if (!created) {
76+ throw new Error("Failed to create anonymous session");
77+ }
78+
79+ return mapAnonymousContext(created);
80+ },
81+});
executor/packages/convex/database-anonymous.ts+163−0
@@ -0,0 +1,163 @@
1+import type { Doc } from "./_generated/dataModel";
2+import type { MutationCtx } from "./_generated/server";
3+import { slugify } from "../core/src/identity";
4+import { ensureUniqueSlug } from "../core/src/slug";
5+
6+type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
7+type OrganizationMemberStatus = "active" | "pending" | "removed";
8+
9+async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {
10+ const baseSlug = slugify(baseName, "workspace");
11+ return await ensureUniqueSlug(baseSlug, async (candidate) => {
12+ const collision = await ctx.db
13+ .query("organizations")
14+ .withIndex("by_slug", (q) => q.eq("slug", candidate))
15+ .unique();
16+ return collision !== null;
17+ });
18+}
19+
20+async function upsertOrganizationMembership(
21+ ctx: Pick<MutationCtx, "db">,
22+ args: {
23+ organizationId: Doc<"organizations">["_id"];
24+ accountId: Doc<"accounts">["_id"];
25+ role: OrganizationRole;
26+ status: OrganizationMemberStatus;
27+ billable: boolean;
28+ now: number;
29+ },
30+) {
31+ const existing = await ctx.db
32+ .query("organizationMembers")
33+ .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
34+ .unique();
35+
36+ if (existing) {
37+ await ctx.db.patch(existing._id, {
38+ role: args.role,
39+ status: args.status,
40+ billable: args.billable,
41+ joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,
42+ updatedAt: args.now,
43+ });
44+ return;
45+ }
46+
47+ await ctx.db.insert("organizationMembers", {
48+ organizationId: args.organizationId,
49+ accountId: args.accountId,
50+ role: args.role,
51+ status: args.status,
52+ billable: args.billable,
53+ joinedAt: args.status === "active" ? args.now : undefined,
54+ createdAt: args.now,
55+ updatedAt: args.now,
56+ });
57+}
58+
59+export async function ensureAnonymousIdentity(
60+ ctx: MutationCtx,
61+ params: {
62+ sessionId: string;
63+ workspaceId?: Doc<"workspaces">["_id"];
64+ actorId: string;
65+ timestamp: number;
66+ },
67+) {
68+ const anonymousOrganizationName = "Anonymous Organization";
69+ const anonymousWorkspaceName = "Anonymous Workspace";
70+ const now = params.timestamp;
71+
72+ let account = await ctx.db
73+ .query("accounts")
74+ .withIndex("by_provider", (q) => q.eq("provider", "anonymous").eq("providerAccountId", params.actorId))
75+ .unique();
76+
77+ if (!account) {
78+ const accountId = await ctx.db.insert("accounts", {
79+ provider: "anonymous",
80+ providerAccountId: params.actorId,
81+ email: `${params.actorId}@guest.executor.local`,
82+ name: "Guest User",
83+ status: "active",
84+ createdAt: now,
85+ updatedAt: now,
86+ lastLoginAt: now,
87+ });
88+ account = await ctx.db.get(accountId);
89+ if (!account) {
90+ throw new Error("Failed to create anonymous account");
91+ }
92+ } else {
93+ await ctx.db.patch(account._id, { updatedAt: now, lastLoginAt: now });
94+ }
95+
96+ let workspace = params.workspaceId ? await ctx.db.get(params.workspaceId) : null;
97+
98+ let organizationId: Doc<"organizations">["_id"];
99+
100+ if (!workspace) {
101+ const organizationSlug = await ensureUniqueOrganizationSlug(ctx, anonymousOrganizationName);
102+ organizationId = await ctx.db.insert("organizations", {
103+ slug: organizationSlug,
104+ name: anonymousOrganizationName,
105+ status: "active",
106+ createdByAccountId: account._id,
107+ createdAt: now,
108+ updatedAt: now,
109+ });
110+
111+ const workspaceId = await ctx.db.insert("workspaces", {
112+ organizationId,
113+ slug: `anonymous-${crypto.randomUUID().slice(0, 8)}`,
114+ name: anonymousWorkspaceName,
115+ createdByAccountId: account._id,
116+ createdAt: now,
117+ updatedAt: now,
118+ });
119+ workspace = await ctx.db.get(workspaceId);
120+ if (!workspace) {
121+ throw new Error("Failed to create anonymous workspace");
122+ }
123+ } else {
124+ organizationId = workspace.organizationId;
125+ }
126+
127+ await upsertOrganizationMembership(ctx, {
128+ organizationId,
129+ accountId: account._id,
130+ role: "owner",
131+ status: "active",
132+ billable: true,
133+ now,
134+ });
135+
136+ let user = await ctx.db
137+ .query("workspaceMembers")
138+ .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
139+ .unique();
140+
141+ if (!user) {
142+ const userId = await ctx.db.insert("workspaceMembers", {
143+ workspaceId: workspace._id,
144+ accountId: account._id,
145+ role: "owner",
146+ status: "active",
147+ createdAt: now,
148+ updatedAt: now,
149+ });
150+ user = await ctx.db.get(userId);
151+ if (!user) {
152+ throw new Error("Failed to create anonymous user membership");
153+ }
154+ } else {
155+ await ctx.db.patch(user._id, { updatedAt: now });
156+ }
157+
158+ return {
159+ accountId: account._id,
160+ workspaceId: workspace._id,
161+ userId: user._id,
162+ };
163+}
executor/packages/convex/database-approvals.ts+156−0
@@ -0,0 +1,156 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+import { mapApproval } from "./database-mappers";
4+import { getApprovalDoc, getTaskDoc } from "./database-readers";
5+import { approvalStatusValidator } from "./database-validators";
6+import type { TaskStatus } from "../core/src/types";
7+
8+export const createApproval = internalMutation({
9+ args: {
10+ id: v.string(),
11+ taskId: v.string(),
12+ toolPath: v.string(),
13+ input: v.optional(v.any()),
14+ },
15+ handler: async (ctx, args) => {
16+ const existing = await getApprovalDoc(ctx, args.id);
17+ if (existing) {
18+ throw new Error(`Approval already exists: ${args.id}`);
19+ }
20+
21+ const task = await getTaskDoc(ctx, args.taskId);
22+ if (!task) {
23+ throw new Error(`Task not found for approval: ${args.taskId}`);
24+ }
25+
26+ const now = Date.now();
27+ await ctx.db.insert("approvals", {
28+ approvalId: args.id,
29+ taskId: args.taskId,
30+ workspaceId: task.workspaceId,
31+ toolPath: args.toolPath,
32+ input: args.input ?? {},
33+ status: "pending",
34+ createdAt: now,
35+ });
36+
37+ const created = await getApprovalDoc(ctx, args.id);
38+ if (!created) {
39+ throw new Error(`Failed to fetch approval ${args.id}`);
40+ }
41+ return mapApproval(created);
42+ },
43+});
44+
45+export const getApproval = internalQuery({
46+ args: { approvalId: v.string() },
47+ handler: async (ctx, args) => {
48+ const doc = await getApprovalDoc(ctx, args.approvalId);
49+ return doc ? mapApproval(doc) : null;
50+ },
51+});
52+
53+export const listApprovals = internalQuery({
54+ args: {
55+ workspaceId: v.id("workspaces"),
56+ status: v.optional(approvalStatusValidator),
57+ },
58+ handler: async (ctx, args) => {
59+ if (args.status) {
60+ const status = args.status;
61+ const docs = await ctx.db
62+ .query("approvals")
63+ .withIndex("by_workspace_status_created", (q) =>
64+ q.eq("workspaceId", args.workspaceId).eq("status", status),
65+ )
66+ .order("desc")
67+ .collect();
68+ return docs.map(mapApproval);
69+ }
70+
71+ const docs = await ctx.db
72+ .query("approvals")
73+ .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
74+ .order("desc")
75+ .take(500);
76+ return docs.map(mapApproval);
77+ },
78+});
79+
80+export const listPendingApprovals = internalQuery({
81+ args: { workspaceId: v.id("workspaces") },
82+ handler: async (ctx, args) => {
83+ const docs = await ctx.db
84+ .query("approvals")
85+ .withIndex("by_workspace_status_created", (q) =>
86+ q.eq("workspaceId", args.workspaceId).eq("status", "pending"),
87+ )
88+ .order("asc")
89+ .collect();
90+
91+ const tasks = await Promise.all(docs.map((approval) => getTaskDoc(ctx, approval.taskId)));
92+
93+ const results: Array<
94+ ReturnType<typeof mapApproval> & {
95+ task: { id: string; status: TaskStatus; runtimeId: string; timeoutMs: number; createdAt: number };
96+ }
97+ > = [];
98+ for (let i = 0; i < docs.length; i++) {
99+ const approval = docs[i]!;
100+ const task = tasks[i];
101+ if (!task) {
102+ continue;
103+ }
104+
105+ results.push({
106+ ...mapApproval(approval),
107+ task: {
108+ id: task.taskId,
109+ status: task.status,
110+ runtimeId: task.runtimeId,
111+ timeoutMs: task.timeoutMs,
112+ createdAt: task.createdAt,
113+ },
114+ });
115+ }
116+
117+ return results;
118+ },
119+});
120+
121+export const resolveApproval = internalMutation({
122+ args: {
123+ approvalId: v.string(),
124+ decision: v.union(v.literal("approved"), v.literal("denied")),
125+ reviewerId: v.optional(v.string()),
126+ reason: v.optional(v.string()),
127+ },
128+ handler: async (ctx, args) => {
129+ const doc = await getApprovalDoc(ctx, args.approvalId);
130+ if (!doc || doc.status !== "pending") {
131+ return null;
132+ }
133+
134+ const now = Date.now();
135+ await ctx.db.patch(doc._id, {
136+ status: args.decision,
137+ reason: args.reason,
138+ reviewerId: args.reviewerId,
139+ resolvedAt: now,
140+ });
141+
142+ const updated = await getApprovalDoc(ctx, args.approvalId);
143+ return updated ? mapApproval(updated) : null;
144+ },
145+});
146+
147+export const getApprovalInWorkspace = internalQuery({
148+ args: { approvalId: v.string(), workspaceId: v.id("workspaces") },
149+ handler: async (ctx, args) => {
150+ const doc = await getApprovalDoc(ctx, args.approvalId);
151+ if (!doc || doc.workspaceId !== args.workspaceId) {
152+ return null;
153+ }
154+ return mapApproval(doc);
155+ },
156+});
executor/packages/convex/database-credentials.ts+201−0
@@ -0,0 +1,201 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+import { mapCredential } from "./database-mappers";
4+import { computeBoundAuthFingerprint } from "./database-readers";
5+import {
6+ credentialProviderValidator,
7+ credentialScopeValidator,
8+} from "./database-validators";
9+import { asRecord } from "./lib/object";
10+
11+export const upsertCredential = internalMutation({
12+ args: {
13+ id: v.optional(v.string()),
14+ workspaceId: v.id("workspaces"),
15+ sourceKey: v.string(),
16+ scope: credentialScopeValidator,
17+ actorId: v.optional(v.string()),
18+ provider: v.optional(credentialProviderValidator),
19+ secretJson: v.any(),
20+ overridesJson: v.optional(v.any()),
21+ },
22+ handler: async (ctx, args) => {
23+ const now = Date.now();
24+ const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : "";
25+ const submittedSecret = asRecord(args.secretJson);
26+ const hasSubmittedSecret = Object.keys(submittedSecret).length > 0;
27+
28+ const existing = await ctx.db
29+ .query("sourceCredentials")
30+ .withIndex("by_workspace_source_scope_actor", (q) =>
31+ q
32+ .eq("workspaceId", args.workspaceId)
33+ .eq("sourceKey", args.sourceKey)
34+ .eq("scope", args.scope)
35+ .eq("actorId", actorId),
36+ )
37+ .unique();
38+
39+ let requestedId = args.id?.trim() || "";
40+ if (requestedId.startsWith("bind_")) {
41+ const binding = await ctx.db
42+ .query("sourceCredentials")
43+ .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId))
44+ .unique();
45+ if (binding && binding.workspaceId === args.workspaceId) {
46+ requestedId = binding.credentialId;
47+ }
48+ }
49+
50+ const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`;
51+
52+ const linkedRows = await ctx.db
53+ .query("sourceCredentials")
54+ .withIndex("by_workspace_credential", (q) =>
55+ q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId),
56+ )
57+ .collect();
58+ const exemplar = linkedRows[0] ?? existing ?? null;
59+
60+ const provider = args.provider ?? exemplar?.provider ?? "local-convex";
61+ const fallbackSecret = asRecord(exemplar?.secretJson);
62+ const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret;
63+ if (Object.keys(finalSecret).length === 0) {
64+ throw new Error("Credential values are required");
65+ }
66+
67+ const overridesJson = args.overridesJson === undefined
68+ ? asRecord(existing?.overridesJson)
69+ : asRecord(args.overridesJson);
70+
71+ const boundAuthFingerprint = await computeBoundAuthFingerprint(
72+ ctx,
73+ args.workspaceId,
74+ args.sourceKey,
75+ );
76+
77+ if (linkedRows.length > 0 && (hasSubmittedSecret || args.provider)) {
78+ await Promise.all(linkedRows.map(async (row) => {
79+ await ctx.db.patch(row._id, {
80+ provider,
81+ secretJson: finalSecret,
82+ updatedAt: now,
83+ });
84+ }));
85+ }
86+
87+ if (existing) {
88+ await ctx.db.patch(existing._id, {
89+ credentialId: connectionId,
90+ provider,
91+ secretJson: finalSecret,
92+ overridesJson,
93+ boundAuthFingerprint,
94+ updatedAt: now,
95+ });
96+ } else {
97+ await ctx.db.insert("sourceCredentials", {
98+ bindingId: `bind_${crypto.randomUUID()}`,
99+ credentialId: connectionId,
100+ workspaceId: args.workspaceId,
101+ sourceKey: args.sourceKey,
102+ scope: args.scope,
103+ actorId,
104+ provider,
105+ secretJson: finalSecret,
106+ overridesJson,
107+ boundAuthFingerprint,
108+ createdAt: now,
109+ updatedAt: now,
110+ });
111+ }
112+
113+ const updated = await ctx.db
114+ .query("sourceCredentials")
115+ .withIndex("by_workspace_source_scope_actor", (q) =>
116+ q
117+ .eq("workspaceId", args.workspaceId)
118+ .eq("sourceKey", args.sourceKey)
119+ .eq("scope", args.scope)
120+ .eq("actorId", actorId),
121+ )
122+ .unique();
123+
124+ if (!updated) {
125+ throw new Error("Failed to read upserted credential");
126+ }
127+
128+ return mapCredential(updated);
129+ },
130+});
131+
132+export const listCredentials = internalQuery({
133+ args: { workspaceId: v.id("workspaces") },
134+ handler: async (ctx, args) => {
135+ const docs = await ctx.db
136+ .query("sourceCredentials")
137+ .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
138+ .order("desc")
139+ .collect();
140+ return docs.map(mapCredential);
141+ },
142+});
143+
144+export const listCredentialProviders = internalQuery({
145+ args: {},
146+ handler: async () => {
147+ const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim());
148+ return [
149+ {
150+ id: workosEnabled ? "workos-vault" : "local-convex",
151+ label: workosEnabled ? "Encrypted" : "Local",
152+ description: workosEnabled
153+ ? "Secrets are stored in WorkOS Vault."
154+ : "Secrets are stored locally in Convex on this machine.",
155+ },
156+ ] as const;
157+ },
158+});
159+
160+export const resolveCredential = internalQuery({
161+ args: {
162+ workspaceId: v.id("workspaces"),
163+ sourceKey: v.string(),
164+ scope: credentialScopeValidator,
165+ actorId: v.optional(v.string()),
166+ },
167+ handler: async (ctx, args) => {
168+ if (args.scope === "actor") {
169+ const actorId = args.actorId?.trim() || "";
170+ if (!actorId) {
171+ return null;
172+ }
173+
174+ const actorDoc = await ctx.db
175+ .query("sourceCredentials")
176+ .withIndex("by_workspace_source_scope_actor", (q) =>
177+ q
178+ .eq("workspaceId", args.workspaceId)
179+ .eq("sourceKey", args.sourceKey)
180+ .eq("scope", "actor")
181+ .eq("actorId", actorId),
182+ )
183+ .unique();
184+
185+ return actorDoc ? mapCredential(actorDoc) : null;
186+ }
187+
188+ const workspaceDoc = await ctx.db
189+ .query("sourceCredentials")
190+ .withIndex("by_workspace_source_scope_actor", (q) =>
191+ q
192+ .eq("workspaceId", args.workspaceId)
193+ .eq("sourceKey", args.sourceKey)
194+ .eq("scope", "workspace")
195+ .eq("actorId", ""),
196+ )
197+ .unique();
198+
199+ return workspaceDoc ? mapCredential(workspaceDoc) : null;
200+ },
201+});
executor/packages/convex/database-mappers.ts+139−0
@@ -0,0 +1,139 @@
1+import type { Doc } from "./_generated/dataModel";
2+import { asRecord } from "./lib/object";
3+import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants";
4+
5+export function stableStringify(value: unknown): string {
6+ if (Array.isArray(value)) {
7+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
8+ }
9+ if (value && typeof value === "object") {
10+ const record = value as Record<string, unknown>;
11+ const keys = Object.keys(record).sort();
12+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
13+ }
14+ return JSON.stringify(value);
15+}
16+
17+export function normalizeSourceAuthFingerprint(value: unknown): string {
18+ const auth = asRecord(value);
19+ const type = typeof auth.type === "string" ? auth.type.trim() : "none";
20+ const mode = auth.mode === "actor" ? "actor" : "workspace";
21+ const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : "";
22+ return stableStringify({
23+ type: type || "none",
24+ mode,
25+ ...(header ? { header } : {}),
26+ });
27+}
28+
29+export function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", config: Record<string, unknown>): string {
30+ if (type === "openapi") {
31+ const spec = config.spec;
32+ if (typeof spec === "string") {
33+ return `openapi:${spec.trim()}`;
34+ }
35+ return `openapi:${stableStringify(spec)}`;
36+ }
37+ if (type === "graphql") {
38+ const endpoint = typeof config.endpoint === "string" ? config.endpoint.trim() : "";
39+ return `graphql:${endpoint}`;
40+ }
41+ const url = typeof config.url === "string" ? config.url.trim() : "";
42+ return `mcp:${url}`;
43+}
44+
45+export function mapTask(doc: Doc<"tasks">) {
46+ return {
47+ id: doc.taskId,
48+ code: doc.code,
49+ runtimeId: doc.runtimeId,
50+ status: doc.status,
51+ timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TASK_TIMEOUT_MS,
52+ metadata: asRecord(doc.metadata),
53+ workspaceId: doc.workspaceId,
54+ actorId: doc.actorId,
55+ clientId: doc.clientId,
56+ createdAt: doc.createdAt,
57+ updatedAt: doc.updatedAt,
58+ startedAt: doc.startedAt,
59+ completedAt: doc.completedAt,
60+ error: doc.error,
61+ result: doc.result,
62+ exitCode: doc.exitCode,
63+ };
64+}
65+
66+export function mapApproval(doc: Doc<"approvals">) {
67+ return {
68+ id: doc.approvalId,
69+ taskId: doc.taskId,
70+ toolPath: doc.toolPath,
71+ input: doc.input,
72+ status: doc.status,
73+ reason: doc.reason,
74+ reviewerId: doc.reviewerId,
75+ createdAt: doc.createdAt,
76+ resolvedAt: doc.resolvedAt,
77+ };
78+}
79+
80+export function mapToolCall(doc: Doc<"toolCalls">) {
81+ return {
82+ taskId: doc.taskId,
83+ callId: doc.callId,
84+ workspaceId: doc.workspaceId,
85+ toolPath: doc.toolPath,
86+ status: doc.status,
87+ approvalId: doc.approvalId,
88+ error: doc.error,
89+ createdAt: doc.createdAt,
90+ updatedAt: doc.updatedAt,
91+ completedAt: doc.completedAt,
92+ };
93+}
94+
95+export function mapPolicy(doc: Doc<"accessPolicies">) {
96+ return {
97+ id: doc.policyId,
98+ workspaceId: doc.workspaceId,
99+ actorId: doc.actorId,
100+ clientId: doc.clientId,
101+ toolPathPattern: doc.toolPathPattern,
102+ decision: doc.decision,
103+ priority: doc.priority,
104+ createdAt: doc.createdAt,
105+ updatedAt: doc.updatedAt,
106+ };
107+}
108+
109+export function mapCredential(doc: Doc<"sourceCredentials">) {
110+ return {
111+ id: doc.credentialId,
112+ bindingId: doc.bindingId,
113+ workspaceId: doc.workspaceId,
114+ sourceKey: doc.sourceKey,
115+ scope: doc.scope,
116+ actorId: doc.actorId || undefined,
117+ provider: doc.provider,
118+ secretJson: asRecord(doc.secretJson),
119+ overridesJson: asRecord(doc.overridesJson),
120+ boundAuthFingerprint: doc.boundAuthFingerprint,
121+ createdAt: doc.createdAt,
122+ updatedAt: doc.updatedAt,
123+ };
124+}
125+
126+export function mapSource(doc: Doc<"toolSources">) {
127+ return {
128+ id: doc.sourceId,
129+ workspaceId: doc.workspaceId,
130+ name: doc.name,
131+ type: doc.type,
132+ config: asRecord(doc.config),
133+ specHash: doc.specHash,
134+ authFingerprint: doc.authFingerprint,
135+ enabled: Boolean(doc.enabled),
136+ createdAt: doc.createdAt,
137+ updatedAt: doc.updatedAt,
138+ };
139+}
executor/packages/convex/database-oauth.ts+182−0
@@ -0,0 +1,182 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+
4+export const getActiveAnonymousOauthSigningKey = internalQuery({
5+ args: {},
6+ handler: async (ctx) => {
7+ const doc = await ctx.db
8+ .query("anonymousOauthSigningKeys")
9+ .withIndex("by_status", (q) => q.eq("status", "active"))
10+ .first();
11+
12+ if (!doc) {
13+ return null;
14+ }
15+
16+ return {
17+ keyId: doc.keyId,
18+ algorithm: doc.algorithm,
19+ privateKeyJwk: doc.privateKeyJwk,
20+ publicKeyJwk: doc.publicKeyJwk,
21+ createdAt: doc.createdAt,
22+ };
23+ },
24+});
25+
26+export const storeAnonymousOauthSigningKey = internalMutation({
27+ args: {
28+ keyId: v.string(),
29+ algorithm: v.string(),
30+ privateKeyJwk: v.any(),
31+ publicKeyJwk: v.any(),
32+ },
33+ handler: async (ctx, args) => {
34+ const now = Date.now();
35+
36+ const activeKeys = await ctx.db
37+ .query("anonymousOauthSigningKeys")
38+ .withIndex("by_status", (q) => q.eq("status", "active"))
39+ .collect();
40+
41+ for (const key of activeKeys) {
42+ await ctx.db.patch(key._id, { status: "rotated", rotatedAt: now });
43+ }
44+
45+ await ctx.db.insert("anonymousOauthSigningKeys", {
46+ keyId: args.keyId,
47+ algorithm: args.algorithm,
48+ privateKeyJwk: args.privateKeyJwk,
49+ publicKeyJwk: args.publicKeyJwk,
50+ status: "active",
51+ createdAt: now,
52+ });
53+ },
54+});
55+
56+export const registerAnonymousOauthClient = internalMutation({
57+ args: {
58+ clientId: v.string(),
59+ clientName: v.optional(v.string()),
60+ redirectUris: v.array(v.string()),
61+ },
62+ handler: async (ctx, args) => {
63+ const now = Date.now();
64+ await ctx.db.insert("anonymousOauthClients", {
65+ clientId: args.clientId,
66+ clientName: args.clientName,
67+ redirectUris: args.redirectUris,
68+ createdAt: now,
69+ });
70+
71+ return {
72+ client_id: args.clientId,
73+ client_name: args.clientName,
74+ redirect_uris: args.redirectUris,
75+ created_at: now,
76+ };
77+ },
78+});
79+
80+export const getAnonymousOauthClient = internalQuery({
81+ args: { clientId: v.string() },
82+ handler: async (ctx, args) => {
83+ const doc = await ctx.db
84+ .query("anonymousOauthClients")
85+ .withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
86+ .unique();
87+
88+ if (!doc) {
89+ return null;
90+ }
91+
92+ return {
93+ client_id: doc.clientId,
94+ client_name: doc.clientName,
95+ redirect_uris: doc.redirectUris,
96+ created_at: doc.createdAt,
97+ };
98+ },
99+});
100+
101+export const storeAnonymousOauthAuthorizationCode = internalMutation({
102+ args: {
103+ code: v.string(),
104+ clientId: v.string(),
105+ redirectUri: v.string(),
106+ codeChallenge: v.string(),
107+ codeChallengeMethod: v.string(),
108+ actorId: v.string(),
109+ tokenClaims: v.optional(v.any()),
110+ expiresAt: v.number(),
111+ createdAt: v.number(),
112+ },
113+ handler: async (ctx, args) => {
114+ await ctx.db.insert("anonymousOauthCodes", {
115+ code: args.code,
116+ clientId: args.clientId,
117+ redirectUri: args.redirectUri,
118+ codeChallenge: args.codeChallenge,
119+ codeChallengeMethod: args.codeChallengeMethod,
120+ actorId: args.actorId,
121+ tokenClaims: args.tokenClaims,
122+ expiresAt: args.expiresAt,
123+ createdAt: args.createdAt,
124+ });
125+ },
126+});
127+
128+export const consumeAnonymousOauthAuthorizationCode = internalMutation({
129+ args: {
130+ code: v.string(),
131+ },
132+ handler: async (ctx, args) => {
133+ const doc = await ctx.db
134+ .query("anonymousOauthCodes")
135+ .withIndex("by_code", (q) => q.eq("code", args.code))
136+ .unique();
137+
138+ if (!doc) {
139+ return null;
140+ }
141+
142+ await ctx.db.delete(doc._id);
143+
144+ return {
145+ code: doc.code,
146+ clientId: doc.clientId,
147+ redirectUri: doc.redirectUri,
148+ codeChallenge: doc.codeChallenge,
149+ codeChallengeMethod: doc.codeChallengeMethod,
150+ actorId: doc.actorId,
151+ tokenClaims: doc.tokenClaims,
152+ expiresAt: doc.expiresAt,
153+ createdAt: doc.createdAt,
154+ };
155+ },
156+});
157+
158+export const purgeExpiredAnonymousOauthAuthorizationCodes = internalMutation({
159+ args: {
160+ now: v.number(),
161+ },
162+ handler: async (ctx, args) => {
163+ const expired = await ctx.db
164+ .query("anonymousOauthCodes")
165+ .withIndex("by_expires_at", (q) => q.lt("expiresAt", args.now))
166+ .collect();
167+
168+ for (const doc of expired) {
169+ await ctx.db.delete(doc._id);
170+ }
171+
172+ return { purged: expired.length };
173+ },
174+});
175+
176+export const countAnonymousOauthAuthorizationCodes = internalQuery({
177+ args: {},
178+ handler: async (ctx) => {
179+ const docs = await ctx.db.query("anonymousOauthCodes").collect();
180+ return { count: docs.length };
181+ },
182+});
executor/packages/convex/database-policies.ts+94−0
@@ -0,0 +1,94 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+import { mapPolicy } from "./database-mappers";
4+import { policyDecisionValidator } from "./database-validators";
5+
6+export const listRuntimeTargets = internalQuery({
7+ args: {},
8+ handler: async () => {
9+ return [
10+ {
11+ id: "local-bun",
12+ label: "Local JS Runtime",
13+ description: "Runs generated code in-process using Bun",
14+ },
15+ {
16+ id: "cloudflare-worker-loader",
17+ label: "Cloudflare Worker Loader",
18+ description: "Runs generated code in a Cloudflare Worker",
19+ },
20+ ];
21+ },
22+});
23+
24+export const upsertAccessPolicy = internalMutation({
25+ args: {
26+ id: v.optional(v.string()),
27+ workspaceId: v.id("workspaces"),
28+ actorId: v.optional(v.string()),
29+ clientId: v.optional(v.string()),
30+ toolPathPattern: v.string(),
31+ decision: policyDecisionValidator,
32+ priority: v.optional(v.number()),
33+ },
34+ handler: async (ctx, args) => {
35+ const now = Date.now();
36+ const policyId = args.id ?? `policy_${crypto.randomUUID()}`;
37+ const existing = await ctx.db
38+ .query("accessPolicies")
39+ .withIndex("by_policy_id", (q) => q.eq("policyId", policyId))
40+ .unique();
41+
42+ if (existing) {
43+ await ctx.db.patch(existing._id, {
44+ workspaceId: args.workspaceId,
45+ actorId: args.actorId?.trim() || undefined,
46+ clientId: args.clientId?.trim() || undefined,
47+ toolPathPattern: args.toolPathPattern,
48+ decision: args.decision,
49+ priority: args.priority ?? 100,
50+ updatedAt: now,
51+ });
52+ } else {
53+ await ctx.db.insert("accessPolicies", {
54+ policyId,
55+ workspaceId: args.workspaceId,
56+ actorId: args.actorId?.trim() || undefined,
57+ clientId: args.clientId?.trim() || undefined,
58+ toolPathPattern: args.toolPathPattern,
59+ decision: args.decision,
60+ priority: args.priority ?? 100,
61+ createdAt: now,
62+ updatedAt: now,
63+ });
64+ }
65+
66+ const updated = await ctx.db
67+ .query("accessPolicies")
68+ .withIndex("by_policy_id", (q) => q.eq("policyId", policyId))
69+ .unique();
70+ if (!updated) {
71+ throw new Error(`Failed to read policy ${policyId}`);
72+ }
73+ return mapPolicy(updated);
74+ },
75+});
76+
77+export const listAccessPolicies = internalQuery({
78+ args: { workspaceId: v.id("workspaces") },
79+ handler: async (ctx, args) => {
80+ const docs = await ctx.db
81+ .query("accessPolicies")
82+ .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
83+ .collect();
84+
85+ return docs
86+ .sort((a, b) => {
87+ if (a.priority !== b.priority) {
88+ return b.priority - a.priority;
89+ }
90+ return a.createdAt - b.createdAt;
91+ })
92+ .map(mapPolicy);
93+ },
94+});
executor/packages/convex/database-readers.ts+77−0
@@ -0,0 +1,77 @@
1+import type { Doc } from "./_generated/dataModel";
2+import type { QueryCtx } from "./_generated/server";
3+import { normalizeSourceAuthFingerprint } from "./database-mappers";
4+import { asRecord } from "./lib/object";
5+
6+export function mapAnonymousContext(doc: Doc<"anonymousSessions">) {
7+ return {
8+ sessionId: doc.sessionId,
9+ workspaceId: doc.workspaceId,
10+ actorId: doc.actorId,
11+ clientId: doc.clientId,
12+ accountId: doc.accountId,
13+ userId: doc.userId,
14+ createdAt: doc.createdAt,
15+ lastSeenAt: doc.lastSeenAt,
16+ };
17+}
18+
19+export function mapTaskEvent(doc: Doc<"taskEvents">) {
20+ return {
21+ id: doc.sequence,
22+ taskId: doc.taskId,
23+ eventName: doc.eventName,
24+ type: doc.type,
25+ payload: doc.payload,
26+ createdAt: doc.createdAt,
27+ };
28+}
29+
30+export async function getTaskDoc(ctx: { db: QueryCtx["db"] }, taskId: string) {
31+ return await ctx.db.query("tasks").withIndex("by_task_id", (q) => q.eq("taskId", taskId)).unique();
32+}
33+
34+export async function getApprovalDoc(ctx: { db: QueryCtx["db"] }, approvalId: string) {
35+ return await ctx.db
36+ .query("approvals")
37+ .withIndex("by_approval_id", (q) => q.eq("approvalId", approvalId))
38+ .unique();
39+}
40+
41+export async function getToolCallDoc(
42+ ctx: { db: QueryCtx["db"] },
43+ taskId: string,
44+ callId: string,
45+) {
46+ return await ctx.db
47+ .query("toolCalls")
48+ .withIndex("by_task_call", (q) => q.eq("taskId", taskId).eq("callId", callId))
49+ .unique();
50+}
51+
52+export async function computeBoundAuthFingerprint(
53+ ctx: Pick<QueryCtx, "db">,
54+ workspaceId: Doc<"workspaces">["_id"],
55+ sourceKey: string,
56+): Promise<string> {
57+ const prefix = "source:";
58+ if (!sourceKey.startsWith(prefix)) {
59+ return normalizeSourceAuthFingerprint({ type: "none" });
60+ }
61+
62+ const sourceId = sourceKey.slice(prefix.length).trim();
63+ if (!sourceId) {
64+ return normalizeSourceAuthFingerprint({ type: "none" });
65+ }
66+
67+ const source = await ctx.db
68+ .query("toolSources")
69+ .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
70+ .unique();
71+
72+ if (!source || source.workspaceId !== workspaceId) {
73+ return normalizeSourceAuthFingerprint({ type: "none" });
74+ }
75+
76+ return normalizeSourceAuthFingerprint(asRecord(source.config).auth);
77+}
executor/packages/convex/database-task-events.ts+61−0
@@ -0,0 +1,61 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+import { getTaskDoc, mapTaskEvent } from "./database-readers";
4+import { asRecord } from "./lib/object";
5+
6+export const createTaskEvent = internalMutation({
7+ args: {
8+ taskId: v.string(),
9+ eventName: v.string(),
10+ type: v.string(),
11+ payload: v.any(),
12+ },
13+ handler: async (ctx, args) => {
14+ const task = await getTaskDoc(ctx, args.taskId);
15+ if (!task) {
16+ throw new Error(`Task not found for event: ${args.taskId}`);
17+ }
18+
19+ const latest = await ctx.db
20+ .query("taskEvents")
21+ .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId))
22+ .order("desc")
23+ .first();
24+
25+ const sequence = latest ? latest.sequence + 1 : 1;
26+ const createdAt = Date.now();
27+
28+ await ctx.db.insert("taskEvents", {
29+ sequence,
30+ taskId: args.taskId,
31+ eventName: args.eventName,
32+ type: args.type,
33+ payload: asRecord(args.payload),
34+ createdAt,
35+ });
36+
37+ const created = await ctx.db
38+ .query("taskEvents")
39+ .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId).eq("sequence", sequence))
40+ .unique();
41+
42+ if (!created) {
43+ throw new Error("Failed to read inserted task event");
44+ }
45+
46+ return mapTaskEvent(created);
47+ },
48+});
49+
50+export const listTaskEvents = internalQuery({
51+ args: { taskId: v.string() },
52+ handler: async (ctx, args) => {
53+ const docs = await ctx.db
54+ .query("taskEvents")
55+ .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId))
56+ .order("asc")
57+ .collect();
58+
59+ return docs.map(mapTaskEvent);
60+ },
61+});
executor/packages/convex/database-tasks.ts+145−0
@@ -0,0 +1,145 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+import { mapTask } from "./database-mappers";
4+import { getTaskDoc } from "./database-readers";
5+import { completedTaskStatusValidator } from "./database-validators";
6+import { asRecord } from "./lib/object";
7+import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants";
8+import { isTerminalTaskStatus } from "./task-status";
9+
10+export const createTask = internalMutation({
11+ args: {
12+ id: v.string(),
13+ code: v.string(),
14+ runtimeId: v.string(),
15+ timeoutMs: v.optional(v.number()),
16+ metadata: v.optional(v.any()),
17+ workspaceId: v.id("workspaces"),
18+ actorId: v.string(),
19+ clientId: v.optional(v.string()),
20+ },
21+ handler: async (ctx, args) => {
22+ const existing = await getTaskDoc(ctx, args.id);
23+ if (existing) {
24+ throw new Error(`Task already exists: ${args.id}`);
25+ }
26+
27+ const now = Date.now();
28+ await ctx.db.insert("tasks", {
29+ taskId: args.id,
30+ code: args.code,
31+ runtimeId: args.runtimeId,
32+ workspaceId: args.workspaceId,
33+ actorId: args.actorId?.trim() || undefined,
34+ clientId: args.clientId?.trim() || undefined,
35+ status: "queued",
36+ timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS,
37+ metadata: asRecord(args.metadata),
38+ createdAt: now,
39+ updatedAt: now,
40+ });
41+
42+ const created = await getTaskDoc(ctx, args.id);
43+ if (!created) {
44+ throw new Error(`Failed to fetch created task ${args.id}`);
45+ }
46+ return mapTask(created);
47+ },
48+});
49+
50+export const getTask = internalQuery({
51+ args: { taskId: v.string() },
52+ handler: async (ctx, args) => {
53+ const doc = await getTaskDoc(ctx, args.taskId);
54+ return doc ? mapTask(doc) : null;
55+ },
56+});
57+
58+export const listTasks = internalQuery({
59+ args: { workspaceId: v.id("workspaces") },
60+ handler: async (ctx, args) => {
61+ const docs = await ctx.db
62+ .query("tasks")
63+ .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
64+ .order("desc")
65+ .take(500);
66+ return docs.map(mapTask);
67+ },
68+});
69+
70+export const listQueuedTaskIds = internalQuery({
71+ args: { limit: v.optional(v.number()) },
72+ handler: async (ctx, args) => {
73+ const docs = await ctx.db
74+ .query("tasks")
75+ .withIndex("by_status_created", (q) => q.eq("status", "queued"))
76+ .order("asc")
77+ .take(args.limit ?? 20);
78+
79+ return docs.map((doc) => doc.taskId);
80+ },
81+});
82+
83+export const getTaskInWorkspace = internalQuery({
84+ args: { taskId: v.string(), workspaceId: v.id("workspaces") },
85+ handler: async (ctx, args) => {
86+ const doc = await getTaskDoc(ctx, args.taskId);
87+ if (!doc || doc.workspaceId !== args.workspaceId) {
88+ return null;
89+ }
90+ return mapTask(doc);
91+ },
92+});
93+
94+export const markTaskRunning = internalMutation({
95+ args: { taskId: v.string() },
96+ handler: async (ctx, args) => {
97+ const doc = await getTaskDoc(ctx, args.taskId);
98+ if (!doc || doc.status !== "queued") {
99+ return null;
100+ }
101+
102+ const now = Date.now();
103+ await ctx.db.patch(doc._id, {
104+ status: "running",
105+ startedAt: doc.startedAt ?? now,
106+ updatedAt: now,
107+ });
108+
109+ const updated = await getTaskDoc(ctx, args.taskId);
110+ return updated ? mapTask(updated) : null;
111+ },
112+});
113+
114+export const markTaskFinished = internalMutation({
115+ args: {
116+ taskId: v.string(),
117+ status: completedTaskStatusValidator,
118+ result: v.optional(v.any()),
119+ exitCode: v.optional(v.number()),
120+ error: v.optional(v.string()),
121+ },
122+ handler: async (ctx, args) => {
123+ const doc = await getTaskDoc(ctx, args.taskId);
124+ if (!doc) {
125+ return null;
126+ }
127+
128+ if (isTerminalTaskStatus(doc.status)) {
129+ return mapTask(doc);
130+ }
131+
132+ const now = Date.now();
133+ await ctx.db.patch(doc._id, {
134+ status: args.status,
135+ result: args.result,
136+ exitCode: args.exitCode,
137+ error: args.error,
138+ completedAt: now,
139+ updatedAt: now,
140+ });
141+
142+ const updated = await getTaskDoc(ctx, args.taskId);
143+ return updated ? mapTask(updated) : null;
144+ },
145+});
executor/packages/convex/database-tool-calls.ts+127−0
@@ -0,0 +1,127 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+import { mapToolCall } from "./database-mappers";
4+import { getToolCallDoc } from "./database-readers";
5+import { terminalToolCallStatusValidator } from "./database-validators";
6+
7+export const upsertToolCallRequested = internalMutation({
8+ args: {
9+ taskId: v.string(),
10+ callId: v.string(),
11+ workspaceId: v.id("workspaces"),
12+ toolPath: v.string(),
13+ },
14+ handler: async (ctx, args) => {
15+ const existing = await getToolCallDoc(ctx, args.taskId, args.callId);
16+ if (existing) {
17+ return mapToolCall(existing);
18+ }
19+
20+ const now = Date.now();
21+ await ctx.db.insert("toolCalls", {
22+ taskId: args.taskId,
23+ callId: args.callId,
24+ workspaceId: args.workspaceId,
25+ toolPath: args.toolPath,
26+ status: "requested",
27+ createdAt: now,
28+ updatedAt: now,
29+ });
30+
31+ const created = await getToolCallDoc(ctx, args.taskId, args.callId);
32+ if (!created) {
33+ throw new Error(`Failed to create tool call ${args.taskId}/${args.callId}`);
34+ }
35+ return mapToolCall(created);
36+ },
37+});
38+
39+export const getToolCall = internalQuery({
40+ args: {
41+ taskId: v.string(),
42+ callId: v.string(),
43+ },
44+ handler: async (ctx, args) => {
45+ const doc = await getToolCallDoc(ctx, args.taskId, args.callId);
46+ return doc ? mapToolCall(doc) : null;
47+ },
48+});
49+
50+export const setToolCallPendingApproval = internalMutation({
51+ args: {
52+ taskId: v.string(),
53+ callId: v.string(),
54+ approvalId: v.string(),
55+ },
56+ handler: async (ctx, args) => {
57+ const doc = await getToolCallDoc(ctx, args.taskId, args.callId);
58+ if (!doc) {
59+ throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`);
60+ }
61+
62+ if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") {
63+ return mapToolCall(doc);
64+ }
65+
66+ const now = Date.now();
67+ await ctx.db.patch(doc._id, {
68+ status: "pending_approval",
69+ approvalId: args.approvalId,
70+ updatedAt: now,
71+ });
72+
73+ const updated = await getToolCallDoc(ctx, args.taskId, args.callId);
74+ if (!updated) {
75+ throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`);
76+ }
77+ return mapToolCall(updated);
78+ },
79+});
80+
81+export const finishToolCall = internalMutation({
82+ args: {
83+ taskId: v.string(),
84+ callId: v.string(),
85+ status: terminalToolCallStatusValidator,
86+ error: v.optional(v.string()),
87+ },
88+ handler: async (ctx, args) => {
89+ const doc = await getToolCallDoc(ctx, args.taskId, args.callId);
90+ if (!doc) {
91+ throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`);
92+ }
93+
94+ if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") {
95+ return mapToolCall(doc);
96+ }
97+
98+ const now = Date.now();
99+ await ctx.db.patch(doc._id, {
100+ status: args.status,
101+ error: args.error,
102+ updatedAt: now,
103+ completedAt: now,
104+ });
105+
106+ const updated = await getToolCallDoc(ctx, args.taskId, args.callId);
107+ if (!updated) {
108+ throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`);
109+ }
110+ return mapToolCall(updated);
111+ },
112+});
113+
114+export const listToolCalls = internalQuery({
115+ args: {
116+ taskId: v.string(),
117+ },
118+ handler: async (ctx, args) => {
119+ const docs = await ctx.db
120+ .query("toolCalls")
121+ .withIndex("by_task_created", (q) => q.eq("taskId", args.taskId))
122+ .order("asc")
123+ .collect();
124+
125+ return docs.map(mapToolCall);
126+ },
127+});
executor/packages/convex/database-tool-sources.ts+117−0
@@ -0,0 +1,117 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+import {
4+ computeSourceSpecHash,
5+ mapSource,
6+ normalizeSourceAuthFingerprint,
7+} from "./database-mappers";
8+import { toolSourceTypeValidator } from "./database-validators";
9+import { asRecord } from "./lib/object";
10+
11+export const upsertToolSource = internalMutation({
12+ args: {
13+ id: v.optional(v.string()),
14+ workspaceId: v.id("workspaces"),
15+ name: v.string(),
16+ type: toolSourceTypeValidator,
17+ config: v.any(),
18+ enabled: v.optional(v.boolean()),
19+ },
20+ handler: async (ctx, args) => {
21+ const now = Date.now();
22+ const sourceId = args.id ?? `src_${crypto.randomUUID()}`;
23+ const config = asRecord(args.config);
24+ const specHash = computeSourceSpecHash(args.type, config);
25+ const authFingerprint = normalizeSourceAuthFingerprint(config.auth);
26+ const [existing, conflict] = await Promise.all([
27+ ctx.db
28+ .query("toolSources")
29+ .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
30+ .unique(),
31+ ctx.db
32+ .query("toolSources")
33+ .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name))
34+ .unique(),
35+ ]);
36+
37+ if (conflict && conflict.sourceId !== sourceId) {
38+ throw new Error(`Tool source name '${args.name}' already exists in workspace ${args.workspaceId}`);
39+ }
40+
41+ if (existing) {
42+ await ctx.db.patch(existing._id, {
43+ workspaceId: args.workspaceId,
44+ name: args.name,
45+ type: args.type,
46+ config,
47+ specHash,
48+ authFingerprint,
49+ enabled: args.enabled !== false,
50+ updatedAt: now,
51+ });
52+ } else {
53+ await ctx.db.insert("toolSources", {
54+ sourceId,
55+ workspaceId: args.workspaceId,
56+ name: args.name,
57+ type: args.type,
58+ config,
59+ specHash,
60+ authFingerprint,
61+ enabled: args.enabled !== false,
62+ createdAt: now,
63+ updatedAt: now,
64+ });
65+ }
66+
67+ const updated = await ctx.db
68+ .query("toolSources")
69+ .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
70+ .unique();
71+ if (!updated) {
72+ throw new Error(`Failed to read tool source ${sourceId}`);
73+ }
74+ return mapSource(updated);
75+ },
76+});
77+
78+export const listToolSources = internalQuery({
79+ args: { workspaceId: v.id("workspaces") },
80+ handler: async (ctx, args) => {
81+ const docs = await ctx.db
82+ .query("toolSources")
83+ .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId))
84+ .order("desc")
85+ .collect();
86+ return docs.map(mapSource);
87+ },
88+});
89+
90+export const deleteToolSource = internalMutation({
91+ args: { workspaceId: v.id("workspaces"), sourceId: v.string() },
92+ handler: async (ctx, args) => {
93+ const doc = await ctx.db
94+ .query("toolSources")
95+ .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId))
96+ .unique();
97+
98+ if (!doc || doc.workspaceId !== args.workspaceId) {
99+ return false;
100+ }
101+
102+ const sourceKey = `source:${args.sourceId}`;
103+ const bindings = await ctx.db
104+ .query("sourceCredentials")
105+ .withIndex("by_workspace_source_scope_actor", (q) =>
106+ q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey),
107+ )
108+ .collect();
109+
110+ for (const binding of bindings) {
111+ await ctx.db.delete(binding._id);
112+ }
113+
114+ await ctx.db.delete(doc._id);
115+ return true;
116+ },
117+});
executor/packages/convex/database-validators.ts+27−0
@@ -0,0 +1,27 @@
1+import { v } from "convex/values";
2+
3+export const completedTaskStatusValidator = v.union(
4+ v.literal("completed"),
5+ v.literal("failed"),
6+ v.literal("timed_out"),
7+ v.literal("denied"),
8+);
9+
10+export const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
11+
12+export const terminalToolCallStatusValidator = v.union(
13+ v.literal("completed"),
14+ v.literal("failed"),
15+ v.literal("denied"),
16+);
17+
18+export const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
19+
20+export const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
21+
22+export const credentialProviderValidator = v.union(
23+ v.literal("local-convex"),
24+ v.literal("workos-vault"),
25+);
26+
27+export const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
executor/packages/convex/database.ts+52−1531
@@ -1,1531 +1,52 @@
1−import { v } from "convex/values";
2−import type { Doc } from "./_generated/dataModel";
3−import { type MutationCtx, type QueryCtx } from "./_generated/server";
4−import { internalMutation, internalQuery } from "./_generated/server";
5−import { slugify } from "../core/src/identity";
6−import { ensureUniqueSlug } from "../core/src/slug";
7−import type { TaskStatus } from "../core/src/types";
8−
9−const DEFAULT_TIMEOUT_MS = 300_000;
10−type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
11−type OrganizationMemberStatus = "active" | "pending" | "removed";
12−
13−const completedTaskStatusValidator = v.union(
14− v.literal("completed"),
15− v.literal("failed"),
16− v.literal("timed_out"),
17− v.literal("denied"),
18−);
19−const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
20−const terminalToolCallStatusValidator = v.union(
21− v.literal("completed"),
22− v.literal("failed"),
23− v.literal("denied"),
24−);
25−const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
26−const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
27−const credentialProviderValidator = v.union(
28− v.literal("local-convex"),
29− v.literal("workos-vault"),
30−);
31−const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
32−
33−async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {
34− const baseSlug = slugify(baseName, "workspace");
35− return await ensureUniqueSlug(baseSlug, async (candidate) => {
36− const collision = await ctx.db
37− .query("organizations")
38− .withIndex("by_slug", (q) => q.eq("slug", candidate))
39− .unique();
40− return collision !== null;
41− });
42−}
43−
44−async function upsertOrganizationMembership(
45− ctx: Pick<MutationCtx, "db">,
46− args: {
47− organizationId: Doc<"organizations">["_id"];
48− accountId: Doc<"accounts">["_id"];
49− role: OrganizationRole;
50− status: OrganizationMemberStatus;
51− billable: boolean;
52− now: number;
53− },
54−) {
55− const existing = await ctx.db
56− .query("organizationMembers")
57− .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
58− .unique();
59−
60− if (existing) {
61− await ctx.db.patch(existing._id, {
62− role: args.role,
63− status: args.status,
64− billable: args.billable,
65− joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,
66− updatedAt: args.now,
67− });
68− return;
69− }
70−
71− await ctx.db.insert("organizationMembers", {
72− organizationId: args.organizationId,
73− accountId: args.accountId,
74− role: args.role,
75− status: args.status,
76− billable: args.billable,
77− joinedAt: args.status === "active" ? args.now : undefined,
78− createdAt: args.now,
79− updatedAt: args.now,
80− });
81−}
82−
83−// NOTE: Canonical version lives in convex/lib/utils.ts.
84−// Convex can't import from the server, so this is a local copy.
85−function asRecord(value: unknown): Record<string, unknown> {
86− if (value && typeof value === "object" && !Array.isArray(value)) {
87− return value as Record<string, unknown>;
88− }
89− return {};
90−}
91−
92−function stableStringify(value: unknown): string {
93− if (Array.isArray(value)) {
94− return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
95− }
96− if (value && typeof value === "object") {
97− const record = value as Record<string, unknown>;
98− const keys = Object.keys(record).sort();
99− return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
100− }
101− return JSON.stringify(value);
102−}
103−
104−function normalizeSourceAuthFingerprint(value: unknown): string {
105− const auth = asRecord(value);
106− const type = typeof auth.type === "string" ? auth.type.trim() : "none";
107− const mode = auth.mode === "actor" ? "actor" : "workspace";
108− const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : "";
109− return stableStringify({
110− type: type || "none",
111− mode,
112− ...(header ? { header } : {}),
113− });
114−}
115−
116−function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", config: Record<string, unknown>): string {
117− if (type === "openapi") {
118− const spec = config.spec;
119− if (typeof spec === "string") {
120− return `openapi:${spec.trim()}`;
121− }
122− return `openapi:${stableStringify(spec)}`;
123− }
124− if (type === "graphql") {
125− const endpoint = typeof config.endpoint === "string" ? config.endpoint.trim() : "";
126− return `graphql:${endpoint}`;
127− }
128− const url = typeof config.url === "string" ? config.url.trim() : "";
129− return `mcp:${url}`;
130−}
131−
132−function mapTask(doc: Doc<"tasks">) {
133− return {
134− id: doc.taskId,
135− code: doc.code,
136− runtimeId: doc.runtimeId,
137− status: doc.status,
138− timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TIMEOUT_MS,
139− metadata: asRecord(doc.metadata),
140− workspaceId: doc.workspaceId,
141− actorId: doc.actorId,
142− clientId: doc.clientId,
143− createdAt: doc.createdAt,
144− updatedAt: doc.updatedAt,
145− startedAt: doc.startedAt,
146− completedAt: doc.completedAt,
147− error: doc.error,
148− result: doc.result,
149− exitCode: doc.exitCode,
150− };
151−}
152−
153−function mapApproval(doc: Doc<"approvals">) {
154− return {
155− id: doc.approvalId,
156− taskId: doc.taskId,
157− toolPath: doc.toolPath,
158− input: doc.input,
159− status: doc.status,
160− reason: doc.reason,
161− reviewerId: doc.reviewerId,
162− createdAt: doc.createdAt,
163− resolvedAt: doc.resolvedAt,
164− };
165−}
166−
167−function mapToolCall(doc: Doc<"toolCalls">) {
168− return {
169− taskId: doc.taskId,
170− callId: doc.callId,
171− workspaceId: doc.workspaceId,
172− toolPath: doc.toolPath,
173− status: doc.status,
174− approvalId: doc.approvalId,
175− error: doc.error,
176− createdAt: doc.createdAt,
177− updatedAt: doc.updatedAt,
178− completedAt: doc.completedAt,
179− };
180−}
181−
182−function mapPolicy(doc: Doc<"accessPolicies">) {
183− return {
184− id: doc.policyId,
185− workspaceId: doc.workspaceId,
186− actorId: doc.actorId,
187− clientId: doc.clientId,
188− toolPathPattern: doc.toolPathPattern,
189− decision: doc.decision,
190− priority: doc.priority,
191− createdAt: doc.createdAt,
192− updatedAt: doc.updatedAt,
193− };
194−}
195−
196−function mapCredential(doc: Doc<"sourceCredentials">) {
197− return {
198− id: doc.credentialId,
199− bindingId: doc.bindingId,
200− workspaceId: doc.workspaceId,
201− sourceKey: doc.sourceKey,
202− scope: doc.scope,
203− actorId: doc.actorId || undefined,
204− provider: doc.provider,
205− secretJson: asRecord(doc.secretJson),
206− overridesJson: asRecord(doc.overridesJson),
207− boundAuthFingerprint: doc.boundAuthFingerprint,
208− createdAt: doc.createdAt,
209− updatedAt: doc.updatedAt,
210− };
211−}
212−
213−function mapSource(doc: Doc<"toolSources">) {
214− return {
215− id: doc.sourceId,
216− workspaceId: doc.workspaceId,
217− name: doc.name,
218− type: doc.type,
219− config: asRecord(doc.config),
220− specHash: doc.specHash,
221− authFingerprint: doc.authFingerprint,
222− enabled: Boolean(doc.enabled),
223− createdAt: doc.createdAt,
224− updatedAt: doc.updatedAt,
225− };
226−}
227−
228−async function ensureAnonymousIdentity(
229− ctx: MutationCtx,
230− params: {
231− sessionId: string;
232− workspaceId?: Doc<"workspaces">["_id"];
233− actorId: string;
234− timestamp: number;
235− },
236−) {
237− const anonymousOrganizationName = "Anonymous Organization";
238− const anonymousWorkspaceName = "Anonymous Workspace";
239− const now = params.timestamp;
240−
241− let account = await ctx.db
242− .query("accounts")
243− .withIndex("by_provider", (q) => q.eq("provider", "anonymous").eq("providerAccountId", params.actorId))
244− .unique();
245−
246− if (!account) {
247− const accountId = await ctx.db.insert("accounts", {
248− provider: "anonymous",
249− providerAccountId: params.actorId,
250− email: `${params.actorId}@guest.executor.local`,
251− name: "Guest User",
252− status: "active",
253− createdAt: now,
254− updatedAt: now,
255− lastLoginAt: now,
256− });
257− account = await ctx.db.get(accountId);
258− if (!account) {
259− throw new Error("Failed to create anonymous account");
260− }
261− } else {
262− await ctx.db.patch(account._id, { updatedAt: now, lastLoginAt: now });
263− }
264−
265− let workspace = params.workspaceId ? await ctx.db.get(params.workspaceId) : null;
266−
267− let organizationId: Doc<"organizations">["_id"];
268−
269− if (!workspace) {
270− const organizationSlug = await ensureUniqueOrganizationSlug(ctx, anonymousOrganizationName);
271− organizationId = await ctx.db.insert("organizations", {
272− slug: organizationSlug,
273− name: anonymousOrganizationName,
274− status: "active",
275− createdByAccountId: account._id,
276− createdAt: now,
277− updatedAt: now,
278− });
279−
280− const workspaceId = await ctx.db.insert("workspaces", {
281− organizationId,
282− slug: `anonymous-${crypto.randomUUID().slice(0, 8)}`,
283− name: anonymousWorkspaceName,
284− createdByAccountId: account._id,
285− createdAt: now,
286− updatedAt: now,
287− });
288− workspace = await ctx.db.get(workspaceId);
289− if (!workspace) {
290− throw new Error("Failed to create anonymous workspace");
291− }
292− } else {
293− organizationId = workspace.organizationId;
294− }
295−
296− await upsertOrganizationMembership(ctx, {
297− organizationId,
298− accountId: account._id,
299− role: "owner",
300− status: "active",
301− billable: true,
302− now,
303− });
304−
305− let user = await ctx.db
306− .query("workspaceMembers")
307− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
308− .unique();
309−
310− if (!user) {
311− const userId = await ctx.db.insert("workspaceMembers", {
312− workspaceId: workspace._id,
313− accountId: account._id,
314− role: "owner",
315− status: "active",
316− createdAt: now,
317− updatedAt: now,
318− });
319− user = await ctx.db.get(userId);
320− if (!user) {
321− throw new Error("Failed to create anonymous user membership");
322− }
323− } else {
324− await ctx.db.patch(user._id, { updatedAt: now });
325− }
326−
327− return {
328− accountId: account._id,
329− workspaceId: workspace._id,
330− userId: user._id,
331− };
332−}
333−
334−function mapAnonymousContext(doc: Doc<"anonymousSessions">) {
335− return {
336− sessionId: doc.sessionId,
337− workspaceId: doc.workspaceId,
338− actorId: doc.actorId,
339− clientId: doc.clientId,
340− accountId: doc.accountId,
341− userId: doc.userId,
342− createdAt: doc.createdAt,
343− lastSeenAt: doc.lastSeenAt,
344− };
345−}
346−
347−function mapTaskEvent(doc: Doc<"taskEvents">) {
348− return {
349− id: doc.sequence,
350− taskId: doc.taskId,
351− eventName: doc.eventName,
352− type: doc.type,
353− payload: doc.payload,
354− createdAt: doc.createdAt,
355− };
356−}
357−
358−async function getTaskDoc(ctx: { db: QueryCtx["db"] }, taskId: string) {
359− return await ctx.db.query("tasks").withIndex("by_task_id", (q) => q.eq("taskId", taskId)).unique();
360−}
361−
362−async function getApprovalDoc(ctx: { db: QueryCtx["db"] }, approvalId: string) {
363− return await ctx.db
364− .query("approvals")
365− .withIndex("by_approval_id", (q) => q.eq("approvalId", approvalId))
366− .unique();
367−}
368−
369−async function getToolCallDoc(
370− ctx: { db: QueryCtx["db"] },
371− taskId: string,
372− callId: string,
373−) {
374− return await ctx.db
375− .query("toolCalls")
376− .withIndex("by_task_call", (q) => q.eq("taskId", taskId).eq("callId", callId))
377− .unique();
378−}
379−
380−export const createTask = internalMutation({
381− args: {
382− id: v.string(),
383− code: v.string(),
384− runtimeId: v.string(),
385− timeoutMs: v.optional(v.number()),
386− metadata: v.optional(v.any()),
387− workspaceId: v.id("workspaces"),
388− actorId: v.string(),
389− clientId: v.optional(v.string()),
390− },
391− handler: async (ctx, args) => {
392− const existing = await getTaskDoc(ctx, args.id);
393− if (existing) {
394− throw new Error(`Task already exists: ${args.id}`);
395− }
396−
397− const now = Date.now();
398− await ctx.db.insert("tasks", {
399− taskId: args.id,
400− code: args.code,
401− runtimeId: args.runtimeId,
402− workspaceId: args.workspaceId,
403− actorId: args.actorId?.trim() || undefined,
404− clientId: args.clientId?.trim() || undefined,
405− status: "queued",
406− timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
407− metadata: asRecord(args.metadata),
408− createdAt: now,
409− updatedAt: now,
410− });
411−
412− const created = await getTaskDoc(ctx, args.id);
413− if (!created) {
414− throw new Error(`Failed to fetch created task ${args.id}`);
415− }
416− return mapTask(created);
417− },
418−});
419−
420−export const getTask = internalQuery({
421− args: { taskId: v.string() },
422− handler: async (ctx, args) => {
423− const doc = await getTaskDoc(ctx, args.taskId);
424− return doc ? mapTask(doc) : null;
425− },
426−});
427−
428−export const listTasks = internalQuery({
429− args: { workspaceId: v.id("workspaces") },
430− handler: async (ctx, args) => {
431− const docs = await ctx.db
432− .query("tasks")
433− .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
434− .order("desc")
435− .take(500);
436− return docs.map(mapTask);
437− },
438−});
439−
440−export const listQueuedTaskIds = internalQuery({
441− args: { limit: v.optional(v.number()) },
442− handler: async (ctx, args) => {
443− const docs = await ctx.db
444− .query("tasks")
445− .withIndex("by_status_created", (q) => q.eq("status", "queued"))
446− .order("asc")
447− .take(args.limit ?? 20);
448−
449− return docs.map((doc) => doc.taskId);
450− },
451−});
452−
453−export const listRuntimeTargets = internalQuery({
454− args: {},
455− handler: async () => {
456− return [
457− {
458− id: "local-bun",
459− label: "Local JS Runtime",
460− description: "Runs generated code in-process using Bun",
461− },
462− {
463− id: "cloudflare-worker-loader",
464− label: "Cloudflare Worker Loader",
465− description: "Runs generated code in a Cloudflare Worker",
466− }
467− ];
468− },
469−});
470−
471−export const getTaskInWorkspace = internalQuery({
472− args: { taskId: v.string(), workspaceId: v.id("workspaces") },
473− handler: async (ctx, args) => {
474− const doc = await getTaskDoc(ctx, args.taskId);
475− if (!doc || doc.workspaceId !== args.workspaceId) {
476− return null;
477− }
478− return mapTask(doc);
479− },
480−});
481−
482−export const markTaskRunning = internalMutation({
483− args: { taskId: v.string() },
484− handler: async (ctx, args) => {
485− const doc = await getTaskDoc(ctx, args.taskId);
486− if (!doc || doc.status !== "queued") {
487− return null;
488− }
489−
490− const now = Date.now();
491− await ctx.db.patch(doc._id, {
492− status: "running",
493− startedAt: doc.startedAt ?? now,
494− updatedAt: now,
495− });
496−
497− const updated = await getTaskDoc(ctx, args.taskId);
498− return updated ? mapTask(updated) : null;
499− },
500−});
501−
502−export const markTaskFinished = internalMutation({
503− args: {
504− taskId: v.string(),
505− status: completedTaskStatusValidator,
506− result: v.optional(v.any()),
507− exitCode: v.optional(v.number()),
508− error: v.optional(v.string()),
509− },
510− handler: async (ctx, args) => {
511− const doc = await getTaskDoc(ctx, args.taskId);
512− if (!doc) {
513− return null;
514− }
515−
516− if (doc.status === "completed" || doc.status === "failed" || doc.status === "timed_out" || doc.status === "denied") {
517− return mapTask(doc);
518− }
519−
520− const now = Date.now();
521− await ctx.db.patch(doc._id, {
522− status: args.status,
523− result: args.result,
524− exitCode: args.exitCode,
525− error: args.error,
526− completedAt: now,
527− updatedAt: now,
528− });
529−
530− const updated = await getTaskDoc(ctx, args.taskId);
531− return updated ? mapTask(updated) : null;
532− },
533−});
534−
535−export const createApproval = internalMutation({
536− args: {
537− id: v.string(),
538− taskId: v.string(),
539− toolPath: v.string(),
540− input: v.optional(v.any()),
541− },
542− handler: async (ctx, args) => {
543− const existing = await getApprovalDoc(ctx, args.id);
544− if (existing) {
545− throw new Error(`Approval already exists: ${args.id}`);
546− }
547−
548− const task = await getTaskDoc(ctx, args.taskId);
549− if (!task) {
550− throw new Error(`Task not found for approval: ${args.taskId}`);
551− }
552−
553− const now = Date.now();
554− await ctx.db.insert("approvals", {
555− approvalId: args.id,
556− taskId: args.taskId,
557− workspaceId: task.workspaceId,
558− toolPath: args.toolPath,
559− input: args.input ?? {},
560− status: "pending",
561− createdAt: now,
562− });
563−
564− const created = await getApprovalDoc(ctx, args.id);
565− if (!created) {
566− throw new Error(`Failed to fetch approval ${args.id}`);
567− }
568− return mapApproval(created);
569− },
570−});
571−
572−export const getApproval = internalQuery({
573− args: { approvalId: v.string() },
574− handler: async (ctx, args) => {
575− const doc = await getApprovalDoc(ctx, args.approvalId);
576− return doc ? mapApproval(doc) : null;
577− },
578−});
579−
580−export const listApprovals = internalQuery({
581− args: {
582− workspaceId: v.id("workspaces"),
583− status: v.optional(approvalStatusValidator),
584− },
585− handler: async (ctx, args) => {
586− if (args.status) {
587− const status = args.status;
588− const docs = await ctx.db
589− .query("approvals")
590− .withIndex("by_workspace_status_created", (q) =>
591− q.eq("workspaceId", args.workspaceId).eq("status", status),
592− )
593− .order("desc")
594− .collect();
595− return docs.map(mapApproval);
596− }
597−
598− const docs = await ctx.db
599− .query("approvals")
600− .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
601− .order("desc")
602− .take(500);
603− return docs.map(mapApproval);
604− },
605−});
606−
607−export const listPendingApprovals = internalQuery({
608− args: { workspaceId: v.id("workspaces") },
609− handler: async (ctx, args) => {
610− const docs = await ctx.db
611− .query("approvals")
612− .withIndex("by_workspace_status_created", (q) =>
613− q.eq("workspaceId", args.workspaceId).eq("status", "pending"),
614− )
615− .order("asc")
616− .collect();
617−
618− const tasks = await Promise.all(docs.map((approval) => getTaskDoc(ctx, approval.taskId)));
619−
620− const results: Array<
621− ReturnType<typeof mapApproval> & {
622− task: { id: string; status: TaskStatus; runtimeId: string; timeoutMs: number; createdAt: number };
623− }
624− > = [];
625− for (let i = 0; i < docs.length; i++) {
626− const approval = docs[i]!;
627− const task = tasks[i];
628− if (!task) {
629− continue;
630− }
631−
632− results.push({
633− ...mapApproval(approval),
634− task: {
635− id: task.taskId,
636− status: task.status,
637− runtimeId: task.runtimeId,
638− timeoutMs: task.timeoutMs,
639− createdAt: task.createdAt,
640− },
641− });
642− }
643−
644− return results;
645− },
646−});
647−
648−export const resolveApproval = internalMutation({
649− args: {
650− approvalId: v.string(),
651− decision: v.union(v.literal("approved"), v.literal("denied")),
652− reviewerId: v.optional(v.string()),
653− reason: v.optional(v.string()),
654− },
655− handler: async (ctx, args) => {
656− const doc = await getApprovalDoc(ctx, args.approvalId);
657− if (!doc || doc.status !== "pending") {
658− return null;
659− }
660−
661− const now = Date.now();
662− await ctx.db.patch(doc._id, {
663− status: args.decision,
664− reason: args.reason,
665− reviewerId: args.reviewerId,
666− resolvedAt: now,
667− });
668−
669− const updated = await getApprovalDoc(ctx, args.approvalId);
670− return updated ? mapApproval(updated) : null;
671− },
672−});
673−
674−export const getApprovalInWorkspace = internalQuery({
675− args: { approvalId: v.string(), workspaceId: v.id("workspaces") },
676− handler: async (ctx, args) => {
677− const doc = await getApprovalDoc(ctx, args.approvalId);
678− if (!doc || doc.workspaceId !== args.workspaceId) {
679− return null;
680− }
681− return mapApproval(doc);
682− },
683−});
684−
685−export const upsertToolCallRequested = internalMutation({
686− args: {
687− taskId: v.string(),
688− callId: v.string(),
689− workspaceId: v.id("workspaces"),
690− toolPath: v.string(),
691− },
692− handler: async (ctx, args) => {
693− const existing = await getToolCallDoc(ctx, args.taskId, args.callId);
694− if (existing) {
695− return mapToolCall(existing);
696− }
697−
698− const now = Date.now();
699− await ctx.db.insert("toolCalls", {
700− taskId: args.taskId,
701− callId: args.callId,
702− workspaceId: args.workspaceId,
703− toolPath: args.toolPath,
704− status: "requested",
705− createdAt: now,
706− updatedAt: now,
707− });
708−
709− const created = await getToolCallDoc(ctx, args.taskId, args.callId);
710− if (!created) {
711− throw new Error(`Failed to create tool call ${args.taskId}/${args.callId}`);
712− }
713− return mapToolCall(created);
714− },
715−});
716−
717−export const getToolCall = internalQuery({
718− args: {
719− taskId: v.string(),
720− callId: v.string(),
721− },
722− handler: async (ctx, args) => {
723− const doc = await getToolCallDoc(ctx, args.taskId, args.callId);
724− return doc ? mapToolCall(doc) : null;
725− },
726−});
727−
728−export const setToolCallPendingApproval = internalMutation({
729− args: {
730− taskId: v.string(),
731− callId: v.string(),
732− approvalId: v.string(),
733− },
734− handler: async (ctx, args) => {
735− const doc = await getToolCallDoc(ctx, args.taskId, args.callId);
736− if (!doc) {
737− throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`);
738− }
739−
740− if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") {
741− return mapToolCall(doc);
742− }
743−
744− const now = Date.now();
745− await ctx.db.patch(doc._id, {
746− status: "pending_approval",
747− approvalId: args.approvalId,
748− updatedAt: now,
749− });
750−
751− const updated = await getToolCallDoc(ctx, args.taskId, args.callId);
752− if (!updated) {
753− throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`);
754− }
755− return mapToolCall(updated);
756− },
757−});
758−
759−export const finishToolCall = internalMutation({
760− args: {
761− taskId: v.string(),
762− callId: v.string(),
763− status: terminalToolCallStatusValidator,
764− error: v.optional(v.string()),
765− },
766− handler: async (ctx, args) => {
767− const doc = await getToolCallDoc(ctx, args.taskId, args.callId);
768− if (!doc) {
769− throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`);
770− }
771−
772− if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") {
773− return mapToolCall(doc);
774− }
775−
776− const now = Date.now();
777− await ctx.db.patch(doc._id, {
778− status: args.status,
779− error: args.error,
780− updatedAt: now,
781− completedAt: now,
782− });
783−
784− const updated = await getToolCallDoc(ctx, args.taskId, args.callId);
785− if (!updated) {
786− throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`);
787− }
788− return mapToolCall(updated);
789− },
790−});
791−
792−export const listToolCalls = internalQuery({
793− args: {
794− taskId: v.string(),
795− },
796− handler: async (ctx, args) => {
797− const docs = await ctx.db
798− .query("toolCalls")
799− .withIndex("by_task_created", (q) => q.eq("taskId", args.taskId))
800− .order("asc")
801− .collect();
802−
803− return docs.map(mapToolCall);
804− },
805−});
806−
807−export const bootstrapAnonymousSession = internalMutation({
808− args: { sessionId: v.optional(v.string()) },
809− handler: async (ctx, args) => {
810− const now = Date.now();
811− const requestedSessionId = args.sessionId?.trim() || "";
812− const allowRequestedSessionId = requestedSessionId?.startsWith("mcp_") ?? false;
813−
814− if (requestedSessionId) {
815− const sessionId = requestedSessionId;
816− const existing = await ctx.db
817− .query("anonymousSessions")
818− .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
819− .unique();
820− if (existing) {
821− const identity = await ensureAnonymousIdentity(ctx, {
822− sessionId,
823− workspaceId: existing.workspaceId,
824− actorId: existing.actorId,
825− timestamp: now,
826− });
827−
828− await ctx.db.patch(existing._id, {
829− workspaceId: identity.workspaceId,
830− accountId: identity.accountId,
831− userId: identity.userId,
832− lastSeenAt: now,
833− });
834−
835− const refreshed = await ctx.db
836− .query("anonymousSessions")
837− .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
838− .unique();
839− if (!refreshed) {
840− throw new Error("Failed to refresh anonymous session");
841− }
842− return mapAnonymousContext(refreshed);
843− }
844− }
845−
846− const generatedSessionId = allowRequestedSessionId
847− ? `mcp_${crypto.randomUUID()}`
848− : `anon_session_${crypto.randomUUID()}`;
849− const sessionId = allowRequestedSessionId
850− ? requestedSessionId as string
851− : generatedSessionId;
852− const actorId = `anon_${crypto.randomUUID()}`;
853− const clientId = "web";
854−
855− const identity = await ensureAnonymousIdentity(ctx, {
856− sessionId,
857− actorId,
858− timestamp: now,
859− });
860−
861− await ctx.db.insert("anonymousSessions", {
862− sessionId,
863− workspaceId: identity.workspaceId,
864− actorId,
865− clientId,
866− accountId: identity.accountId,
867− userId: identity.userId,
868− createdAt: now,
869− lastSeenAt: now,
870− });
871−
872− const created = await ctx.db
873− .query("anonymousSessions")
874− .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
875− .unique();
876− if (!created) {
877− throw new Error("Failed to create anonymous session");
878− }
879−
880− return mapAnonymousContext(created);
881− },
882−});
883−
884−export const upsertAccessPolicy = internalMutation({
885− args: {
886− id: v.optional(v.string()),
887− workspaceId: v.id("workspaces"),
888− actorId: v.optional(v.string()),
889− clientId: v.optional(v.string()),
890− toolPathPattern: v.string(),
891− decision: policyDecisionValidator,
892− priority: v.optional(v.number()),
893− },
894− handler: async (ctx, args) => {
895− const now = Date.now();
896− const policyId = args.id ?? `policy_${crypto.randomUUID()}`;
897− const existing = await ctx.db
898− .query("accessPolicies")
899− .withIndex("by_policy_id", (q) => q.eq("policyId", policyId))
900− .unique();
901−
902− if (existing) {
903− await ctx.db.patch(existing._id, {
904− workspaceId: args.workspaceId,
905− actorId: args.actorId?.trim() || undefined,
906− clientId: args.clientId?.trim() || undefined,
907− toolPathPattern: args.toolPathPattern,
908− decision: args.decision,
909− priority: args.priority ?? 100,
910− updatedAt: now,
911− });
912− } else {
913− await ctx.db.insert("accessPolicies", {
914− policyId,
915− workspaceId: args.workspaceId,
916− actorId: args.actorId?.trim() || undefined,
917− clientId: args.clientId?.trim() || undefined,
918− toolPathPattern: args.toolPathPattern,
919− decision: args.decision,
920− priority: args.priority ?? 100,
921− createdAt: now,
922− updatedAt: now,
923− });
924− }
925−
926− const updated = await ctx.db
927− .query("accessPolicies")
928− .withIndex("by_policy_id", (q) => q.eq("policyId", policyId))
929− .unique();
930− if (!updated) {
931− throw new Error(`Failed to read policy ${policyId}`);
932− }
933− return mapPolicy(updated);
934− },
935−});
936−
937−export const listAccessPolicies = internalQuery({
938− args: { workspaceId: v.id("workspaces") },
939− handler: async (ctx, args) => {
940− const docs = await ctx.db
941− .query("accessPolicies")
942− .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
943− .collect();
944−
945− return docs
946− .sort((a, b) => {
947− if (a.priority !== b.priority) {
948− return b.priority - a.priority;
949− }
950− return a.createdAt - b.createdAt;
951− })
952− .map(mapPolicy);
953− },
954−});
955−
956−async function computeBoundAuthFingerprint(
957− ctx: Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">,
958− workspaceId: Doc<"workspaces">["_id"],
959− sourceKey: string,
960−): Promise<string> {
961− const prefix = "source:";
962− if (!sourceKey.startsWith(prefix)) {
963− return normalizeSourceAuthFingerprint({ type: "none" });
964− }
965−
966− const sourceId = sourceKey.slice(prefix.length).trim();
967− if (!sourceId) {
968− return normalizeSourceAuthFingerprint({ type: "none" });
969− }
970−
971− const source = await ctx.db
972− .query("toolSources")
973− .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
974− .unique();
975−
976− if (!source || source.workspaceId !== workspaceId) {
977− return normalizeSourceAuthFingerprint({ type: "none" });
978− }
979−
980− return normalizeSourceAuthFingerprint(asRecord(source.config).auth);
981−}
982−
983−export const upsertCredential = internalMutation({
984− args: {
985− id: v.optional(v.string()),
986− workspaceId: v.id("workspaces"),
987− sourceKey: v.string(),
988− scope: credentialScopeValidator,
989− actorId: v.optional(v.string()),
990− provider: v.optional(credentialProviderValidator),
991− secretJson: v.any(),
992− overridesJson: v.optional(v.any()),
993− },
994− handler: async (ctx, args) => {
995− const now = Date.now();
996− const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : "";
997− const submittedSecret = asRecord(args.secretJson);
998− const hasSubmittedSecret = Object.keys(submittedSecret).length > 0;
999−
1000− const existing = await ctx.db
1001− .query("sourceCredentials")
1002− .withIndex("by_workspace_source_scope_actor", (q) =>
1003− q
1004− .eq("workspaceId", args.workspaceId)
1005− .eq("sourceKey", args.sourceKey)
1006− .eq("scope", args.scope)
1007− .eq("actorId", actorId),
1008− )
1009− .unique();
1010−
1011− let requestedId = args.id?.trim() || "";
1012− if (requestedId.startsWith("bind_")) {
1013− const binding = await ctx.db
1014− .query("sourceCredentials")
1015− .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId))
1016− .unique();
1017− if (binding && binding.workspaceId === args.workspaceId) {
1018− requestedId = binding.credentialId;
1019− }
1020− }
1021−
1022− const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`;
1023−
1024− const linkedRows = await ctx.db
1025− .query("sourceCredentials")
1026− .withIndex("by_workspace_credential", (q) =>
1027− q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId),
1028− )
1029− .collect();
1030− const exemplar = linkedRows[0] ?? existing ?? null;
1031−
1032− const provider = args.provider ?? exemplar?.provider ?? "local-convex";
1033− const fallbackSecret = asRecord(exemplar?.secretJson);
1034− const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret;
1035− if (Object.keys(finalSecret).length === 0) {
1036− throw new Error("Credential values are required");
1037− }
1038−
1039− const overridesJson = args.overridesJson === undefined
1040− ? asRecord(existing?.overridesJson)
1041− : asRecord(args.overridesJson);
1042−
1043− const boundAuthFingerprint = await computeBoundAuthFingerprint(ctx, args.workspaceId, args.sourceKey);
1044−
1045− if (linkedRows.length > 0 && (hasSubmittedSecret || args.provider)) {
1046− await Promise.all(linkedRows.map(async (row) => {
1047− await ctx.db.patch(row._id, {
1048− provider,
1049− secretJson: finalSecret,
1050− updatedAt: now,
1051− });
1052− }));
1053− }
1054−
1055− if (existing) {
1056− await ctx.db.patch(existing._id, {
1057− credentialId: connectionId,
1058− provider,
1059− secretJson: finalSecret,
1060− overridesJson,
1061− boundAuthFingerprint,
1062− updatedAt: now,
1063− });
1064− } else {
1065− await ctx.db.insert("sourceCredentials", {
1066− bindingId: `bind_${crypto.randomUUID()}`,
1067− credentialId: connectionId,
1068− workspaceId: args.workspaceId,
1069− sourceKey: args.sourceKey,
1070− scope: args.scope,
1071− actorId,
1072− provider,
1073− secretJson: finalSecret,
1074− overridesJson,
1075− boundAuthFingerprint,
1076− createdAt: now,
1077− updatedAt: now,
1078− });
1079− }
1080−
1081− const updated = await ctx.db
1082− .query("sourceCredentials")
1083− .withIndex("by_workspace_source_scope_actor", (q) =>
1084− q
1085− .eq("workspaceId", args.workspaceId)
1086− .eq("sourceKey", args.sourceKey)
1087− .eq("scope", args.scope)
1088− .eq("actorId", actorId),
1089− )
1090− .unique();
1091−
1092− if (!updated) {
1093− throw new Error("Failed to read upserted credential");
1094− }
1095−
1096− return mapCredential(updated);
1097− },
1098−});
1099−
1100−export const listCredentials = internalQuery({
1101− args: { workspaceId: v.id("workspaces") },
1102− handler: async (ctx, args) => {
1103− const docs = await ctx.db
1104− .query("sourceCredentials")
1105− .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
1106− .order("desc")
1107− .collect();
1108− return docs.map(mapCredential);
1109− },
1110−});
1111−
1112−export const listCredentialProviders = internalQuery({
1113− args: {},
1114− handler: async () => {
1115− const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim());
1116− return [
1117− {
1118− id: workosEnabled ? "workos-vault" : "local-convex",
1119− label: workosEnabled ? "Encrypted" : "Local",
1120− description: workosEnabled
1121− ? "Secrets are stored in WorkOS Vault."
1122− : "Secrets are stored locally in Convex on this machine.",
1123− },
1124− ] as const;
1125− },
1126−});
1127−
1128−export const resolveCredential = internalQuery({
1129− args: {
1130− workspaceId: v.id("workspaces"),
1131− sourceKey: v.string(),
1132− scope: credentialScopeValidator,
1133− actorId: v.optional(v.string()),
1134− },
1135− handler: async (ctx, args) => {
1136− if (args.scope === "actor") {
1137− const actorId = args.actorId?.trim() || "";
1138− if (!actorId) {
1139− return null;
1140− }
1141−
1142− const actorDoc = await ctx.db
1143− .query("sourceCredentials")
1144− .withIndex("by_workspace_source_scope_actor", (q) =>
1145− q
1146− .eq("workspaceId", args.workspaceId)
1147− .eq("sourceKey", args.sourceKey)
1148− .eq("scope", "actor")
1149− .eq("actorId", actorId),
1150− )
1151− .unique();
1152−
1153− return actorDoc ? mapCredential(actorDoc) : null;
1154− }
1155−
1156− const workspaceDoc = await ctx.db
1157− .query("sourceCredentials")
1158− .withIndex("by_workspace_source_scope_actor", (q) =>
1159− q
1160− .eq("workspaceId", args.workspaceId)
1161− .eq("sourceKey", args.sourceKey)
1162− .eq("scope", "workspace")
1163− .eq("actorId", ""),
1164− )
1165− .unique();
1166−
1167− return workspaceDoc ? mapCredential(workspaceDoc) : null;
1168− },
1169−});
1170−
1171−export const upsertToolSource = internalMutation({
1172− args: {
1173− id: v.optional(v.string()),
1174− workspaceId: v.id("workspaces"),
1175− name: v.string(),
1176− type: toolSourceTypeValidator,
1177− config: v.any(),
1178− enabled: v.optional(v.boolean()),
1179− },
1180− handler: async (ctx, args) => {
1181− const now = Date.now();
1182− const sourceId = args.id ?? `src_${crypto.randomUUID()}`;
1183− const config = asRecord(args.config);
1184− const specHash = computeSourceSpecHash(args.type, config);
1185− const authFingerprint = normalizeSourceAuthFingerprint(config.auth);
1186− const [existing, conflict] = await Promise.all([
1187− ctx.db
1188− .query("toolSources")
1189− .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
1190− .unique(),
1191− ctx.db
1192− .query("toolSources")
1193− .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name))
1194− .unique(),
1195− ]);
1196−
1197− if (conflict && conflict.sourceId !== sourceId) {
1198− throw new Error(`Tool source name '${args.name}' already exists in workspace ${args.workspaceId}`);
1199− }
1200−
1201− if (existing) {
1202− await ctx.db.patch(existing._id, {
1203− workspaceId: args.workspaceId,
1204− name: args.name,
1205− type: args.type,
1206− config,
1207− specHash,
1208− authFingerprint,
1209− enabled: args.enabled !== false,
1210− updatedAt: now,
1211− });
1212− } else {
1213− await ctx.db.insert("toolSources", {
1214− sourceId,
1215− workspaceId: args.workspaceId,
1216− name: args.name,
1217− type: args.type,
1218− config,
1219− specHash,
1220− authFingerprint,
1221− enabled: args.enabled !== false,
1222− createdAt: now,
1223− updatedAt: now,
1224− });
1225− }
1226−
1227− const updated = await ctx.db
1228− .query("toolSources")
1229− .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
1230− .unique();
1231− if (!updated) {
1232− throw new Error(`Failed to read tool source ${sourceId}`);
1233− }
1234− return mapSource(updated);
1235− },
1236−});
1237−
1238−export const listToolSources = internalQuery({
1239− args: { workspaceId: v.id("workspaces") },
1240− handler: async (ctx, args) => {
1241− const docs = await ctx.db
1242− .query("toolSources")
1243− .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId))
1244− .order("desc")
1245− .collect();
1246− return docs.map(mapSource);
1247− },
1248−});
1249−
1250−export const deleteToolSource = internalMutation({
1251− args: { workspaceId: v.id("workspaces"), sourceId: v.string() },
1252− handler: async (ctx, args) => {
1253− const doc = await ctx.db
1254− .query("toolSources")
1255− .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId))
1256− .unique();
1257−
1258− if (!doc || doc.workspaceId !== args.workspaceId) {
1259− return false;
1260− }
1261−
1262− const sourceKey = `source:${args.sourceId}`;
1263− const bindings = await ctx.db
1264− .query("sourceCredentials")
1265− .withIndex("by_workspace_source_scope_actor", (q) =>
1266− q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey),
1267− )
1268− .collect();
1269−
1270− for (const binding of bindings) {
1271− await ctx.db.delete(binding._id);
1272− }
1273−
1274− await ctx.db.delete(doc._id);
1275− return true;
1276− },
1277−});
1278−
1279−export const createTaskEvent = internalMutation({
1280− args: {
1281− taskId: v.string(),
1282− eventName: v.string(),
1283− type: v.string(),
1284− payload: v.any(),
1285− },
1286− handler: async (ctx, args) => {
1287− const task = await getTaskDoc(ctx, args.taskId);
1288− if (!task) {
1289− throw new Error(`Task not found for event: ${args.taskId}`);
1290− }
1291−
1292− const latest = await ctx.db
1293− .query("taskEvents")
1294− .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId))
1295− .order("desc")
1296− .first();
1297−
1298− const sequence = latest ? latest.sequence + 1 : 1;
1299− const createdAt = Date.now();
1300−
1301− await ctx.db.insert("taskEvents", {
1302− sequence,
1303− taskId: args.taskId,
1304− eventName: args.eventName,
1305− type: args.type,
1306− payload: asRecord(args.payload),
1307− createdAt,
1308− });
1309−
1310− const created = await ctx.db
1311− .query("taskEvents")
1312− .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId).eq("sequence", sequence))
1313− .unique();
1314−
1315− if (!created) {
1316− throw new Error("Failed to read inserted task event");
1317− }
1318−
1319− return mapTaskEvent(created);
1320− },
1321−});
1322−
1323−export const listTaskEvents = internalQuery({
1324− args: { taskId: v.string() },
1325− handler: async (ctx, args) => {
1326− const docs = await ctx.db
1327− .query("taskEvents")
1328− .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId))
1329− .order("asc")
1330− .collect();
1331−
1332− return docs.map(mapTaskEvent);
1333− },
1334−});
1335−
1336−// ── Anonymous OAuth Signing Keys ─────────────────────────────────────────────
1337−
1338−/**
1339− * Get the active anonymous OAuth signing key pair.
1340− * Returns null if no key has been generated yet.
1341− */
1342−export const getActiveAnonymousOauthSigningKey = internalQuery({
1343− args: {},
1344− handler: async (ctx) => {
1345− const doc = await ctx.db
1346− .query("anonymousOauthSigningKeys")
1347− .withIndex("by_status", (q) => q.eq("status", "active"))
1348− .first();
1349−
1350− if (!doc) return null;
1351−
1352− return {
1353− keyId: doc.keyId,
1354− algorithm: doc.algorithm,
1355− privateKeyJwk: doc.privateKeyJwk,
1356− publicKeyJwk: doc.publicKeyJwk,
1357− createdAt: doc.createdAt,
1358− };
1359− },
1360−});
1361−
1362−/**
1363− * Store a new anonymous OAuth signing key pair, rotating any previously active key.
1364− */
1365−export const storeAnonymousOauthSigningKey = internalMutation({
1366− args: {
1367− keyId: v.string(),
1368− algorithm: v.string(),
1369− privateKeyJwk: v.any(),
1370− publicKeyJwk: v.any(),
1371− },
1372− handler: async (ctx, args) => {
1373− const now = Date.now();
1374−
1375− // Rotate any currently active keys
1376− const activeKeys = await ctx.db
1377− .query("anonymousOauthSigningKeys")
1378− .withIndex("by_status", (q) => q.eq("status", "active"))
1379− .collect();
1380−
1381− for (const key of activeKeys) {
1382− await ctx.db.patch(key._id, { status: "rotated", rotatedAt: now });
1383− }
1384−
1385− // Insert the new active key
1386− await ctx.db.insert("anonymousOauthSigningKeys", {
1387− keyId: args.keyId,
1388− algorithm: args.algorithm,
1389− privateKeyJwk: args.privateKeyJwk,
1390− publicKeyJwk: args.publicKeyJwk,
1391− status: "active",
1392− createdAt: now,
1393− });
1394− },
1395−});
1396−
1397−// ── Anonymous OAuth Client Registrations ────────────────────────────────────
1398−
1399−/**
1400− * Register a new anonymous OAuth client (RFC 7591 dynamic client registration).
1401− */
1402−export const registerAnonymousOauthClient = internalMutation({
1403− args: {
1404− clientId: v.string(),
1405− clientName: v.optional(v.string()),
1406− redirectUris: v.array(v.string()),
1407− },
1408− handler: async (ctx, args) => {
1409− const now = Date.now();
1410− await ctx.db.insert("anonymousOauthClients", {
1411− clientId: args.clientId,
1412− clientName: args.clientName,
1413− redirectUris: args.redirectUris,
1414− createdAt: now,
1415− });
1416−
1417− return {
1418− client_id: args.clientId,
1419− client_name: args.clientName,
1420− redirect_uris: args.redirectUris,
1421− created_at: now,
1422− };
1423− },
1424−});
1425−
1426−/**
1427− * Look up an anonymous OAuth client by client_id.
1428− */
1429−export const getAnonymousOauthClient = internalQuery({
1430− args: { clientId: v.string() },
1431− handler: async (ctx, args) => {
1432− const doc = await ctx.db
1433− .query("anonymousOauthClients")
1434− .withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
1435− .unique();
1436−
1437− if (!doc) return null;
1438−
1439− return {
1440− client_id: doc.clientId,
1441− client_name: doc.clientName,
1442− redirect_uris: doc.redirectUris,
1443− created_at: doc.createdAt,
1444− };
1445− },
1446−});
1447−
1448−// ── Anonymous OAuth Authorization Codes ──────────────────────────────────────
1449−
1450−export const storeAnonymousOauthAuthorizationCode = internalMutation({
1451− args: {
1452− code: v.string(),
1453− clientId: v.string(),
1454− redirectUri: v.string(),
1455− codeChallenge: v.string(),
1456− codeChallengeMethod: v.string(),
1457− actorId: v.string(),
1458− tokenClaims: v.optional(v.any()),
1459− expiresAt: v.number(),
1460− createdAt: v.number(),
1461− },
1462− handler: async (ctx, args) => {
1463− await ctx.db.insert("anonymousOauthCodes", {
1464− code: args.code,
1465− clientId: args.clientId,
1466− redirectUri: args.redirectUri,
1467− codeChallenge: args.codeChallenge,
1468− codeChallengeMethod: args.codeChallengeMethod,
1469− actorId: args.actorId,
1470− tokenClaims: args.tokenClaims,
1471− expiresAt: args.expiresAt,
1472− createdAt: args.createdAt,
1473− });
1474− },
1475−});
1476−
1477−export const consumeAnonymousOauthAuthorizationCode = internalMutation({
1478− args: {
1479− code: v.string(),
1480− },
1481− handler: async (ctx, args) => {
1482− const doc = await ctx.db
1483− .query("anonymousOauthCodes")
1484− .withIndex("by_code", (q) => q.eq("code", args.code))
1485− .unique();
1486−
1487− if (!doc) {
1488− return null;
1489− }
1490−
1491− await ctx.db.delete(doc._id);
1492−
1493− return {
1494− code: doc.code,
1495− clientId: doc.clientId,
1496− redirectUri: doc.redirectUri,
1497− codeChallenge: doc.codeChallenge,
1498− codeChallengeMethod: doc.codeChallengeMethod,
1499− actorId: doc.actorId,
1500− tokenClaims: doc.tokenClaims,
1501− expiresAt: doc.expiresAt,
1502− createdAt: doc.createdAt,
1503− };
1504− },
1505−});
1506−
1507−export const purgeExpiredAnonymousOauthAuthorizationCodes = internalMutation({
1508− args: {
1509− now: v.number(),
1510− },
1511− handler: async (ctx, args) => {
1512− const expired = await ctx.db
1513− .query("anonymousOauthCodes")
1514− .withIndex("by_expires_at", (q) => q.lt("expiresAt", args.now))
1515− .collect();
1516−
1517− for (const doc of expired) {
1518− await ctx.db.delete(doc._id);
1519− }
1520−
1521− return { purged: expired.length };
1522− },
1523−});
1524−
1525−export const countAnonymousOauthAuthorizationCodes = internalQuery({
1526− args: {},
1527− handler: async (ctx) => {
1528− const docs = await ctx.db.query("anonymousOauthCodes").collect();
1529− return { count: docs.length };
1530− },
1531−});
1+export {
2+ createTask,
3+ getTask,
4+ listTasks,
5+ listQueuedTaskIds,
6+ getTaskInWorkspace,
7+ markTaskRunning,
8+ markTaskFinished,
9+} from "./database-tasks";
10+export {
11+ createApproval,
12+ getApproval,
13+ listApprovals,
14+ listPendingApprovals,
15+ resolveApproval,
16+ getApprovalInWorkspace,
17+} from "./database-approvals";
18+export {
19+ upsertToolCallRequested,
20+ getToolCall,
21+ setToolCallPendingApproval,
22+ finishToolCall,
23+ listToolCalls,
24+} from "./database-tool-calls";
25+export { bootstrapAnonymousSession } from "./database-anonymous-session";
26+export {
27+ listRuntimeTargets,
28+ upsertAccessPolicy,
29+ listAccessPolicies,
30+} from "./database-policies";
31+export {
32+ upsertCredential,
33+ listCredentials,
34+ listCredentialProviders,
35+ resolveCredential,
36+} from "./database-credentials";
37+export {
38+ upsertToolSource,
39+ listToolSources,
40+ deleteToolSource,
41+} from "./database-tool-sources";
42+export { createTaskEvent, listTaskEvents } from "./database-task-events";
43+export {
44+ getActiveAnonymousOauthSigningKey,
45+ storeAnonymousOauthSigningKey,
46+ registerAnonymousOauthClient,
47+ getAnonymousOauthClient,
48+ storeAnonymousOauthAuthorizationCode,
49+ consumeAnonymousOauthAuthorizationCode,
50+ purgeExpiredAnonymousOauthAuthorizationCodes,
51+ countAnonymousOauthAuthorizationCodes,
52+} from "./database-oauth";
executor/packages/convex/executor.ts+12−30
@@ -7,28 +7,10 @@ import { workspaceMutation } from "../core/src/function-builders"; · 6 unmodified lines
77 import { actorIdForAccount } from "../core/src/identity";
88 import { isKnownRuntimeId } from "../core/src/runtimes/runtime-catalog";
99 import type { ApprovalRecord, TaskRecord } from "../core/src/types";
10−
11−const DEFAULT_TIMEOUT_MS = 300_000;
12−
13−async function publishTaskEvent(
14− ctx: MutationCtx,
15− input: {
16− taskId: string;
17− eventName: string;
18− type: string;
19− payload: Record<string, unknown>;
20− },
21−): Promise<void> {
22− await ctx.runMutation(internal.database.createTaskEvent, input);
23−}
24−
25−function terminalEventForStatus(status: "completed" | "failed" | "timed_out" | "denied"):
26− "task.completed" | "task.failed" | "task.timed_out" | "task.denied" {
27− if (status === "completed") return "task.completed";
28− if (status === "timed_out") return "task.timed_out";
29− if (status === "denied") return "task.denied";
30− return "task.failed";
31−}
10+import { isTerminalTaskStatus, taskTerminalEventType } from "./task-status";
11+import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants";
12+import { createTaskEvent } from "./task-events";
13+import { markTaskFinished } from "./task-finish";
3214
3315 async function createTaskRecord(
3416 ctx: MutationCtx,
@@ -57,14 +39,14 @@ async function createTaskRecord( · 22 unmodified lines
5739 id: taskId,
5840 code: args.code,
5941 runtimeId,
60− timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
42+ timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS,
6143 metadata: args.metadata,
6244 workspaceId: args.workspaceId,
6345 actorId: args.actorId,
6446 clientId: args.clientId,
6547 })) as TaskRecord;
6648
67− await publishTaskEvent(ctx, {
49+ await createTaskEvent(ctx, {
6850 taskId,
6951 eventName: "task",
7052 type: "task.created",
@@ -80,7 +62,7 @@ async function createTaskRecord( · 9 unmodified lines
8062 },
8163 });
8264
83− await publishTaskEvent(ctx, {
65+ await createTaskEvent(ctx, {
8466 taskId,
8567 eventName: "task",
8668 type: "task.queued",
@@ -127,7 +109,7 @@ async function resolveApprovalRecord( · 40 unmodified lines
127109 return null;
128110 }
129111
130− await publishTaskEvent(ctx, {
112+ await createTaskEvent(ctx, {
131113 taskId: approval.taskId,
132114 eventName: "approval",
133115 type: "approval.resolved",
@@ -241,11 +223,11 @@ export const completeRuntimeRun = internalMutation({ · 107 unmodified lines
241223 return { ok: false as const, error: `Run not found: ${args.runId}` };
242224 }
243225
244− if (task.status === "completed" || task.status === "failed" || task.status === "timed_out" || task.status === "denied") {
226+ if (isTerminalTaskStatus(task.status)) {
245227 return { ok: true as const, alreadyFinal: true as const, task };
246228 }
247229
248− const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
230+ const finished = await markTaskFinished(ctx, {
249231 taskId: args.runId,
250232 status: args.status,
251233 result: args.result,
@@ -257,10 +239,10 @@ export const completeRuntimeRun = internalMutation({ · 5 unmodified lines
257239 return { ok: false as const, error: `Failed to mark run finished: ${args.runId}` };
258240 }
259241
260− await publishTaskEvent(ctx, {
242+ await createTaskEvent(ctx, {
261243 taskId: args.runId,
262244 eventName: "task",
263− type: terminalEventForStatus(args.status),
245+ type: taskTerminalEventType(args.status),
264246 payload: {
265247 taskId: args.runId,
266248 status: finished.status,
executor/packages/convex/executor_node/events.ts+3−3
@@ -1,16 +1,16 @@
11 "use node";
22
33 import type { ActionCtx } from "../_generated/server";
4−import { internal } from "../_generated/api";
4+import { createTaskEvent, type TaskEventName } from "../task-events";
55
66 export async function publishTaskEvent(
77 ctx: ActionCtx,
88 taskId: string,
9− eventName: "task" | "approval",
9+ eventName: TaskEventName,
1010 type: string,
1111 payload: Record<string, unknown>,
1212 ): Promise<void> {
13− await ctx.runMutation(internal.database.createTaskEvent, {
13+ await createTaskEvent(ctx, {
1414 taskId,
1515 eventName,
1616 type,
executor/packages/convex/executor_node/task_runner.ts+58−66
@@ -14,8 +14,51 @@ import { runCodeWithAdapter } from "../../core/src/runtimes/runtime-core"; · 13 unmodified lines
1414 import type { TaskRecord } from "../../core/src/types";
1515 import { describeError } from "../../core/src/utils";
1616 import { publishTaskEvent } from "./events";
17+import { taskTerminalEventType } from "../task-status";
18+import { markTaskFinished } from "../task-finish";
1719 import { invokeTool } from "./tool_invocation";
1820
21+async function markTaskFailedAndPublish(
22+ ctx: ActionCtx,
23+ args: { taskId: string; error: string },
24+): Promise<void> {
25+ const failed = await markTaskFinished(ctx, {
26+ taskId: args.taskId,
27+ status: "failed",
28+ error: args.error,
29+ });
30+
31+ if (!failed) {
32+ return;
33+ }
34+
35+ await publishTaskEvent(ctx, args.taskId, "task", "task.failed", {
36+ taskId: args.taskId,
37+ status: failed.status,
38+ error: failed.error,
39+ ...(failed.completedAt ? { completedAt: failed.completedAt } : {}),
40+ });
41+}
42+
43+async function publishTerminalTaskResult(
44+ ctx: ActionCtx,
45+ args: {
46+ taskId: string;
47+ status: "completed" | "failed" | "timed_out" | "denied";
48+ finished: TaskRecord;
49+ durationMs?: number;
50+ },
51+): Promise<void> {
52+ await publishTaskEvent(ctx, args.taskId, "task", taskTerminalEventType(args.status), {
53+ taskId: args.taskId,
54+ status: args.finished.status,
55+ exitCode: args.finished.exitCode,
56+ durationMs: args.durationMs,
57+ error: args.finished.error,
58+ completedAt: args.finished.completedAt,
59+ });
60+}
61+
1962 export async function runQueuedTask(
2063 ctx: ActionCtx,
2164 args: { taskId: string },
@@ -26,36 +69,18 @@ export async function runQueuedTask( · 4 unmodified lines
2669 }
2770
2871 if (!isKnownRuntimeId(task.runtimeId)) {
29− const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {
72+ await markTaskFailedAndPublish(ctx, {
3073 taskId: args.taskId,
31− status: "failed",
3274 error: `Runtime not found: ${task.runtimeId}`,
3375 });
34−
35− if (failed) {
36− await publishTaskEvent(ctx, args.taskId, "task", "task.failed", {
37− taskId: args.taskId,
38− status: failed.status,
39− error: failed.error,
40− });
41− }
4276 return null;
4377 }
4478
4579 if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) {
46− const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {
80+ await markTaskFailedAndPublish(ctx, {
4781 taskId: args.taskId,
48− status: "failed",
4982 error: `Runtime is not configured: ${task.runtimeId}`,
5083 });
51−
52− if (failed) {
53− await publishTaskEvent(ctx, args.taskId, "task", "task.failed", {
54− taskId: args.taskId,
55− status: failed.status,
56− error: failed.error,
57− });
58− }
5984 return null;
6085 }
6186
@@ -81,24 +106,14 @@ export async function runQueuedTask( · 19 unmodified lines
81106 });
82107
83108 if (!dispatchResult.ok) {
84− const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {
109+ await markTaskFailedAndPublish(ctx, {
85110 taskId: args.taskId,
86− status: "failed",
87111 error: dispatchResult.error,
88112 });
89−
90− if (failed) {
91− await publishTaskEvent(ctx, args.taskId, "task", "task.failed", {
92− taskId: args.taskId,
93− status: failed.status,
94− error: failed.error,
95− completedAt: failed.completedAt,
96− });
97− }
98113 return null;
99114 }
100115
101− const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
116+ const finished = await markTaskFinished(ctx, {
102117 taskId: args.taskId,
103118 status: dispatchResult.status,
104119 result: dispatchResult.result,
@@ -110,22 +125,11 @@ export async function runQueuedTask( · 5 unmodified lines
110125 return null;
111126 }
112127
113− const terminalEvent =
114− dispatchResult.status === "completed"
115− ? "task.completed"
116− : dispatchResult.status === "timed_out"
117− ? "task.timed_out"
118− : dispatchResult.status === "denied"
119− ? "task.denied"
120− : "task.failed";
121−
122− await publishTaskEvent(ctx, args.taskId, "task", terminalEvent, {
128+ await publishTerminalTaskResult(ctx, {
123129 taskId: args.taskId,
124− status: finished.status,
125− exitCode: finished.exitCode,
130+ status: dispatchResult.status,
131+ finished,
126132 durationMs: dispatchResult.durationMs,
127− error: finished.error,
128− completedAt: finished.completedAt,
129133 });
130134 return null;
131135 }
@@ -146,7 +150,7 @@ export async function runQueuedTask( · 14 unmodified lines
146150 );
147151 })();
148152
149− const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
153+ const finished = await markTaskFinished(ctx, {
150154 taskId: args.taskId,
151155 status: runtimeResult.status,
152156 result: runtimeResult.result,
@@ -158,38 +162,26 @@ export async function runQueuedTask( · 5 unmodified lines
158162 return null;
159163 }
160164
161− const terminalEvent =
162− runtimeResult.status === "completed"
163− ? "task.completed"
164− : runtimeResult.status === "timed_out"
165− ? "task.timed_out"
166− : runtimeResult.status === "denied"
167− ? "task.denied"
168− : "task.failed";
169−
170− await publishTaskEvent(ctx, args.taskId, "task", terminalEvent, {
165+ await publishTerminalTaskResult(ctx, {
171166 taskId: args.taskId,
172− status: finished.status,
173− exitCode: finished.exitCode,
167+ status: runtimeResult.status,
168+ finished,
174169 durationMs: runtimeResult.durationMs,
175− error: finished.error,
176− completedAt: finished.completedAt,
177170 });
178171 } catch (error) {
179172 const message = describeError(error);
180173 const denied = message.startsWith(APPROVAL_DENIED_PREFIX);
181− const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
174+ const finished = await markTaskFinished(ctx, {
182175 taskId: args.taskId,
183176 status: denied ? "denied" : "failed",
184177 error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message,
185178 });
186179
187180 if (finished) {
188− await publishTaskEvent(ctx, args.taskId, "task", denied ? "task.denied" : "task.failed", {
181+ await publishTerminalTaskResult(ctx, {
189182 taskId: args.taskId,
190− status: finished.status,
191− error: finished.error,
192− completedAt: finished.completedAt,
183+ status: denied ? "denied" : "failed",
184+ finished,
193185 });
194186 }
195187 }
executor/packages/convex/executor_node/tool_call_credentials.ts+74−0
@@ -0,0 +1,74 @@
1+import type { ActionCtx } from "../_generated/server";
2+import { internal } from "../_generated/api";
3+import { resolveCredentialPayload } from "../../core/src/credential-providers";
4+import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants";
5+import type { CredentialScope, ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../core/src/types";
6+import { asPayload } from "../../core/src/utils";
7+
8+export async function resolveCredentialHeaders(
9+ ctx: ActionCtx,
10+ spec: ToolCredentialSpec,
11+ task: TaskRecord,
12+): Promise<ResolvedToolCredential | null> {
13+ const record = await ctx.runQuery(internal.database.resolveCredential, {
14+ workspaceId: task.workspaceId,
15+ sourceKey: spec.sourceKey,
16+ scope: spec.mode as CredentialScope,
17+ actorId: task.actorId,
18+ });
19+
20+ const source = record
21+ ? await resolveCredentialPayload(record)
22+ : spec.staticSecretJson ?? null;
23+ if (!source) {
24+ return null;
25+ }
26+
27+ const headers: Record<string, string> = {};
28+ if (spec.authType === "bearer") {
29+ const token = String((source as Record<string, unknown>).token ?? "").trim();
30+ if (token) headers.authorization = `Bearer ${token}`;
31+ } else if (spec.authType === "apiKey") {
32+ const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key");
33+ const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim();
34+ if (value) headers[headerName] = value;
35+ } else if (spec.authType === "basic") {
36+ const username = String((source as Record<string, unknown>).username ?? "");
37+ const password = String((source as Record<string, unknown>).password ?? "");
38+ if (username || password) {
39+ const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
40+ headers.authorization = `Basic ${encoded}`;
41+ }
42+ }
43+
44+ const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {});
45+ const overrideHeaders = asPayload(bindingOverrides.headers);
46+ for (const [key, value] of Object.entries(overrideHeaders)) {
47+ if (!key) continue;
48+ headers[key] = String(value);
49+ }
50+
51+ if (Object.keys(headers).length === 0) {
52+ return null;
53+ }
54+
55+ return {
56+ sourceKey: spec.sourceKey,
57+ mode: spec.mode,
58+ headers,
59+ };
60+}
61+
62+export function assertPersistedCallRunnable(persistedCall: ToolCallRecord, callId: string): void {
63+ if (persistedCall.status === "completed") {
64+ throw new Error(`Tool call ${callId} already completed; output is not retained`);
65+ }
66+
67+ if (persistedCall.status === "failed") {
68+ throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`);
69+ }
70+
71+ if (persistedCall.status === "denied") {
72+ throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`);
73+ }
74+}
executor/packages/convex/executor_node/tool_call_lifecycle.ts+90−0
@@ -0,0 +1,90 @@
1+"use node";
2+
3+import type { ActionCtx } from "../_generated/server";
4+import { internal } from "../_generated/api";
5+import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants";
6+import type { TaskRecord } from "../../core/src/types";
7+import { publishTaskEvent } from "./events";
8+
9+async function finishToolCall(
10+ ctx: ActionCtx,
11+ args: {
12+ taskId: string;
13+ callId: string;
14+ status: "completed" | "failed" | "denied";
15+ error?: string;
16+ },
17+): Promise<void> {
18+ await ctx.runMutation(internal.database.finishToolCall, args);
19+}
20+
21+export async function denyToolCall(
22+ ctx: ActionCtx,
23+ args: {
24+ task: TaskRecord;
25+ callId: string;
26+ toolPath: string;
27+ deniedMessage: string;
28+ approvalId?: string;
29+ reason?: string;
30+ },
31+): Promise<never> {
32+ await finishToolCall(ctx, {
33+ taskId: args.task.id,
34+ callId: args.callId,
35+ status: "denied",
36+ error: args.deniedMessage,
37+ });
38+ await publishTaskEvent(ctx, args.task.id, "task", "tool.call.denied", {
39+ taskId: args.task.id,
40+ callId: args.callId,
41+ toolPath: args.toolPath,
42+ ...(args.approvalId ? { approvalId: args.approvalId } : {}),
43+ ...(args.reason ? { reason: args.reason } : {}),
44+ });
45+ throw new Error(`${APPROVAL_DENIED_PREFIX}${args.deniedMessage}`);
46+}
47+
48+export async function completeToolCall(
49+ ctx: ActionCtx,
50+ args: {
51+ taskId: string;
52+ callId: string;
53+ toolPath: string;
54+ },
55+): Promise<void> {
56+ await finishToolCall(ctx, {
57+ taskId: args.taskId,
58+ callId: args.callId,
59+ status: "completed",
60+ });
61+ await publishTaskEvent(ctx, args.taskId, "task", "tool.call.completed", {
62+ taskId: args.taskId,
63+ callId: args.callId,
64+ toolPath: args.toolPath,
65+ outputRedacted: true,
66+ });
67+}
68+
69+export async function failToolCall(
70+ ctx: ActionCtx,
71+ args: {
72+ taskId: string;
73+ callId: string;
74+ toolPath: string;
75+ error: string;
76+ },
77+): Promise<void> {
78+ await finishToolCall(ctx, {
79+ taskId: args.taskId,
80+ callId: args.callId,
81+ status: "failed",
82+ error: args.error,
83+ });
84+ await publishTaskEvent(ctx, args.taskId, "task", "tool.call.failed", {
85+ taskId: args.taskId,
86+ callId: args.callId,
87+ toolPath: args.toolPath,
88+ error: args.error,
89+ });
90+}
executor/packages/convex/executor_node/tool_call_resolution.ts+133−0
@@ -0,0 +1,133 @@
1+import type { ActionCtx } from "../_generated/server";
2+import { parseGraphqlOperationPaths } from "../../core/src/graphql-operation-paths";
3+import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types";
4+import { getDecisionForContext, getToolDecision } from "./policy";
5+import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";
6+import { baseTools, getWorkspaceTools } from "./workspace_tools";
7+
8+export function getGraphqlDecision(
9+ task: TaskRecord,
10+ tool: ToolDefinition,
11+ input: unknown,
12+ workspaceTools: Map<string, ToolDefinition>,
13+ policies: AccessPolicyRecord[],
14+): { decision: PolicyDecision; effectivePaths: string[] } {
15+ const sourceName = tool._graphqlSource!;
16+ const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
17+ const queryString = typeof payload.query === "string" ? payload.query : "";
18+
19+ if (!queryString.trim()) {
20+ return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
21+ }
22+
23+ const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString);
24+ if (fieldPaths.length === 0) {
25+ return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
26+ }
27+
28+ let worstDecision: PolicyDecision = "allow";
29+
30+ for (const fieldPath of fieldPaths) {
31+ const pseudoTool = workspaceTools.get(fieldPath);
32+ const fieldDecision = pseudoTool
33+ ? getDecisionForContext(
34+ pseudoTool,
35+ {
36+ workspaceId: task.workspaceId,
37+ actorId: task.actorId,
38+ clientId: task.clientId,
39+ },
40+ policies,
41+ )
42+ : getDecisionForContext(
43+ { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" },
44+ {
45+ workspaceId: task.workspaceId,
46+ actorId: task.actorId,
47+ clientId: task.clientId,
48+ },
49+ policies,
50+ );
51+
52+ if (fieldDecision === "deny") {
53+ worstDecision = "deny";
54+ break;
55+ }
56+ if (fieldDecision === "require_approval") {
57+ worstDecision = "require_approval";
58+ }
59+ }
60+
61+ return { decision: worstDecision, effectivePaths: fieldPaths };
62+}
63+
64+function unknownToolErrorMessage(toolPath: string, availableTools: Map<string, ToolDefinition>): string {
65+ const suggestions = suggestToolPaths(toolPath, availableTools);
66+ const queryHint = toolPath
67+ .split(".")
68+ .filter(Boolean)
69+ .join(" ");
70+ const suggestionText = suggestions.length > 0
71+ ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}`
72+ : "";
73+ const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: false, depth: 2, limit: 12 });`;
74+ return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`;
75+}
76+
77+export async function ensureWorkspaceTools(
78+ ctx: ActionCtx,
79+ task: TaskRecord,
80+ workspaceTools?: Map<string, ToolDefinition>,
81+): Promise<Map<string, ToolDefinition>> {
82+ if (workspaceTools) {
83+ return workspaceTools;
84+ }
85+ const result = await getWorkspaceTools(ctx, task.workspaceId);
86+ return result.tools;
87+}
88+
89+export async function resolveToolForCall(
90+ ctx: ActionCtx,
91+ task: TaskRecord,
92+ toolPath: string,
93+): Promise<{
94+ tool: ToolDefinition;
95+ resolvedToolPath: string;
96+ workspaceTools?: Map<string, ToolDefinition>;
97+}> {
98+ let workspaceTools: Map<string, ToolDefinition> | undefined;
99+ let resolvedToolPath = toolPath;
100+ let tool = baseTools.get(toolPath);
101+ if (!tool) {
102+ workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);
103+ tool = workspaceTools.get(toolPath);
104+
105+ if (!tool) {
106+ const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools);
107+ if (aliasedPath) {
108+ resolvedToolPath = aliasedPath;
109+ tool = workspaceTools.get(aliasedPath);
110+ }
111+ }
112+ }
113+
114+ if (!tool) {
115+ const availableTools = workspaceTools ?? baseTools;
116+ const healedPath = resolveClosestToolPath(toolPath, availableTools);
117+ if (healedPath) {
118+ resolvedToolPath = healedPath;
119+ tool = availableTools.get(healedPath);
120+ }
121+ }
122+
123+ if (!tool) {
124+ const availableTools = workspaceTools ?? baseTools;
125+ throw new Error(unknownToolErrorMessage(toolPath, availableTools));
126+ }
127+
128+ return {
129+ tool,
130+ resolvedToolPath,
131+ workspaceTools,
132+ };
133+}
executor/packages/convex/executor_node/tool_invocation.ts+33−221
@@ -2,147 +2,45 @@ · 1 unmodified line
22
33 import type { ActionCtx } from "../_generated/server";
44 import { internal } from "../_generated/api";
5−import { resolveCredentialPayload } from "../../core/src/credential-providers";
6−import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants";
7−import { parseGraphqlOperationPaths } from "../../core/src/tool-sources";
5+import { APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants";
86 import type {
97 AccessPolicyRecord,
10− CredentialScope,
118 PolicyDecision,
129 ResolvedToolCredential,
1310 TaskRecord,
1411 ToolCallRecord,
1512 ToolCallRequest,
16− ToolCredentialSpec,
17− ToolDefinition,
1813 ToolRunContext,
1914 } from "../../core/src/types";
2015 import { asPayload, describeError } from "../../core/src/utils";
21−import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy";
22−import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";
23−import { baseTools, getWorkspaceTools } from "./workspace_tools";
16+import { getToolDecision, isToolAllowedForTask } from "./policy";
17+import { baseTools } from "./workspace_tools";
2418 import { publishTaskEvent } from "./events";
19+import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
20+import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";
21+import { ensureWorkspaceTools, getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
2522
2623 function createApprovalId(): string {
2724 return `approval_${crypto.randomUUID()}`;
2825 }
2926
30−async function resolveCredentialHeaders(
27+async function denyToolCallForApproval(
3128 ctx: ActionCtx,
32− spec: ToolCredentialSpec,
33− task: TaskRecord,
34−): Promise<ResolvedToolCredential | null> {
35− const record = await ctx.runQuery(internal.database.resolveCredential, {
36− workspaceId: task.workspaceId,
37− sourceKey: spec.sourceKey,
38− scope: spec.mode as CredentialScope,
39− actorId: task.actorId,
29+ args: {
30+ task: TaskRecord;
31+ callId: string;
32+ toolPath: string;
33+ approvalId: string;
34+ },
35+): Promise<never> {
36+ const deniedMessage = `${args.toolPath} (${args.approvalId})`;
37+ return await denyToolCall(ctx, {
38+ task: args.task,
39+ callId: args.callId,
40+ toolPath: args.toolPath,
41+ deniedMessage,
42+ approvalId: args.approvalId,
4043 });
41−
42− const source = record
43− ? await resolveCredentialPayload(record)
44− : spec.staticSecretJson ?? null;
45− if (!source) {
46− return null;
47− }
48−
49− const headers: Record<string, string> = {};
50− if (spec.authType === "bearer") {
51− const token = String((source as Record<string, unknown>).token ?? "").trim();
52− if (token) headers.authorization = `Bearer ${token}`;
53− } else if (spec.authType === "apiKey") {
54− const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key");
55− const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim();
56− if (value) headers[headerName] = value;
57− } else if (spec.authType === "basic") {
58− const username = String((source as Record<string, unknown>).username ?? "");
59− const password = String((source as Record<string, unknown>).password ?? "");
60− if (username || password) {
61− const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
62− headers.authorization = `Basic ${encoded}`;
63− }
64− }
65−
66− if (Object.keys(headers).length === 0) {
67− const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {});
68− const overrideHeaders = asPayload(bindingOverrides.headers);
69− if (Object.keys(overrideHeaders).length === 0) {
70− return null;
71− }
72− for (const [key, value] of Object.entries(overrideHeaders)) {
73− if (!key) continue;
74− headers[key] = String(value);
75− }
76− } else {
77− const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {});
78− const overrideHeaders = asPayload(bindingOverrides.headers);
79− for (const [key, value] of Object.entries(overrideHeaders)) {
80− if (!key) continue;
81− headers[key] = String(value);
82− }
83− }
84−
85− return {
86− sourceKey: spec.sourceKey,
87− mode: spec.mode,
88− headers,
89− };
90−}
91−
92−function getGraphqlDecision(
93− task: TaskRecord,
94− tool: ToolDefinition,
95− input: unknown,
96− workspaceTools: Map<string, ToolDefinition>,
97− policies: AccessPolicyRecord[],
98−): { decision: PolicyDecision; effectivePaths: string[] } {
99− const sourceName = tool._graphqlSource!;
100− const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
101− const queryString = typeof payload.query === "string" ? payload.query : "";
102−
103− if (!queryString.trim()) {
104− return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
105− }
106−
107− const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString);
108− if (fieldPaths.length === 0) {
109− return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
110− }
111−
112− let worstDecision: PolicyDecision = "allow";
113−
114− for (const fieldPath of fieldPaths) {
115− const pseudoTool = workspaceTools.get(fieldPath);
116− const fieldDecision = pseudoTool
117− ? getDecisionForContext(
118− pseudoTool,
119− {
120− workspaceId: task.workspaceId,
121− actorId: task.actorId,
122− clientId: task.clientId,
123− },
124− policies,
125− )
126− : getDecisionForContext(
127− { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" },
128− {
129− workspaceId: task.workspaceId,
130− actorId: task.actorId,
131− clientId: task.clientId,
132− },
133− policies,
134− );
135−
136− if (fieldDecision === "deny") {
137− worstDecision = "deny";
138− break;
139− }
140− if (fieldDecision === "require_approval") {
141− worstDecision = "require_approval";
142− }
143− }
144−
145− return { decision: worstDecision, effectivePaths: fieldPaths };
14644 }
14745
14846 export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
@@ -153,69 +51,17 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 4 unmodified lines
15351 workspaceId: task.workspaceId,
15452 toolPath,
15553 })) as ToolCallRecord;
156−
157− if (persistedCall.status === "completed") {
158− throw new Error(`Tool call ${callId} already completed; output is not retained`);
159− }
160−
161− if (persistedCall.status === "failed") {
162− throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`);
163− }
164−
165− if (persistedCall.status === "denied") {
166− throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`);
167− }
54+ assertPersistedCallRunnable(persistedCall, callId);
16855
16956 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
17057 const typedPolicies = policies as AccessPolicyRecord[];
17158
172− let workspaceTools: Map<string, ToolDefinition> | undefined;
173− let resolvedToolPath = toolPath;
174− let tool = baseTools.get(toolPath);
175− if (!tool) {
176− const result = await getWorkspaceTools(ctx, task.workspaceId);
177− workspaceTools = result.tools;
178− tool = workspaceTools.get(toolPath);
179−
180− if (!tool) {
181− const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools);
182− if (aliasedPath) {
183− resolvedToolPath = aliasedPath;
184− tool = workspaceTools.get(aliasedPath);
185− }
186− }
187− }
188−
189− if (!tool) {
190− const availableTools = workspaceTools ?? baseTools;
191− const healedPath = resolveClosestToolPath(toolPath, availableTools);
192− if (healedPath) {
193− resolvedToolPath = healedPath;
194− tool = availableTools.get(healedPath);
195− }
196− }
197−
198− if (!tool) {
199− const availableTools = workspaceTools ?? baseTools;
200− const suggestions = suggestToolPaths(toolPath, availableTools);
201− const queryHint = toolPath
202− .split(".")
203− .filter(Boolean)
204− .join(" ");
205− const suggestionText = suggestions.length > 0
206− ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}`
207− : "";
208− const discoverHint = `\nTry: const found = await tools.discover({ query: \"${queryHint}\", compact: false, depth: 2, limit: 12 });`;
209− throw new Error(`Unknown tool: ${toolPath}${suggestionText}${discoverHint}`);
210− }
59+ let { tool, resolvedToolPath, workspaceTools } = await resolveToolForCall(ctx, task, toolPath);
21160
21261 let decision: PolicyDecision;
21362 let effectiveToolPath = resolvedToolPath;
21463 if (tool._graphqlSource) {
215− if (!workspaceTools) {
216− const result = await getWorkspaceTools(ctx, task.workspaceId);
217− workspaceTools = result.tools;
218− }
64+ workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);
21965 const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);
22066 decision = result.decision;
22167 if (result.effectivePaths.length > 0) {
@@ -229,19 +75,13 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 7 unmodified lines
22975
23076 if (decision === "deny") {
23177 const deniedMessage = `${effectiveToolPath} (policy denied)`;
232− await ctx.runMutation(internal.database.finishToolCall, {
233− taskId: task.id,
234− callId,
235− status: "denied",
236− error: deniedMessage,
237− });
238− await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", {
239− taskId: task.id,
78+ await denyToolCall(ctx, {
79+ task,
24080 callId,
24181 toolPath: effectiveToolPath,
82+ deniedMessage,
24283 reason: "policy_deny",
24384 });
244− throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
24585 }
24686
24787 let credential: ResolvedToolCredential | undefined;
@@ -276,20 +116,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 28 unmodified lines
276116 }
277117
278118 if (existingApproval.status === "denied") {
279− const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`;
280− await ctx.runMutation(internal.database.finishToolCall, {
281− taskId: task.id,
282− callId,
283− status: "denied",
284− error: deniedMessage,
285− });
286− await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", {
287− taskId: task.id,
119+ await denyToolCallForApproval(ctx, {
120+ task,
288121 callId,
289122 toolPath: effectiveToolPath,
290123 approvalId: existingApproval.id,
291124 });
292− throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
293125 }
294126
295127 approvalSatisfied = existingApproval.status === "approved";
@@ -330,20 +162,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 34 unmodified lines
330162 }
331163
332164 if (approval.status === "denied") {
333− const deniedMessage = `${effectiveToolPath} (${approval.id})`;
334− await ctx.runMutation(internal.database.finishToolCall, {
335− taskId: task.id,
336− callId,
337− status: "denied",
338− error: deniedMessage,
339− });
340− await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", {
341− taskId: task.id,
165+ await denyToolCallForApproval(ctx, {
166+ task,
342167 callId,
343168 toolPath: effectiveToolPath,
344169 approvalId: approval.id,
345170 });
346− throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
347171 }
348172 }
349173
@@ -357,31 +181,19 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 7 unmodified lines
357181 isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies),
358182 };
359183 const value = await tool.run(input, context);
360− await ctx.runMutation(internal.database.finishToolCall, {
361− taskId: task.id,
362− callId,
363− status: "completed",
364− });
365− await publishTaskEvent(ctx, task.id, "task", "tool.call.completed", {
184+ await completeToolCall(ctx, {
366185 taskId: task.id,
367186 callId,
368187 toolPath: effectiveToolPath,
369− outputRedacted: true,
370188 });
371189 return value;
372190 } catch (error) {
373191 const message = describeError(error);
374− await ctx.runMutation(internal.database.finishToolCall, {
192+ await failToolCall(ctx, {
375193 taskId: task.id,
376194 callId,
377− status: "failed",
378195 error: message,
379− });
380− await publishTaskEvent(ctx, task.id, "task", "tool.call.failed", {
381− taskId: task.id,
382− callId,
383196 toolPath: effectiveToolPath,
384− error: message,
385197 });
386198 throw error;
387199 }
executor/packages/convex/executor_node/tool_source_loading.ts+11−10
@@ -7,25 +7,26 @@ import { · 6 unmodified lines
77 compileOpenApiToolSourceFromPrepared,
88 prepareOpenApiSpec,
99 type CompiledToolSourceArtifact,
10− type ExternalToolSourceConfig,
11− type GraphqlToolSourceConfig,
12− type McpToolSourceConfig,
13− type OpenApiToolSourceConfig,
14− type PreparedOpenApiSpec,
1510 } from "../../core/src/tool-sources";
11+import type {
12+ ExternalToolSourceConfig,
13+ GraphqlToolSourceConfig,
14+ McpToolSourceConfig,
15+ OpenApiToolSourceConfig,
16+ PreparedOpenApiSpec,
17+} from "../../core/src/tool-source-types";
1618 import type { ToolSourceRecord } from "../../core/src/types";
1719
1820 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
1921
2022 /** Cache version - bump when PreparedOpenApiSpec shape changes. */
21−const OPENAPI_CACHE_VERSION = "v15";
23+const TOOL_SOURCE_CACHE_VERSION = "v15";
2224
2325 export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
24− const signatureVersion = "v15";
2526 const parts = sources
2627 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
2728 .sort();
28− return `${signatureVersion}|${workspaceId}|${parts.join(",")}`;
29+ return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`;
2930 }
3031
3132 export function normalizeExternalToolSource(raw: {
@@ -124,7 +125,7 @@ async function loadCachedOpenApiSpec( · 92 unmodified lines
124125 try {
125126 const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {
126127 specUrl,
127− version: OPENAPI_CACHE_VERSION,
128+ version: TOOL_SOURCE_CACHE_VERSION,
128129 maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,
129130 });
130131
@@ -148,7 +149,7 @@ async function loadCachedOpenApiSpec( · 17 unmodified lines
148149 const storageId = await ctx.storage.store(blob);
149150 await ctx.runMutation(internal.openApiSpecCache.putEntry, {
150151 specUrl,
151− version: OPENAPI_CACHE_VERSION,
152+ version: TOOL_SOURCE_CACHE_VERSION,
152153 storageId,
153154 sizeBytes: json.length,
154155 });
executor/packages/convex/executor_node/workspace_tools.ts+26−34
@@ -8,9 +8,9 @@ import { · 7 unmodified lines
88 materializeCompiledToolSource,
99 materializeWorkspaceSnapshot,
1010 type CompiledToolSourceArtifact,
11− type ExternalToolSourceConfig,
1211 type WorkspaceToolSnapshot,
1312 } from "../../core/src/tool-sources";
13+import type { ExternalToolSourceConfig } from "../../core/src/tool-source-types";
1414 import { DEFAULT_TOOLS } from "../../core/src/tools";
1515 import type {
1616 AccessPolicyRecord,
@@ -75,6 +75,29 @@ function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<s · 58 unmodified lines
7575 return profiles;
7676 }
7777
78+function mergeToolsWithCatalog(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {
79+ const merged = new Map<string, ToolDefinition>();
80+
81+ for (const tool of baseTools.values()) {
82+ if (tool.path === "discover") continue;
83+ merged.set(tool.path, tool);
84+ }
85+
86+ for (const tool of externalTools) {
87+ if (tool.path === "discover") continue;
88+ merged.set(tool.path, tool);
89+ }
90+
91+ const catalogTools = createCatalogTools([...merged.values()]);
92+ for (const tool of catalogTools) {
93+ merged.set(tool.path, tool);
94+ }
95+
96+ const discover = createDiscoverTool([...merged.values()]);
97+ merged.set(discover.path, discover);
98+ return merged;
99+}
100+
78101 export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> {
79102 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
80103 .filter((source: { enabled: boolean }) => source.enabled);
@@ -91,22 +114,7 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 10 unmodified lines
91114 if (blob) {
92115 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
93116 const restored = materializeWorkspaceSnapshot(snapshot);
94−
95− const merged = new Map<string, ToolDefinition>();
96− for (const tool of baseTools.values()) {
97− if (tool.path === "discover") continue;
98− merged.set(tool.path, tool);
99− }
100− for (const tool of restored) {
101− if (tool.path === "discover") continue;
102− merged.set(tool.path, tool);
103− }
104− const catalogTools = createCatalogTools([...merged.values()]);
105− for (const tool of catalogTools) {
106− merged.set(tool.path, tool);
107− }
108− const discover = createDiscoverTool([...merged.values()]);
109− merged.set(discover.path, discover);
117+ const merged = mergeToolsWithCatalog(restored);
110118
111119 const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
112120
@@ -135,23 +143,7 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 22 unmodified lines
135143 .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact));
136144 const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact));
137145 warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));
138−
139− const merged = new Map<string, ToolDefinition>();
140− for (const tool of baseTools.values()) {
141− if (tool.path === "discover") continue;
142− merged.set(tool.path, tool);
143− }
144− for (const tool of externalTools) {
145− merged.set(tool.path, tool);
146− }
147−
148− const catalogTools = createCatalogTools([...merged.values()]);
149− for (const tool of catalogTools) {
150− merged.set(tool.path, tool);
151− }
152−
153− const discover = createDiscoverTool([...merged.values()]);
154− merged.set(discover.path, discover);
146+ const merged = mergeToolsWithCatalog(externalTools);
155147
156148 let dtsStorageIds: DtsStorageEntry[] = [];
157149 try {
executor/packages/convex/http/mcp_service.ts+15−13
@@ -18,37 +18,39 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 17 unmodified lines
1818 workspaceId: Id<"workspaces">;
1919 actorId: string;
2020 clientId?: string;
21− }) => {
21+ }): Promise<{ task: TaskRecord }> => {
2222 const taskInput = {
2323 ...input,
2424 scheduleAfterCreate: false,
25− } as Parameters<typeof ctx.runMutation<typeof internal.executor.createTaskInternal>>[1];
26− return (await ctx.runMutation(internal.executor.createTaskInternal, taskInput)) as { task: TaskRecord };
25+ };
26+ return await ctx.runMutation(internal.executor.createTaskInternal, taskInput);
2727 },
28− runTaskNow: async (taskId: string) => {
29− return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null;
28+ runTaskNow: async (taskId: string): Promise<null> => {
29+ return await ctx.runAction(internal.executorNode.runTask, { taskId });
3030 },
31− getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => {
31+ getTask: async (taskId: string, workspaceId?: Id<"workspaces">): Promise<TaskRecord | null> => {
3232 if (workspaceId) {
33− return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null;
33+ return await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId });
3434 }
3535 return null;
3636 },
3737 subscribe: () => {
3838 return () => {};
3939 },
40− bootstrapAnonymousContext: async (sessionId?: string) => {
41− return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext;
40+ bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => {
41+ return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId });
4242 },
43− listTools: async (toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => {
43+ listTools: async (
44+ toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
45+ ): Promise<ToolDescriptor[]> => {
4446 if (!toolContext) {
4547 return [];
4648 }
4749
48− return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[];
50+ return await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext });
4951 },
50− listPendingApprovals: async (workspaceId: Id<"workspaces">) => {
51− return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[];
52+ listPendingApprovals: async (workspaceId: Id<"workspaces">): Promise<PendingApprovalRecord[]> => {
53+ return await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId });
5254 },
5355 resolveApproval: async (input: {
5456 workspaceId: Id<"workspaces">;
executor/packages/convex/invites-workos.ts+100−0
@@ -0,0 +1,100 @@
1+import { WorkOS } from "@workos-inc/node";
2+
3+const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null;
4+
5+export const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);
6+
7+type WorkosInvitationResponse = {
8+ id: string;
9+ state: string;
10+ expires_at?: string;
11+};
12+
13+function requireWorkosClient(): WorkOS {
14+ if (!workosClient) {
15+ throw new Error("WORKOS_API_KEY is required for WorkOS invite operations");
16+ }
17+ return workosClient;
18+}
19+
20+export async function sendWorkosInvitation(args: {
21+ email: string;
22+ workosOrgId: string;
23+ inviterWorkosUserId: string;
24+ expiresInDays?: number;
25+ roleSlug?: string;
26+}): Promise<WorkosInvitationResponse> {
27+ const workos = requireWorkosClient();
28+ const invitation = await workos.userManagement.sendInvitation({
29+ email: args.email,
30+ organizationId: args.workosOrgId,
31+ inviterUserId: args.inviterWorkosUserId,
32+ expiresInDays: args.expiresInDays,
33+ roleSlug: args.roleSlug,
34+ });
35+
36+ return {
37+ id: invitation.id,

63 more lines aren’t shown here.

executor/packages/convex/invites.ts+9−97

This diff is large: its 121 lines aren’t shown here.

executor/packages/convex/lib/object.ts+6−0

This diff is large: its 7 lines aren’t shown here.

executor/packages/convex/runtimeCallbacks.ts+1−1

This diff is large: its 9 lines aren’t shown here.

executor/packages/convex/schema.ts+2−0

This diff is large: its 9 lines aren’t shown here.

executor/packages/convex/task-constants.ts+1−0

This diff is large: its 2 lines aren’t shown here.

executor/packages/convex/task-events.ts+27−0

This diff is large: its 28 lines aren’t shown here.

executor/packages/convex/task-finish.ts+21−0

This diff is large: its 22 lines aren’t shown here.

executor/packages/convex/task-status.ts+14−0

This diff is large: its 15 lines aren’t shown here.

executor/packages/core/src/anonymous-oauth-errors.ts+6−0

This diff is large: its 7 lines aren’t shown here.

executor/packages/core/src/anonymous-oauth-pkce.ts+13−0

This diff is large: its 14 lines aren’t shown here.

executor/packages/core/src/anonymous-oauth-server.ts+291−0

This diff is large: its 292 lines aren’t shown here.

executor/packages/core/src/anonymous-oauth-storage-memory.ts+61−0

This diff is large: its 62 lines aren’t shown here.

executor/packages/core/src/anonymous-oauth-types.ts+74−0

This diff is large: its 75 lines aren’t shown here.

executor/packages/core/src/anonymous-oauth.ts+11−584

This diff is large: its 596 lines aren’t shown here.

executor/packages/core/src/credential-providers.ts+0−8

This diff is large: its 15 lines aren’t shown here.

executor/packages/core/src/declaration-generation.ts+308−0

This diff is large: its 309 lines aren’t shown here.

executor/packages/core/src/graphql-field-tools.ts+289−0

This diff is large: its 290 lines aren’t shown here.

executor/packages/core/src/graphql-operation-paths.ts+77−0

This diff is large: its 78 lines aren’t shown here.

executor/packages/core/src/managed-runtime-archives.ts+96−0

This diff is large: its 97 lines aren’t shown here.

executor/packages/core/src/managed-runtime-bootstrap.ts+158−0

This diff is large: its 159 lines aren’t shown here.

executor/packages/core/src/managed-runtime-info.ts+178−0

This diff is large: its 179 lines aren’t shown here.

executor/packages/core/src/managed-runtime-installation.ts+132−0

This diff is large: its 133 lines aren’t shown here.

executor/packages/core/src/managed-runtime-process.ts+27−0

This diff is large: its 28 lines aren’t shown here.

executor/packages/core/src/managed-runtime.ts+4−563

This diff is large: its 581 lines aren’t shown here.

executor/packages/core/src/mcp-runtime.ts+51−0

This diff is large: its 52 lines aren’t shown here.

executor/packages/core/src/mcp-server-approval.ts+195−0

This diff is large: its 196 lines aren’t shown here.

executor/packages/core/src/mcp-server-contracts.ts+41−0

This diff is large: its 42 lines aren’t shown here.

executor/packages/core/src/mcp-server-formatting.ts+65−0

This diff is large: its 66 lines aren’t shown here.

executor/packages/core/src/mcp-server-utils.ts+7−0

This diff is large: its 8 lines aren’t shown here.

executor/packages/core/src/mcp-server.ts+4−313

This diff is large: its 332 lines aren’t shown here.

executor/packages/core/src/openapi-auth.ts+50−0

This diff is large: its 51 lines aren’t shown here.

executor/packages/core/src/openapi-compaction.ts+159−0

This diff is large: its 160 lines aren’t shown here.

executor/packages/core/src/openapi-prepare.ts+169−0

This diff is large: its 170 lines aren’t shown here.

executor/packages/core/src/openapi-schema-hints.ts+354−0

This diff is large: its 355 lines aren’t shown here.

executor/packages/core/src/openapi-tool-builder.ts+186−0

This diff is large: its 187 lines aren’t shown here.

executor/packages/core/src/openapi-tool-path.ts+41−0

This diff is large: its 42 lines aren’t shown here.

executor/packages/core/src/postman-collection-utils.ts+108−0

This diff is large: its 109 lines aren’t shown here.

executor/packages/core/src/postman-runtime.ts+114−0

This diff is large: its 115 lines aren’t shown here.

executor/packages/core/src/postman-utils.ts+40−0

This diff is large: its 41 lines aren’t shown here.

executor/packages/core/src/tool-discovery.ts+13−304

This diff is large: its 339 lines aren’t shown here.

executor/packages/core/src/tool-discovery/formatting.ts+59−0

This diff is large: its 60 lines aren’t shown here.

executor/packages/core/src/tool-discovery/indexing.ts+125−0

This diff is large: its 126 lines aren’t shown here.

executor/packages/core/src/tool-discovery/ranking.ts+110−0

This diff is large: its 111 lines aren’t shown here.

executor/packages/core/src/tool-discovery/types.ts+21−0

This diff is large: its 22 lines aren’t shown here.

executor/packages/core/src/tool-path-utils.ts+19−0

This diff is large: its 20 lines aren’t shown here.

executor/packages/core/src/tool-reference-analysis.ts+130−0

This diff is large: its 131 lines aren’t shown here.

executor/packages/core/src/tool-source-auth.ts+61−0

This diff is large: its 62 lines aren’t shown here.

executor/packages/core/src/tool-source-execution.ts+151−0

This diff is large: its 152 lines aren’t shown here.

executor/packages/core/src/tool-source-loaders/graphql-loader.ts+219−0

This diff is large: its 220 lines aren’t shown here.

executor/packages/core/src/tool-source-loaders/mcp-loader.ts+75−0

This diff is large: its 76 lines aren’t shown here.

executor/packages/core/src/tool-source-loaders/openapi-loader.ts+165−0

This diff is large: its 166 lines aren’t shown here.

executor/packages/core/src/tool-source-serialization.ts+208−0

This diff is large: its 209 lines aren’t shown here.

executor/packages/core/src/tool-source-types.ts+60−0

This diff is large: its 61 lines aren’t shown here.

executor/packages/core/src/tool-sources.ts+55−2530

This diff is large: its 2615 lines aren’t shown here.

executor/packages/core/src/tsgo-engine.ts+224−0

This diff is large: its 225 lines aren’t shown here.

executor/packages/core/src/typecheck-contract.ts+6−0

This diff is large: its 7 lines aren’t shown here.

executor/packages/core/src/typecheck-engine.ts+156−0

This diff is large: its 157 lines aren’t shown here.

executor/packages/core/src/typechecker-ts-utils.ts+55−0

This diff is large: its 56 lines aren’t shown here.

executor/packages/core/src/typechecker.ts+24−881

This diff is large: its 909 lines aren’t shown here.

executor/packages/runner-sandbox-host/src/auth.ts+35−0

This diff is large: its 36 lines aren’t shown here.

executor/packages/runner-sandbox-host/src/bridge.ts+157−0

This diff is large: its 158 lines aren’t shown here.

executor/packages/runner-sandbox-host/src/index.ts+15−303

This diff is large: its 350 lines aren’t shown here.

executor/packages/runner-sandbox-host/src/isolate-text-modules.d.ts+4−0

This diff is large: its 5 lines aren’t shown here.

executor/packages/runner-sandbox-host/src/request.ts+19−0

This diff is large: its 20 lines aren’t shown here.

executor/packages/runner-sandbox-host/src/sandbox.ts+82−0

This diff is large: its 83 lines aren’t shown here.

executor/packages/runner-sandbox-host/src/types.ts+66−0

This diff is large: its 67 lines aren’t shown here.