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
2 2
3Assistant-side code lives here.3Assistant-side code lives here.
4 4
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
131. User mentions the bot in Discord.
142. Bot subscribes to the thread and sends prompts to `@assistant/server`.
153. Server resolves MCP context per chat user:
16 - default: deterministic anonymous `sessionId` via executor bootstrap;
17 - linked: saved anonymous session or WorkOS bearer token.
184. `@assistant/core` runs the prompt via MCP `run_code` and returns text.
195. Bot posts the response back to the thread.
20
21## Link commands (Discord)
22
23Preferred slash commands:
24
25- `/whoami`
26- `/link-workos` (opens a private modal for workspace/token)
27- `/link-anon [session_id]`
28- `/unlink`
29
30Prompt commands:
31
32- `/ask <prompt>`
33- mention-based thread mode (mention bot once, then keep chatting)
34
35Prefix fallback (optional):
36
37- `!whoami`
38- `!link-anon [sessionId]`
39- `!unlink`
40
41Links persist in `assistant/.chat-links.json` by default. Override with `ASSISTANT_LINKS_FILE`.
42
43Bot env requirements: `DISCORD_TOKEN` (optional prefix override via `ASSISTANT_COMMAND_PREFIX`).
44
45For 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
8 "start": "bun with-env bun src/index.ts"8 "start": "bun with-env bun src/index.ts"
9 },9 },
10 "dependencies": {10 "dependencies": {
11 "@assistant/core": "workspace:*",
12 "@assistant/server": "workspace:*",11 "@assistant/server": "workspace:*",
13 "@elysiajs/eden": "latest",12 "@elysiajs/eden": "latest",
14 "@openassistant/reacord": "workspace:*",13 "discord.js": "^14.25.1"
15 "convex": "^1.31.7",
16 "discord.js": "^14.17.3",
17 "effect": "latest",
18 "react": "^19.0.0"
19 },14 },
20 "devDependencies": {15 "devDependencies": {
21 "@types/bun": "latest",16 "@types/bun": "latest"
22 "@types/react": "^19.0.0"
23 }17 }
24}18}
assistant/packages/bot/src/commands/ask.tsx+0−59
@@ -1,59 +0,0 @@
1/**
2 * /ask command handler
3 */
4
5import type { ChatInputCommandInteraction, CommandInteraction } from "discord.js";
6import type { Client } from "@assistant/server/client";
7import { unwrap } from "@assistant/server/client";
8import type { ConvexReactClient } from "convex/react";
9import { ConvexProvider } from "convex/react";
10import type { ReacordInstance } from "@openassistant/reacord";
11import { Effect, Runtime } from "effect";
12import type { Id } from "@executor/convex/_generated/dataModel";
13import { TaskMessage } from "../views/task-message";
14
15interface 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
23export 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
8import {1import {
2 ActionRowBuilder,
3 ButtonBuilder,
4 ButtonStyle,
5 ChannelType,
9 Client,6 Client,
10 GatewayIntentBits,7 GatewayIntentBits,
8 ModalBuilder,
9 Partials,
11 REST,10 REST,
12 Routes,11 Routes,
13 SlashCommandBuilder,12 SlashCommandBuilder,
13 TextInputBuilder,
14 TextInputStyle,
15 type Message,
16 type RESTPostAPIChatInputApplicationCommandsJSONBody,
14} from "discord.js";17} from "discord.js";
15import { makeReacord } from "@openassistant/reacord";18import { createClient, unwrap } from "@assistant/server/client";
16import { createClient } from "@assistant/server/client";19
17import { ConvexReactClient } from "convex/react";20type ChatIdentity = {
18import { handleAskCommand } from "./commands/ask";21 readonly platform: "discord";
22 readonly userId: string;
23};
19 24
20// ---------------------------------------------------------------------------25const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${Bun.env.ASSISTANT_PORT ?? "3002"}`;
21// Config26const DISCORD_TOKEN = Bun.env.DISCORD_TOKEN ?? Bun.env.DISCORD_BOT_TOKEN;
22// ---------------------------------------------------------------------------27const DISCORD_GUILD_ID = Bun.env.DISCORD_GUILD_ID;
28const COMMAND_PREFIX = Bun.env.ASSISTANT_COMMAND_PREFIX ?? "!";
29const 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";
23 33
24const DISCORD_TOKEN = Bun.env.DISCORD_BOT_TOKEN;
25if (!DISCORD_TOKEN) {34if (!DISCORD_TOKEN) {
26 throw new Error("DISCORD_BOT_TOKEN is required");35 throw new Error("DISCORD_TOKEN (or DISCORD_BOT_TOKEN) is required");
27}36}
37const BOT_TOKEN = DISCORD_TOKEN;
28 38
29const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${Bun.env.ASSISTANT_PORT ?? "3002"}`;39const api = createClient(SERVER_URL);
30const CONVEX_URL = Bun.env.CONVEX_URL;40const subscribedChannels = new Set<string>();
31if (!CONVEX_URL) {41
32 throw new Error("CONVEX_URL is required. Set it in your environment.");42const LINK_MODAL_ID = "assistant_link_workos_modal";
33}43const LINK_OPEN_MODAL_BUTTON_ID = "assistant_link_workos_open_modal";
44const LINK_FIELD_WORKSPACE = "workspace_id";
45const LINK_FIELD_TOKEN = "access_token";
46const LINK_FIELD_ACTOR = "actor_id";
34 47
35// ---------------------------------------------------------------------------48const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [
36// Clients49 new SlashCommandBuilder()
37// ---------------------------------------------------------------------------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];
38 77
39const client = new Client({78const bot = new Client({
40 intents: [79 intents: [
41 GatewayIntentBits.Guilds,80 GatewayIntentBits.Guilds,
42 GatewayIntentBits.GuildMessages,81 GatewayIntentBits.GuildMessages,
82 GatewayIntentBits.MessageContent,
83 GatewayIntentBits.DirectMessages,
43 ],84 ],
85 partials: [Partials.Channel],
44});86});
45 87
46const reacord = makeReacord(client, { maxInstances: 50 });88function identityFromUser(userId: string): ChatIdentity {
47const api = createClient(SERVER_URL);89 return {
48const convex = new ConvexReactClient(CONVEX_URL, {90 platform: "discord",
49 unsavedChangesWarning: false,91 userId,
50});92 };
93}
51 94
52// ---------------------------------------------------------------------------95function truncate(value: string, max = 3500): string {
53// Slash commands96 if (value.length <= max) return value;
54// ---------------------------------------------------------------------------97 return `${value.slice(0, max)}...`;
98}
55 99
56const commands = [100function chunkMessage(text: string, max = 1800): string[] {
57 new SlashCommandBuilder()101 if (text.length <= max) return [text];
58 .setName("ask")102 const chunks: string[] = [];
59 .setDescription("Ask the AI assistant to do something")103 let remaining = text;
60 .addStringOption((opt) =>104 while (remaining.length > max) {
61 opt.setName("prompt").setDescription("What do you want the assistant to do?").setRequired(true),105 const splitAt = remaining.lastIndexOf("\n", max);
62 ),106 const end = splitAt > max * 0.4 ? splitAt : max;
63 new SlashCommandBuilder()107 chunks.push(remaining.slice(0, end));
64 .setName("clear")108 remaining = remaining.slice(end).trimStart();
65 .setDescription("Push chat history out of view"),109 }
66];110 if (remaining.length > 0) chunks.push(remaining);
111 return chunks;
112}
113
114async 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
122function commandParts(text: string): readonly string[] {
123 return text.trim().split(/\s+/).filter((part) => part.length > 0);
124}
125
126function stripBotMention(text: string, botUserId: string): string {
127 return text
128 .replaceAll(`<@${botUserId}>`, "")
129 .replaceAll(`<@!${botUserId}>`, "")
130 .trim();
131}
132
133function isCommand(text: string): boolean {
134 return text.startsWith(COMMAND_PREFIX);
135}
136
137function 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
146async 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
156async 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
170async 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
182async 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
192async 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
203function 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
221function 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}
67 255
68async function registerCommands() {256function buildExecutorLink(identity: ChatIdentity): string | null {
69 const rest = new REST().setToken(DISCORD_TOKEN!);
70 try {257 try {
71 console.log("Registering slash commands...");258 const url = new URL(EXECUTOR_WEB_URL);
72 await rest.put(Routes.applicationCommands(client.user!.id), {259 url.searchParams.set("source", "discord-bot-link");
73 body: commands.map((c) => c.toJSON()),260 url.searchParams.set("discord_user", identity.userId);
74 });261 return url.toString();
75 console.log("Slash commands registered.");262 } catch {
76 } catch (error) {263 return null;
77 console.error("Failed to register slash commands:", error);
78 }264 }
79}265}
80 266
81// ---------------------------------------------------------------------------267function buildLinkWorkosPanel(identity: ChatIdentity) {
82// Command handler268 const executorLink = buildExecutorLink(identity);
83// ---------------------------------------------------------------------------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
300async function registerSlashCommands() {
301 const appId = bot.user?.id;
302 if (!appId) return;
303
304 const rest = new REST({ version: "10" }).setToken(BOT_TOKEN);
84 305
85client.on("interactionCreate", async (interaction) => {306 if (DISCORD_GUILD_ID) {
86 if (!interaction.isChatInputCommand()) return;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 }
87 314
88 switch (interaction.commandName) {315 await rest.put(
89 case "ask":316 Routes.applicationCommands(appId),
90 await handleAskCommand(interaction, { api, convex, reacord });317 { body: slashCommands },
91 break;318 );
92 case "clear":319 console.log("[assistant-bot] registered global slash commands");
93 await interaction.reply({ content: `_${"\n".repeat(50)}_` });320}
94 break;321
95 default:322async function handlePrefixCommand(message: Message<boolean>, rawText: string) {
96 await interaction.reply({ content: `Unknown command: ${interaction.commandName}` });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
367bot.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);
97 }373 }
98});374});
99 375
100// ---------------------------------------------------------------------------376bot.on("interactionCreate", async (interaction) => {
101// Start377 try {
102// ---------------------------------------------------------------------------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
472bot.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;
103 478
104client.once("clientReady", async () => {479 const rawText = message.content?.trim() ?? "";
105 console.log(`Logged in as ${client.user?.tag}`);480 if (!rawText && !message.mentions.users.has(botUserId)) return;
106 await registerCommands();481
107 console.log(`Connected to server at ${SERVER_URL}`);482 const mentioned = message.mentions.users.has(botUserId);
108 console.log(`Convex at ${CONVEX_URL}`);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 }
109});513});
110 514
111client.login(DISCORD_TOKEN);515await 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
9import { useState, useCallback } from "react";
10import {
11 Container,
12 TextDisplay,
13 Separator,
14 ActionRow,
15 Button,
16 Loading,
17 useInstance,
18} from "@openassistant/reacord";
19import { useMutation, useQuery } from "convex/react";
20import { api } from "@executor/convex/_generated/api";
21import type { Id } from "@executor/convex/_generated/dataModel";
22
23// ---------------------------------------------------------------------------
24// Component
25// ---------------------------------------------------------------------------
26
27export interface TaskMessageProps {
28 readonly agentTaskId: string;
29 readonly prompt: string;
30 readonly workspaceId: Id<"workspaces">;
31}
32
33export 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
110function 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
77export interface AgentOptions {77export interface AgentOptions {
78 readonly executorUrl: string;78 readonly executorUrl: string;
79 readonly workspaceId: string;79 readonly workspaceId: string;
80 readonly actorId: string;80 readonly actorId?: string;
81 readonly clientId?: string;81 readonly clientId?: string;
82 readonly sessionId?: string;
83 readonly mcpAccessToken?: string;
82 readonly apiKey?: string;84 readonly apiKey?: string;
83 readonly modelId?: string;85 readonly modelId?: string;
84 readonly context?: string;86 readonly context?: string;
@@ -152,6 +154,8 @@ export function createAgent(options: AgentOptions) { · 67 unmodified lines
152 workspaceId,154 workspaceId,
153 actorId,155 actorId,
154 clientId,156 clientId,
157 sessionId,
158 mcpAccessToken,
155 apiKey,159 apiKey,
156 modelId = "claude-sonnet-4-5",160 modelId = "claude-sonnet-4-5",
157 context,161 context,
@@ -192,10 +196,22 @@ export function createAgent(options: AgentOptions) { · 34 unmodified lines
192 emit({ type: "status", message: "Connecting..." });196 emit({ type: "status", message: "Connecting..." });
193 const mcpUrl = new URL(`${executorBaseUrl}/mcp`);197 const mcpUrl = new URL(`${executorBaseUrl}/mcp`);
194 mcpUrl.searchParams.set("workspaceId", workspaceId);198 mcpUrl.searchParams.set("workspaceId", workspaceId);
195 mcpUrl.searchParams.set("actorId", actorId);199 if (actorId) mcpUrl.searchParams.set("actorId", actorId);
196 if (clientId) mcpUrl.searchParams.set("clientId", clientId);200 if (clientId) mcpUrl.searchParams.set("clientId", clientId);
197 201 if (sessionId) mcpUrl.searchParams.set("sessionId", sessionId);
198 const transport = new StreamableHTTPClientTransport(mcpUrl);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);
199 const mcp = new Client({ name: "assistant-agent", version: "0.1.0" });215 const mcp = new Client({ name: "assistant-agent", version: "0.1.0" });
200 216
201 let mcpConnected = false;217 let mcpConnected = false;
assistant/packages/server/src/index.ts+10−55
@@ -1,81 +1,36 @@
1/**
2 * Assistant Server entry point.
3 */
4
5import { createApp } from "./routes";1import { createApp } from "./routes";
6import { ConvexHttpClient } from "convex/browser";2import { defaultLinksFilePath } from "./link-store";
7import { api } from "@executor/convex/_generated/api";
8import type { Id } from "@executor/convex/_generated/dataModel";
9 3
10const PORT = Number(Bun.env.ASSISTANT_PORT ?? Bun.env.PORT ?? 3002);4const PORT = Number(Bun.env.ASSISTANT_PORT ?? Bun.env.PORT ?? 3002);
11const CONVEX_URL = Bun.env.CONVEX_URL;5const CONVEX_URL = Bun.env.CONVEX_URL;
12if (!CONVEX_URL) {6if (!CONVEX_URL) {
13 throw new Error("CONVEX_URL is required. Set it in your environment.");7 throw new Error("CONVEX_URL is required. Set it in your environment.");
14}8}
9
15const EXECUTOR_URL = Bun.env.EXECUTOR_URL10const EXECUTOR_URL = Bun.env.EXECUTOR_URL
16 ?? Bun.env.CONVEX_SITE_URL11 ?? Bun.env.CONVEX_SITE_URL
17 ?? (CONVEX_URL.includes(".convex.cloud")12 ?? (CONVEX_URL.includes(".convex.cloud")
18 ? CONVEX_URL.replace(".convex.cloud", ".convex.site")13 ? CONVEX_URL.replace(".convex.cloud", ".convex.site")
19 : CONVEX_URL);14 : CONVEX_URL);
20const PINNED_WORKSPACE_ID = Bun.env.EXECUTOR_WORKSPACE_ID?.trim();
21const PINNED_ACTOR_ID = Bun.env.EXECUTOR_ACTOR_ID?.trim();
22const PINNED_CLIENT_ID = Bun.env.EXECUTOR_CLIENT_ID?.trim();
23const ANON_SESSION_ID = Bun.env.EXECUTOR_ANON_SESSION_ID?.trim();
24
25// Bootstrap anonymous context on executor Convex backend
26const convex = new ConvexHttpClient(CONVEX_URL);
27let ctx: { workspaceId: Id<"workspaces">; actorId: string; clientId?: string };
28
29if (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}
59 15
60console.log(`[assistant] executor context: workspace=${ctx.workspaceId} actor=${ctx.actorId}`);16const contextLines: string[] = [];
61if (ANON_SESSION_ID) {17if (Bun.env.POSTHOG_PROJECT_ID) {
62 console.log(`[assistant] executor anon session: ${ANON_SESSION_ID}`);18 contextLines.push(`- PostHog project ID: ${Bun.env.POSTHOG_PROJECT_ID}`);
63}19}
64 20
65const contextLines: string[] = [];21const linksFile = Bun.env.ASSISTANT_LINKS_FILE ?? defaultLinksFilePath;
66if (Bun.env.POSTHOG_PROJECT_ID) contextLines.push(`- PostHog project ID: ${Bun.env.POSTHOG_PROJECT_ID}`);
67 22
68const app = createApp({23const app = createApp({
69 executorUrl: EXECUTOR_URL,24 executorUrl: EXECUTOR_URL,
70 workspaceId: ctx.workspaceId,
71 actorId: ctx.actorId,
72 clientId: ctx.clientId,
73 context: contextLines.length > 0 ? contextLines.join("\n") : undefined,
74 convexUrl: CONVEX_URL,25 convexUrl: CONVEX_URL,
26 context: contextLines.length > 0 ? contextLines.join("\n") : undefined,
27 defaultClientId: Bun.env.ASSISTANT_CLIENT_ID ?? "assistant-chat",
28 linksFile,
75});29});
76 30
77app.listen(PORT);31app.listen(PORT);
78console.log(`[assistant] server running at http://localhost:${PORT}`);32console.log(`[assistant] server running at http://localhost:${PORT}`);
79console.log(`[assistant] executor at ${EXECUTOR_URL}`);33console.log(`[assistant] executor at ${EXECUTOR_URL}`);
34console.log(`[assistant] link store at ${linksFile}`);
80 35
81export type { App } from "./routes";36export type { App } from "./routes";
assistant/packages/server/src/link-store.ts+76−0
@@ -0,0 +1,76 @@
1import { mkdir } from "node:fs/promises";
2import { dirname } from "node:path";
3import type { Id } from "@executor/convex/_generated/dataModel";
4
5export type LinkedProvider = "anonymous" | "workos";
6
7export 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
17interface LinkStoreFile {
18 readonly version: 1;
19 readonly links: Record<string, LinkedMcpContext>;
20}
21
22export const defaultLinksFilePath = new URL("../../../.chat-links.json", import.meta.url).pathname;
23
24export 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
8import { Elysia, t } from "elysia";1import { Elysia, t } from "elysia";
9import { createAgent } from "@assistant/core";2import { createAgent } from "@assistant/core";
10import type { TaskEvent } from "@assistant/core";
11import { ConvexHttpClient } from "convex/browser";3import { ConvexHttpClient } from "convex/browser";
12import { api } from "@executor/convex/_generated/api";4import { api } from "@executor/convex/_generated/api";
13import type { Id } from "@executor/convex/_generated/dataModel";5import type { Id } from "@executor/convex/_generated/dataModel";
14 6import { createFileLinkStore, type LinkedMcpContext } from "./link-store";
15// ---------------------------------------------------------------------------
16// Config
17// ---------------------------------------------------------------------------
18 7
19export interface ServerOptions {8export interface ServerOptions {
20 readonly executorUrl: string;9 readonly executorUrl: string;
10 readonly convexUrl: string;
11 readonly context?: string;
12 readonly defaultClientId?: string;
13 readonly linksFile?: string;
14}
15
16interface ChatIdentity {
17 readonly platform: string;
18 readonly userId: string;
19}
20
21interface ResolvedExecutorContext {
21 readonly workspaceId: Id<"workspaces">;22 readonly workspaceId: Id<"workspaces">;
22 readonly actorId: string;23 readonly actorId?: string;
24 readonly sessionId?: string;
23 readonly clientId?: string;25 readonly clientId?: string;
24 readonly context?: string;26 readonly mcpAccessToken?: string;
25 readonly convexUrl: string;27 readonly linked: boolean;
28 readonly source: "anonymous" | "linked_anonymous" | "linked_workos";
26}29}
27 30
28// ---------------------------------------------------------------------------31function normalizeIdentityPart(value: string): string {
29// App32 return value
30// ---------------------------------------------------------------------------33 .toLowerCase()
34 .trim()
35 .replace(/[^a-z0-9]+/g, "_")
36 .replace(/^_+|_+$/g, "")
37 .slice(0, 64);
38}
31 39
32export function createApp(options: ServerOptions) {40function identityKey(identity: ChatIdentity): string {
33 const agent = createAgent({41 return `${normalizeIdentityPart(identity.platform)}:${normalizeIdentityPart(identity.userId)}`;
34 executorUrl: options.executorUrl,42}
35 workspaceId: options.workspaceId,43
36 actorId: options.actorId,44function defaultSessionId(identity: ChatIdentity): string {
37 clientId: options.clientId,45 const platform = normalizeIdentityPart(identity.platform) || "chat";
38 context: options.context,46 const user = normalizeIdentityPart(identity.userId) || "user";
39 });47 return `mcp_assistant_${platform}_${user}`;
48}
40 49
50export function createApp(options: ServerOptions) {
41 const convex = new ConvexHttpClient(options.convexUrl);51 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 });
42 75
43 let counter = 0;76 const clientId = linked?.clientId ?? defaultClientId;
44 function generateAgentTaskId(): string {
45 return `atask_${Date.now()}_${++counter}`;
46 }
47 77
48 const app = new Elysia()78 if (linked?.provider === "anonymous") {
49 .get("/api/context", () => ({79 await linkStore.set(key, {
50 workspaceId: options.workspaceId,80 provider: "anonymous",
51 actorId: options.actorId,81 workspaceId: anonymous.workspaceId,
52 clientId: options.clientId,82 actorId: anonymous.actorId,
53 }))83 sessionId: anonymous.sessionId,
54 84 clientId,
55 .post("/api/tasks", async ({ body }) => {85 linkedAt: linked.linkedAt,
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,
64 });86 });
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 }
65 98
66 let toolCalls = 0;99 function toPublicContext(context: ResolvedExecutorContext) {
67 100 return {
68 agent.run(body.prompt, (event: TaskEvent) => {101 workspaceId: context.workspaceId,
69 if (event.type === "code_result") toolCalls++;102 actorId: context.actorId,
70 103 sessionId: context.sessionId,
71 if (event.type === "agent_message") {104 clientId: context.clientId,
72 convex.mutation(api.database.updateAgentTask, {105 linked: context.linked,
73 agentTaskId,106 source: context.source,
74 resultText: event.text,107 hasAccessToken: Boolean(context.mcpAccessToken),
75 codeRuns: toolCalls,108 };
76 }).catch((err) => console.error(`[${agentTaskId}]`, err));109 }
77 }
78 110
79 if (event.type === "completed") {111 const providerSchema = t.Union([t.Literal("anonymous"), t.Literal("workos")]);
80 convex.mutation(api.database.updateAgentTask, {
81 agentTaskId,
82 status: "completed",
83 codeRuns: toolCalls,
84 }).catch((err) => console.error(`[${agentTaskId}]`, err));
85 }
86 112
87 if (event.type === "error") {113 const app = new Elysia()
88 convex.mutation(api.database.updateAgentTask, {114 .post(
89 agentTaskId,115 "/api/context/resolve",
90 status: "failed",116 async ({ body }) => {
91 error: event.error,117 const context = await resolveContext(body);
92 codeRuns: toolCalls,118 return { context: toPublicContext(context) };
93 }).catch((err) => console.error(`[${agentTaskId}]`, err));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) };
94 }156 }
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 });
104 157
105 return { agentTaskId, workspaceId: options.workspaceId };158 const requestedSessionId = body.sessionId?.trim() || defaultSessionId(identity);
106 }, {159 const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, {
107 body: t.Object({160 sessionId: requestedSessionId,
108 prompt: t.String({ minLength: 1 }),161 });
109 requesterId: t.String({ minLength: 1 }),162
110 }),163 const link: LinkedMcpContext = {
111 });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 );
112 236
113 return app;237 return app;
114}238}
bun.lock+5−11
@@ -33,18 +33,12 @@ · 32 unmodified lines
33 "assistant/packages/bot": {33 "assistant/packages/bot": {
34 "name": "@assistant/bot",34 "name": "@assistant/bot",
35 "dependencies": {35 "dependencies": {
36 "@assistant/core": "workspace:*",
37 "@assistant/server": "workspace:*",36 "@assistant/server": "workspace:*",
38 "@elysiajs/eden": "latest",37 "@elysiajs/eden": "latest",
39 "@openassistant/reacord": "workspace:*",38 "discord.js": "^14.25.1",
40 "convex": "^1.31.7",
41 "discord.js": "^14.17.3",
42 "effect": "latest",
43 "react": "^19.0.0",
44 },39 },
45 "devDependencies": {40 "devDependencies": {
46 "@types/bun": "latest",41 "@types/bun": "latest",
47 "@types/react": "^19.0.0",
48 },42 },
49 },43 },
50 "assistant/packages/core": {44 "assistant/packages/core": {
@@ -1627,7 +1621,7 @@ · 1576 unmodified lines
1627 1621
1628 "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],1622 "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
1629 1623
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=="],
1631 1625
1632 "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],1626 "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
1633 1627
@@ -2541,7 +2535,7 @@ · 907 unmodified lines
2541 2535
2542 "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=="],2536 "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=="],
2543 2537
2544 "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],2538 "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
2545 2539
2546 "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],2540 "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
2547 2541
@@ -3071,8 +3065,6 @@ · 523 unmodified lines
3071 3065
3072 "@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=="],3066 "@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=="],
3073 3067
3074 "@executor/web/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
3075
3076 "@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=="],3068 "@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=="],
3077 3069
3078 "@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=="],3070 "@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
3109 3101
3110 "@npmcli/run-script/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],3102 "@npmcli/run-script/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
3111 3103
3104 "@openassistant/reacord/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
3105
3112 "@opentelemetry/instrumentation-grpc/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.27.0", "", {}, "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg=="],3106 "@opentelemetry/instrumentation-grpc/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.27.0", "", {}, "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg=="],
3113 3107
3114 "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],3108 "@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 @@
1"use client";1"use client";
2 2
3import { Suspense, useEffect, useMemo, useState, type FormEvent } from "react";3import { Suspense, useEffect, useState } from "react";
4import { Link, useLocation, useNavigate, useSearchParams } from "react-router";4import { Link, useLocation, useNavigate } from "react-router";
5import { useMutation } from "convex/react";5import { LayoutDashboard, Play, ShieldCheck, Wrench, Menu, X } from "lucide-react";
6import {
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";
19import { Button } from "@/components/ui/button";6import { Button } from "@/components/ui/button";
20import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";7import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
21import {
22 DropdownMenu,
23 DropdownMenuContent,
24 DropdownMenuItem,
25 DropdownMenuLabel,
26 DropdownMenuSeparator,
27 DropdownMenuTrigger,
28} from "@/components/ui/dropdown-menu";
29import {
30 Dialog,
31 DialogContent,
32 DialogDescription,
33 DialogFooter,
34 DialogHeader,
35 DialogTitle,
36} from "@/components/ui/dialog";
37import { Input } from "@/components/ui/input";
38import { useSession } from "@/lib/session-context";8import { useSession } from "@/lib/session-context";
39import { convexApi } from "@/lib/convex-api";
40import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";
41import { ThemeSwitcher } from "@/components/theme-switcher";
42import { ApprovalNotifier } from "@/components/approval-notifier";9import { ApprovalNotifier } from "@/components/approval-notifier";
10import { ThemeSwitcher } from "@/components/theme-switcher";
11import { WorkspaceSelector } from "@/components/workspace-selector";
12import { SessionInfo } from "@/components/session-info";
13import { NoOrganizationModal } from "@/components/no-organization-modal";
43import { cn } from "@/lib/utils";14import { cn } from "@/lib/utils";
44 15
45const NAV_ITEMS = [16const NAV_ITEMS = [
@@ -81,530 +52,6 @@ function NavLinks({ onClick }: { onClick?: () => void }) { · 35 unmodified lines
81 );52 );
82}53}
83 54
84function 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
402function 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
608function Sidebar() {55function Sidebar() {
609 return (56 return (
610 <aside className="hidden md:flex md:w-56 lg:w-60 flex-col border-r border-border bg-sidebar h-screen sticky top-0">57 <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
662 );109 );
663}110}
664 111
665function 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
734export function AppShell({ children }: { children: React.ReactNode }) {112export function AppShell({ children }: { children: React.ReactNode }) {
735 const location = useLocation();113 const location = useLocation();
736 const pathname = location.pathname;114 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
7import { useSession } from "@/lib/session-context";7import { useSession } from "@/lib/session-context";
8import { convexApi } from "@/lib/convex-api";8import { convexApi } from "@/lib/convex-api";
9import type { PendingApprovalRecord } from "@/lib/types";9import type { PendingApprovalRecord } from "@/lib/types";
10import { workspaceQueryArgs } from "@/lib/workspace-query-args";
10 11
11export function ApprovalNotifier() {12export function ApprovalNotifier() {
12 const navigate = useNavigate();13 const navigate = useNavigate();
@@ -16,7 +17,7 @@ export function ApprovalNotifier() { · 3 unmodified lines
16 17
17 const approvals = useQuery(18 const approvals = useQuery(
18 convexApi.workspace.listPendingApprovals,19 convexApi.workspace.listPendingApprovals,
19 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",20 workspaceQueryArgs(context),
20 );21 );
21 22
22 useEffect(() => {23 useEffect(() => {
executor/apps/web/src/components/approvals-view.tsx+9−59
@@ -21,62 +21,8 @@ import type { PendingApprovalRecord } from "@/lib/types"; · 20 unmodified lines
21import { toast } from "sonner";21import { toast } from "sonner";
22import { formatTimeAgo } from "@/lib/format";22import { formatTimeAgo } from "@/lib/format";
23import { FormattedCodeBlock } from "@/components/formatted-code-block";23import { FormattedCodeBlock } from "@/components/formatted-code-block";
24 24import { formatApprovalInput } from "@/lib/approval-input-format";
25function formatApprovalInput(25import { workspaceQueryArgs } from "@/lib/workspace-query-args";
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}
80 26
81function ApprovalCard({27function ApprovalCard({
82 approval,28 approval,
@@ -113,7 +59,11 @@ function ApprovalCard({ · 30 unmodified lines
113 };59 };
114 60
115 const inputDisplay = useMemo(61 const inputDisplay = useMemo(
116 () => formatApprovalInput(approval.input),62 () =>
63 formatApprovalInput(approval.input, {
64 hideSerializedNull: true,
65 hideSerializedEmptyObject: true,
66 }),
117 [approval.input],67 [approval.input],
118 );68 );
119 69
@@ -203,7 +153,7 @@ export function ApprovalsView() { · 83 unmodified lines
203 153
204 const approvals = useQuery(154 const approvals = useQuery(
205 convexApi.workspace.listPendingApprovals,155 convexApi.workspace.listPendingApprovals,
206 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",156 workspaceQueryArgs(context),
207 );157 );
208 const approvalsLoading = !!context && approvals === undefined;158 const approvalsLoading = !!context && approvals === undefined;
209 159
@@ -255,7 +205,7 @@ export function ApprovalsView() { · 45 unmodified lines
255 <Clock className="h-3.5 w-3.5" />205 <Clock className="h-3.5 w-3.5" />
256 {count} pending approval{count !== 1 ? "s" : ""}206 {count} pending approval{count !== 1 ? "s" : ""}
257 </div>207 </div>
258 {approvals!.map((a: PendingApprovalRecord) => (208 {(approvals ?? []).map((a: PendingApprovalRecord) => (
259 <ApprovalCard key={a.id} approval={a} />209 <ApprovalCard key={a.id} approval={a} />
260 ))}210 ))}
261 </div>211 </div>
executor/apps/web/src/components/code-editor-monaco.ts+193−0
@@ -0,0 +1,193 @@
1import type { EditorProps, Monaco } from "@monaco-editor/react";
2
3const DIAGNOSTIC_CODES_TO_IGNORE = [
4 1375,
5 1378,
6 2307,
7 80005,
8];
9
10export 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
20export 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
43export 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
136export 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 @@
1import type { ToolDescriptor } from "@/lib/types";
2
3interface NamespaceNode {
4 children: Map<string, NamespaceNode>;
5 tools: ToolDescriptor[];
6}
7
8function 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
28function 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
36function 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
72function 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
97export 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
128export const OPENAPI_HELPER_TYPES = `
129type _Normalize<T> = Exclude<T, undefined | null>;
130type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;
131type _Simplify<T> = { [K in keyof T]: T[K] } & {};
132type _ParamsOf<Op> =
133 Op extends { parameters: infer P } ? P :
134 Op extends { parameters?: infer P } ? P :
135 never;
136type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> =
137 _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never;
138type _BodyOf<Op> =
139 Op extends { requestBody?: infer B } ? B :
140 Op extends { requestBody: infer B } ? B :
141 never;
142type _BodyContent<B> =
143 B extends { content: infer C }
144 ? C extends Record<string, infer V> ? V : never
145 : never;
146type 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>;
153type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never;
154type _RespAt<Op, Code extends PropertyKey> =
155 _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never;
156type _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;
161type _HasStatus<Op, Code extends PropertyKey> =
162 [_RespAt<Op, Code>] extends [never] ? false : true;
163type _PayloadAt<Op, Code extends PropertyKey> =
164 Code extends 204 | 205
165 ? (_HasStatus<Op, Code> extends true ? void : never)
166 : _ResponsePayload<_RespAt<Op, Code>>;
167type _FirstKnown<T extends readonly unknown[]> =
168 T extends readonly [infer H, ...infer Rest]
169 ? [H] extends [never] ? _FirstKnown<Rest> : H
170 : unknown;
171type 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
187export const BASE_ENVIRONMENT_DTS = `
188interface 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}
198declare var console: Console;
199
200declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
201declare function clearTimeout(id: number): void;
202declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
203declare function clearInterval(id: number): void;
204`;
executor/apps/web/src/components/code-editor.tsx+42−445
@@ -1,260 +1,22 @@
1"use client";1"use client";
2 2
3import { useRef, useEffect, useState } from "react";3import { useRef, useEffect, useMemo, useState } from "react";
4import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";4import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";
5import { Loader2 } from "lucide-react";5import { Loader2 } from "lucide-react";
6import { useTheme } from "next-themes";6import { useTheme } from "next-themes";
7import type { ToolDescriptor } from "@/lib/types";7import type { ToolDescriptor } from "@/lib/types";
8import { cn } from "@/lib/utils";8import { cn } from "@/lib/utils";
9 9import {
10// ── Generate TypeScript declarations for the tools proxy ──10 BASE_ENVIRONMENT_DTS,
11 11 OPENAPI_HELPER_TYPES,
12interface NamespaceNode {12 generateToolsDts,
13 children: Map<string, NamespaceNode>;13} from "./code-editor-types";
14 tools: ToolDescriptor[];14import {
15}15 CODE_EDITOR_OPTIONS,
16 16 configureJavascriptDefaults,
17function buildTree(tools: ToolDescriptor[]): NamespaceNode {17 defineExecutorThemes,
18 const root: NamespaceNode = { children: new Map(), tools: [] };18 setDiagnosticsOptions,
19 for (const tool of tools) {19} from "./code-editor-monaco";
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
38function 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
79function 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
109function 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) */
118const OPENAPI_HELPER_TYPES = `
119type _Normalize<T> = Exclude<T, undefined | null>;
120type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;
121type _Simplify<T> = { [K in keyof T]: T[K] } & {};
122type _ParamsOf<Op> =
123 Op extends { parameters: infer P } ? P :
124 Op extends { parameters?: infer P } ? P :
125 never;
126type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> =
127 _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never;
128type _BodyOf<Op> =
129 Op extends { requestBody?: infer B } ? B :
130 Op extends { requestBody: infer B } ? B :
131 never;
132type _BodyContent<B> =
133 B extends { content: infer C }
134 ? C extends Record<string, infer V> ? V : never
135 : never;
136type 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>;
143type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never;
144type _RespAt<Op, Code extends PropertyKey> =
145 _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never;
146type _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;
151type _HasStatus<Op, Code extends PropertyKey> =
152 [_RespAt<Op, Code>] extends [never] ? false : true;
153type _PayloadAt<Op, Code extends PropertyKey> =
154 Code extends 204 | 205
155 ? (_HasStatus<Op, Code> extends true ? void : never)
156 : _ResponsePayload<_RespAt<Op, Code>>;
157type _FirstKnown<T extends readonly unknown[]> =
158 T extends readonly [infer H, ...infer Rest]
159 ? [H] extends [never] ? _FirstKnown<Rest> : H
160 : unknown;
161type 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
177function 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
223const BASE_ENVIRONMENT_DTS = `
224interface 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}
234declare var console: Console;
235
236declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
237declare function clearTimeout(id: number): void;
238declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
239declare function clearInterval(id: number): void;
240`;
241
242const 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
249function 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}
258 20
259interface CodeEditorProps {21interface CodeEditorProps {
260 value: string;22 value: string;
@@ -285,20 +47,24 @@ export function CodeEditor({ · 24 unmodified lines
285 const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]);47 const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]);
286 const toolsLibVersion = useRef(0);48 const toolsLibVersion = useRef(0);
287 const fetchedDtsUrls = useRef<string>("");49 const fetchedDtsUrls = useRef<string>("");
288 const dtsSources = new Set(Object.keys(dtsUrls ?? {}));50 const dtsSources = useMemo(() => new Set(Object.keys(dtsUrls ?? {})), [dtsUrls]);
289 const toolTypesLoading = typesLoading || dtsHydrating;51 const toolTypesLoading = typesLoading || dtsHydrating;
290 52
291 // Fetch and register .d.ts blobs from OpenAPI sources53 // Fetch and register .d.ts blobs from OpenAPI sources
292 useEffect(() => {54 useEffect(() => {
293 if (!dtsUrls || Object.keys(dtsUrls).length === 0) {55 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 });
295 return;62 return;
296 }63 }
297 const m = monacoRef.current;64 const m = monacoRef.current;
298 if (!m) return;65 if (!m) return;
299 66
300 // eslint-disable-next-line @typescript-eslint/no-explicit-any67 const jsDefaults = m.languages.typescript.javascriptDefaults;
301 const jsDefaults = (m.languages as any).typescript.javascriptDefaults;
302 68
303 // Skip if URLs haven't changed69 // Skip if URLs haven't changed
304 const urlsKey = JSON.stringify(dtsUrls);70 const urlsKey = JSON.stringify(dtsUrls);
@@ -310,23 +76,24 @@ export function CodeEditor({ · 5 unmodified lines
310 dtsLibDisposables.current = [];76 dtsLibDisposables.current = [];
311 77
312 let cancelled = false;78 let cancelled = false;
313 setDtsHydrating(true);
314
315 // Fetch each .d.ts blob and register with Monaco
316 const entries = Object.entries(dtsUrls);79 const entries = Object.entries(dtsUrls);
317 Promise.all(80
318 entries.map(async ([sourceKey, url]) => {81 void Promise.resolve()
319 try {82 .then(async () => {
320 const resp = await fetch(url);83 setDtsHydrating(true);
321 if (!resp.ok || cancelled) return null;84 const results = await Promise.all(
322 const content = await resp.text();85 entries.map(async ([sourceKey, url]) => {
323 return { sourceKey, content };86 try {
324 } catch {87 const resp = await fetch(url);
325 return null;88 if (!resp.ok || cancelled) return null;
326 }89 const content = await resp.text();
327 }),90 return { sourceKey, content };
328 )91 } catch {
329 .then((results) => {92 return null;
93 }
94 }),
95 );
96
330 if (cancelled) return;97 if (cancelled) return;
331 98
332 // Build the helper types + .d.ts declarations99 // Build the helper types + .d.ts declarations
@@ -361,8 +128,7 @@ export function CodeEditor({ · 28 unmodified lines
361 const m = monacoRef.current;128 const m = monacoRef.current;
362 if (!m) return;129 if (!m) return;
363 130
364 // eslint-disable-next-line @typescript-eslint/no-explicit-any131 const jsDefaults = m.languages.typescript.javascriptDefaults;
365 const jsDefaults = (m.languages as any).typescript.javascriptDefaults;
366 132
367 // Dispose previous tool type declarations133 // Dispose previous tool type declarations
368 toolsLibDisposable.current?.dispose();134 toolsLibDisposable.current?.dispose();
@@ -376,7 +142,7 @@ export function CodeEditor({ · 7 unmodified lines
376 dts,142 dts,
377 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,143 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
378 );144 );
379 }, [tools, dtsUrls]);145 }, [tools, dtsSources]);
380 146
381 // Avoid transient semantic errors while tool metadata is still loading.147 // Avoid transient semantic errors while tool metadata is still loading.
382 useEffect(() => {148 useEffect(() => {
@@ -397,29 +163,7 @@ export function CodeEditor({ · 14 unmodified lines
397 const handleBeforeMount: BeforeMount = (monaco) => {163 const handleBeforeMount: BeforeMount = (monaco) => {
398 monacoRef.current = monaco;164 monacoRef.current = monaco;
399 165
400 // eslint-disable-next-line @typescript-eslint/no-explicit-any166 const ts = configureJavascriptDefaults(monaco, toolTypesLoading);
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);
423 167
424 // Add stable environment declarations (once)168 // Add stable environment declarations (once)
425 envLibDisposable.current?.dispose();169 envLibDisposable.current?.dispose();
@@ -438,97 +182,7 @@ export function CodeEditor({ · 12 unmodified lines
438 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,182 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
439 );183 );
440 184
441 // Define themes that track the app's light/dark mode.185 defineExecutorThemes(monaco);
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 });
532 };186 };
533 187
534 const monacoTheme = resolvedTheme === "light" ? "executor-light" : "executor-dark";188 const monacoTheme = resolvedTheme === "light" ? "executor-light" : "executor-dark";
@@ -566,64 +220,7 @@ export function CodeEditor({ · 31 unmodified lines
566 onChange={(v) => onChange(v ?? "")}220 onChange={(v) => onChange(v ?? "")}
567 beforeMount={handleBeforeMount}221 beforeMount={handleBeforeMount}
568 onMount={handleMount}222 onMount={handleMount}
569 options={{223 options={CODE_EDITOR_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 }}
627 loading={224 loading={
628 <div className="flex h-full items-center justify-center bg-background text-xs font-mono text-muted-foreground">225 <div className="flex h-full items-center justify-center bg-background text-xs font-mono text-muted-foreground">
629 Loading editor...226 Loading editor...
executor/apps/web/src/components/dashboard-pending-approvals-card.tsx+67−0
@@ -0,0 +1,67 @@
1import { useNavigate } from "react-router";
2import { AlertTriangle, ArrowRight, CheckCircle2 } from "lucide-react";
3import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4import { Button } from "@/components/ui/button";
5import type { PendingApprovalRecord } from "@/lib/types";
6import { formatTimeAgo } from "@/lib/format";
7
8function 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
26export 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 @@
1import { useNavigate } from "react-router";
2import { Clock } from "lucide-react";
3import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4import { Button } from "@/components/ui/button";
5import { TaskStatusBadge } from "@/components/status-badge";
6import type { TaskRecord } from "@/lib/types";
7import { formatTime } from "@/lib/format";
8
9function 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
28export 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 @@
1import { ArrowRight, CheckCircle2 } from "lucide-react";
2import { Button } from "@/components/ui/button";
3import { Card, CardContent } from "@/components/ui/card";
4
5type SetupStep = {
6 label: string;
7 done: boolean;
8 href: string;
9 pendingText: string;
10 doneText: string;
11};
12
13export 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 @@
1import { Card, CardContent } from "@/components/ui/card";
2
3export 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 @@
1import { useMemo } from "react";
2import { useNavigate } from "react-router";
3import { ArrowRight, ChevronRight, Globe, Server, Wrench } from "lucide-react";
4import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5import { Button } from "@/components/ui/button";
6import type { ToolDescriptor } from "@/lib/types";
7import { groupToolsBySource } from "@/components/dashboard-view-helpers";
8
9export 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 @@
1import type { PendingApprovalRecord, TaskRecord, ToolDescriptor } from "@/lib/types";
2import { sourceLabel, sourceType } from "@/lib/tool-source-utils";
3
4type SetupStep = {
5 label: string;
6 done: boolean;
7 href: string;
8 pendingText: string;
9 doneText: string;
10};
11
12export type DashboardTaskStats = {
13 runningCount: number;
14 completedCount: number;
15 failedCount: number;
16};
17
18export type ToolSourceGroup = {
19 name: string;
20 type: string;
21 tools: ToolDescriptor[];
22 namespaces: Set<string>;
23 approvalCount: number;
24};
25
26export 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
34export 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
68export function getRecentTasks(tasks: TaskRecord[]): TaskRecord[] {
69 return tasks.slice(0, 8);
70}
71
72export function getPendingApprovals(approvals: PendingApprovalRecord[]): PendingApprovalRecord[] {
73 return approvals.slice(0, 5);
74}
75
76export 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 @@
1"use client";1"use client";
2 2
3import { useMemo } from "react";3import { CheckCircle2, Play, ShieldCheck, XCircle } from "lucide-react";
4import { useNavigate } from "react-router";
5import {
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";
18import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
19import { Button } from "@/components/ui/button";
20import { Skeleton } from "@/components/ui/skeleton";4import { Skeleton } from "@/components/ui/skeleton";
21import { PageHeader } from "@/components/page-header";5import { PageHeader } from "@/components/page-header";
22import { TaskStatusBadge } from "@/components/status-badge";6import { DashboardSetupCard } from "@/components/dashboard-setup-card";
7import { DashboardStatCard } from "@/components/dashboard-stat-card";
8import { DashboardPendingApprovalsCard } from "@/components/dashboard-pending-approvals-card";
9import { DashboardRecentTasksCard } from "@/components/dashboard-recent-tasks-card";
10import { DashboardToolsSummaryCard } from "@/components/dashboard-tools-summary-card";
23import { useSession } from "@/lib/session-context";11import { useSession } from "@/lib/session-context";
24import { useWorkspaceTools } from "@/hooks/use-workspace-tools";12import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
25import { useQuery } from "convex/react";13import { useQuery } from "convex/react";
26import { convexApi } from "@/lib/convex-api";14import { convexApi } from "@/lib/convex-api";
27import type { TaskRecord, PendingApprovalRecord, ToolDescriptor } from "@/lib/types";15import {
28import { formatTime, formatTimeAgo } from "@/lib/format";16 getPendingApprovals,
29 17 getRecentTasks,
30function StatCard({18 getSetupSteps,
31 label,19 getTaskStats,
32 value,20} from "@/components/dashboard-view-helpers";
33 icon: Icon,21import { workspaceQueryArgs } from "@/lib/workspace-query-args";
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
69function 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
90function 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"). */
111function 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"). */
118function 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
124function 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}
231 22
232export function DashboardView() {23export function DashboardView() {
233 const { context, loading: sessionLoading } = useSession();24 const { context, loading: sessionLoading } = useSession();
234 25
235 const tasks = useQuery(26 const tasks = useQuery(
236 convexApi.workspace.listTasks,27 convexApi.workspace.listTasks,
237 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",28 workspaceQueryArgs(context),
238 );29 );
239 30
240 const approvals = useQuery(31 const approvals = useQuery(
241 convexApi.workspace.listPendingApprovals,32 convexApi.workspace.listPendingApprovals,
242 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",33 workspaceQueryArgs(context),
243 );34 );
244 35
245 const sources = useQuery(36 const sources = useQuery(
246 convexApi.workspace.listToolSources,37 convexApi.workspace.listToolSources,
247 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",38 workspaceQueryArgs(context),
248 );39 );
249 40
250 const { tools } = useWorkspaceTools(context ?? null);41 const { tools } = useWorkspaceTools(context ?? null);
@@ -265,38 +56,10 @@ export function DashboardView() { · 14 unmodified lines
265 const pendingCount = approvals?.length ?? 0;56 const pendingCount = approvals?.length ?? 0;
266 const sourceCount = sources?.length ?? 0;57 const sourceCount = sources?.length ?? 0;
267 const taskItems = tasks ?? [];58 const taskItems = tasks ?? [];
268 const runningCount =59 const { runningCount, completedCount, failedCount } = getTaskStats(taskItems);
269 tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0;60 const recentTasks = getRecentTasks(taskItems);
270 const completedCount =61 const pendingApprovals = getPendingApprovals(approvals ?? []);
271 tasks?.filter((t: TaskRecord) => t.status === "completed").length ?? 0;62 const setupSteps = getSetupSteps({ sourceCount, taskCount: taskItems.length, pendingCount });
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 ];
300 63
301 return (64 return (
302 <div className="space-y-6">65 <div className="space-y-6">
@@ -305,75 +68,28 @@ export function DashboardView() { · 2 unmodified lines
305 description="Start from the current status, then jump straight to execution"68 description="Start from the current status, then jump straight to execution"
306 />69 />
307 70
308 <Card className="border-border bg-gradient-to-br from-card to-muted/25">71 <DashboardSetupCard taskCount={taskItems.length} setupSteps={setupSteps} />
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>
331 72
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 */}
357 <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">73 <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
358 <StatCard74 <DashboardStatCard
359 label="Pending Approvals"75 label="Pending Approvals"
360 value={pendingCount}76 value={pendingCount}
361 icon={ShieldCheck}77 icon={ShieldCheck}
362 accent={pendingCount > 0 ? "amber" : "default"}78 accent={pendingCount > 0 ? "amber" : "default"}
363 />79 />
364 <StatCard80 <DashboardStatCard
365 label="Running"81 label="Running"
366 value={runningCount}82 value={runningCount}
367 icon={Play}83 icon={Play}
368 accent={runningCount > 0 ? "green" : "default"}84 accent={runningCount > 0 ? "green" : "default"}
369 />85 />
370 <StatCard86 <DashboardStatCard
371 label="Completed"87 label="Completed"
372 value={completedCount}88 value={completedCount}
373 icon={CheckCircle2}89 icon={CheckCircle2}
374 accent="green"90 accent="green"
375 />91 />
376 <StatCard92 <DashboardStatCard
377 label="Failed"93 label="Failed"
378 value={failedCount}94 value={failedCount}
379 icon={XCircle}95 icon={XCircle}
@@ -381,83 +97,12 @@ export function DashboardView() { · 1 unmodified line
381 />97 />
382 </div>98 </div>
383 99
384 {/* Two column layout */}
385 <div className="grid lg:grid-cols-2 gap-6">100 <div className="grid lg:grid-cols-2 gap-6">
386 {/* Pending approvals */}101 <DashboardPendingApprovalsCard pendingCount={pendingCount} approvals={pendingApprovals} />
387 <Card className="bg-card border-border">102 <DashboardRecentTasksCard recentTasks={recentTasks} />
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>
457 </div>103 </div>
458 104
459 {/* Tools summary — grouped by source */}105 {tools.length > 0 && <DashboardToolsSummaryCard tools={tools} />}
460 {tools.length > 0 && <ToolsSummaryCard tools={tools} />}
461 </div>106 </div>
462 );107 );
463}108}
executor/apps/web/src/components/mcp-setup-card.tsx+9−1
@@ -2,6 +2,7 @@ · 1 unmodified line
2 2
3import { useMemo, useState } from "react";3import { useMemo, useState } from "react";
4import { Check, Copy } from "lucide-react";4import { Check, Copy } from "lucide-react";
5import Image from "next/image";
5import { Button } from "@/components/ui/button";6import { Button } from "@/components/ui/button";
6import { Input } from "@/components/ui/input";7import { Input } from "@/components/ui/input";
7import { Label } from "@/components/ui/label";8import { Label } from "@/components/ui/label";
@@ -118,7 +119,14 @@ export function McpSetupCard({ · 110 unmodified lines
118 {MCP_PROVIDERS.map((item) => (119 {MCP_PROVIDERS.map((item) => (
119 <SelectItem key={item.id} value={item.id} className="text-xs">120 <SelectItem key={item.id} value={item.id} className="text-xs">
120 <div className="flex items-center gap-2">121 <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 />
122 <span>{item.name}</span>130 <span>{item.name}</span>
123 </div>131 </div>
124 </SelectItem>132 </SelectItem>
executor/apps/web/src/components/no-organization-modal.tsx+84−0
@@ -0,0 +1,84 @@
1"use client";
2
3import { useState } from "react";
4import { Link } from "react-router";
5import { LogIn } from "lucide-react";
6import { Button } from "@/components/ui/button";
7import {
8 Dialog,
9 DialogContent,
10 DialogDescription,
11 DialogHeader,
12 DialogTitle,
13} from "@/components/ui/dialog";
14import { useSession } from "@/lib/session-context";
15import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";
16
17export 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
3import { useState, type FormEvent } from "react";
4import { Link, useSearchParams } from "react-router";
5import { useMutation } from "convex/react";
6import { ChevronsUpDown, LogIn } from "lucide-react";
7import Image from "next/image";
8import { Button } from "@/components/ui/button";
9import {
10 DropdownMenu,
11 DropdownMenuContent,
12 DropdownMenuItem,
13 DropdownMenuLabel,
14 DropdownMenuSeparator,
15 DropdownMenuTrigger,
16} from "@/components/ui/dropdown-menu";
17import {
18 Dialog,
19 DialogContent,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogTitle,
24} from "@/components/ui/dialog";
25import { Input } from "@/components/ui/input";
26import { useSession } from "@/lib/session-context";
27import { convexApi } from "@/lib/convex-api";
28import { workosEnabled } from "@/lib/auth-capabilities";
29
30export 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
3import { useState } from "react";
4import { Play, Send } from "lucide-react";
5import { useMutation, useQuery } from "convex/react";
6import { toast } from "sonner";
7import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8import { Button } from "@/components/ui/button";
9import { Input } from "@/components/ui/input";
10import { Label } from "@/components/ui/label";
11import {
12 Select,
13 SelectContent,
14 SelectItem,
15 SelectTrigger,
16 SelectValue,
17} from "@/components/ui/select";
18import { CodeEditor } from "@/components/code-editor";
19import { convexApi } from "@/lib/convex-api";
20import { useSession } from "@/lib/session-context";
21import type { RuntimeTargetDescriptor } from "@/lib/types";
22import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
23
24const DEFAULT_CODE = `// Example: call some tools and return a compact result
25const time = await tools.utils.get_time();
26const sum = await tools.math.add({ a: 7, b: 35 });
27
28// This will require approval:
29await tools.admin.send_announcement({
30 channel: "general",
31 message: "Hello from executor!"
32});
33
34return {
35 isoTime: time.iso,
36 total: sum.result,
37};`;
38const DEFAULT_TIMEOUT_MS = 300_000;
39
40export 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
3import { useState } from "react";
4import { X, ShieldX, CheckCircle2 } from "lucide-react";
5import { useMutation, useQuery } from "convex/react";
6import type { Id } from "@executor/convex/_generated/dataModel";
7import { toast } from "sonner";
8import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9import { Button } from "@/components/ui/button";
10import { Separator } from "@/components/ui/separator";
11import { TaskStatusBadge } from "@/components/status-badge";
12import { FormattedCodeBlock } from "@/components/formatted-code-block";
13import { convexApi } from "@/lib/convex-api";
14import { formatApprovalInput } from "@/lib/approval-input-format";
15import { cn } from "@/lib/utils";
16import type { TaskRecord, PendingApprovalRecord } from "@/lib/types";
17
18function 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
27function 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
39export 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
3import { ChevronRight } from "lucide-react";
4import { TaskStatusBadge } from "@/components/status-badge";
5import type { TaskRecord } from "@/lib/types";
6import { cn } from "@/lib/utils";
7
8function 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
17type TaskListItemProps = {
18 task: TaskRecord;
19 selected: boolean;
20 onClick: () => void;
21};
22
23export 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
2 2
3import { useState, useCallback } from "react";3import { useState, useCallback } from "react";
4import { useNavigate, useSearchParams } from "react-router";4import { useNavigate, useSearchParams } from "react-router";
5import {5import { Play, ShieldCheck } from "lucide-react";
6 Play,
7 ChevronRight,
8 X,
9 Send,
10 ShieldCheck,
11 ShieldX,
12 CheckCircle2,
13} from "lucide-react";
14import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";6import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
15import { Button } from "@/components/ui/button";7import { Button } from "@/components/ui/button";
16import { Input } from "@/components/ui/input";
17import { Label } from "@/components/ui/label";
18import { CodeEditor } from "@/components/code-editor";
19import {
20 Select,
21 SelectContent,
22 SelectItem,
23 SelectTrigger,
24 SelectValue,
25} from "@/components/ui/select";
26import { Skeleton } from "@/components/ui/skeleton";8import { Skeleton } from "@/components/ui/skeleton";
27import { Separator } from "@/components/ui/separator";
28import { PageHeader } from "@/components/page-header";9import { PageHeader } from "@/components/page-header";
29import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";10import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
30import { TaskStatusBadge } from "@/components/status-badge";11import { TaskDetail } from "@/components/task-detail";
31import { FormattedCodeBlock } from "@/components/formatted-code-block";12import { TaskComposer } from "@/components/task-composer";
13import { TaskListItem } from "@/components/task-list-item";
32import { useSession } from "@/lib/session-context";14import { useSession } from "@/lib/session-context";
33import { useWorkspaceTools } from "@/hooks/use-workspace-tools";15import { useQuery } from "convex/react";
34import { useMutation, useQuery } from "convex/react";
35import { convexApi } from "@/lib/convex-api";16import { convexApi } from "@/lib/convex-api";
17import { workspaceQueryArgs } from "@/lib/workspace-query-args";
36import type {18import type {
37 RuntimeTargetDescriptor,
38 TaskRecord,19 TaskRecord,
39 PendingApprovalRecord,20 PendingApprovalRecord,
40} from "@/lib/types";21} from "@/lib/types";
41import type { Id } from "@executor/convex/_generated/dataModel";
42import { toast } from "sonner";
43import { cn } from "@/lib/utils";
44
45const DEFAULT_CODE = `// Example: call some tools and return a compact result
46const time = await tools.utils.get_time();
47const sum = await tools.math.add({ a: 7, b: 35 });
48
49// This will require approval:
50await tools.admin.send_announcement({
51 channel: "general",
52 message: "Hello from executor!"
53});
54
55return {
56 isoTime: time.iso,
57 total: sum.result,
58};`;
59const DEFAULT_TIMEOUT_MS = 300_000;
60
61function 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
70function 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
82function 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
131function 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
256function 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
298function 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
520// ── Tasks View ──22// ── Tasks View ──
521 23
522export function TasksView() {24export function TasksView() {
@@ -528,14 +30,14 @@ export function TasksView() { · 5 unmodified lines
528 30
529 const tasks = useQuery(31 const tasks = useQuery(
530 convexApi.workspace.listTasks,32 convexApi.workspace.listTasks,
531 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",33 workspaceQueryArgs(context),
532 );34 );
533 const tasksLoading = !!context && tasks === undefined;35 const tasksLoading = !!context && tasks === undefined;
534 const taskItems = tasks ?? [];36 const taskItems = tasks ?? [];
535 37
536 const approvals = useQuery(38 const approvals = useQuery(
537 convexApi.workspace.listPendingApprovals,39 convexApi.workspace.listPendingApprovals,
538 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",40 workspaceQueryArgs(context),
539 );41 );
540 const pendingApprovals = approvals ?? [];42 const pendingApprovals = approvals ?? [];
541 43
@@ -631,10 +133,10 @@ export function TasksView() { · 89 unmodified lines
631 </Card>133 </Card>
632 134
633 <div>135 <div>
634 {selectedTask ? (136 {selectedTask && context ? (
635 <TaskDetail137 <TaskDetail
636 task={selectedTask}138 task={selectedTask}
637 workspaceId={context!.workspaceId}139 workspaceId={context.workspaceId}
638 sessionId={context?.sessionId}140 sessionId={context?.sessionId}
639 pendingApprovals={selectedTaskApprovals}141 pendingApprovals={selectedTaskApprovals}
640 onClose={() => selectTask(null)}142 onClose={() => selectTask(null)}
executor/apps/web/src/components/tool-explorer-copy-button.tsx+55−0
@@ -0,0 +1,55 @@
1"use client";
2
3import { useCallback, useState } from "react";
4import { Check, Copy } from "lucide-react";
5import {
6 Tooltip,
7 TooltipContent,
8 TooltipProvider,
9 TooltipTrigger,
10} from "@/components/ui/tooltip";
11import { cn } from "@/lib/utils";
12
13interface CopyButtonProps {
14 text: string;
15 className?: string;
16}
17
18export 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 @@
1import {
2 buildApprovalTree,
3 buildNamespaceTree,
4 buildSourceTree,
5 toolNamespace,
6 type ToolGroup,
7} from "@/lib/tool-explorer-grouping";
8import { sourceLabel } from "@/lib/tool-source-utils";
9import type { ToolDescriptor } from "@/lib/types";
10import type { GroupBy, ViewMode } from "./tool-explorer-toolbar";
11
12export type FilterApproval = "all" | "required" | "auto";
13
14export function expandedKeysForSource(source: string | null): Set<string> {
15 return source ? new Set([`source:${source}`]) : new Set();
16}
17
18export 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
38export 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
54export 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
74export 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
85export 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
113export 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
122export 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
3import { useMemo } from "react";
4import {
5 Check,
6 ChevronDown,
7 ChevronRight,
8 Globe,
9 Layers,
10 Server,
11 ShieldCheck,
12} from "lucide-react";
13import {
14 Collapsible,
15 CollapsibleContent,
16 CollapsibleTrigger,
17} from "@/components/ui/collapsible";
18import { cn } from "@/lib/utils";
19import { sourceLabel, sourceType } from "@/lib/tool-source-utils";
20import { toolOperation, type ToolGroup } from "@/lib/tool-explorer-grouping";
21import type { ToolDescriptor } from "@/lib/types";
22import { SelectableToolRow } from "./tool-explorer-rows";
23
24export 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
158export 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 @@
1import type { ToolDescriptor } from "@/lib/types";
2import type { ToolGroup } from "@/lib/tool-explorer-grouping";
3
4export 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
24function 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 @@
1import { Loader2 } from "lucide-react";
2import { displaySourceName } from "@/lib/tool-source-utils";
3import type { ToolSourceRecord } from "@/lib/types";
4
5export 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
3import { useCallback, useState, memo } from "react";
4import {
5 Check,
6 ChevronDown,
7 ChevronRight,
8 Search,
9 ShieldCheck,
10 Zap,
11} from "lucide-react";
12import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
13import { cn } from "@/lib/utils";
14import type { ToolDescriptor } from "@/lib/types";
15import { CopyButton } from "./tool-explorer-copy-button";
16import { ToolDetail } from "./tool-explorer-tool-detail";
17
18const 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
96export 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
132export 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
164export 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
3import { Streamdown } from "streamdown";
4import { createCodePlugin } from "@streamdown/code";
5import type { ToolDescriptor } from "@/lib/types";
6import { TypeSignature } from "./tool-explorer-type-signature";
7
8const codePlugin = createCodePlugin({
9 themes: ["github-light", "github-dark"],
10});
11
12export 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 @@
1import {
2 Search,
3 ShieldCheck,
4 Server,
5 Filter,
6 X,
7 FolderTree,
8 List,
9} from "lucide-react";
10import { Input } from "@/components/ui/input";
11import { Button } from "@/components/ui/button";
12import {
13 DropdownMenu,
14 DropdownMenuContent,
15 DropdownMenuCheckboxItem,
16 DropdownMenuLabel,
17 DropdownMenuSeparator,
18 DropdownMenuTrigger,
19} from "@/components/ui/dropdown-menu";
20import { cn } from "@/lib/utils";
21
22export type GroupBy = "source" | "namespace" | "approval";
23export type ViewMode = "tree" | "flat";
24type FilterApproval = "all" | "required" | "auto";
25
26interface 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
51export 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
3import { useEffect, useMemo, useState } from "react";
4
5function 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
49export 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 @@
1"use client";1"use client";
2 2
3import { useState, useMemo, useCallback, useRef } from "react";
4import { cn } from "@/lib/utils";
3import {5import {
4 useState,6 collectGroupKeys,
5 useMemo,7} from "@/lib/tool-explorer-grouping";
6 useCallback,8import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types";
7 useEffect,9import { findToolsInGroupByKey } from "./tool-explorer-helpers";
8 useRef,
9 memo,
10} from "react";
11import {
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";
28import { Input } from "@/components/ui/input";
29import { Button } from "@/components/ui/button";
30import {10import {
31 DropdownMenu,11 autoExpandedKeysForSearch,
32 DropdownMenuContent,12 countSelectedTools,
33 DropdownMenuCheckboxItem,13 expandedKeysForSource,
34 DropdownMenuLabel,14 filterToolsBySearch,
35 DropdownMenuSeparator,15 filterToolsBySourceAndApproval,
36 DropdownMenuTrigger,16 flatToolsForView,
37} from "@/components/ui/dropdown-menu";17 sourceOptionsFromTools,
18 treeGroupsForView,
19 type FilterApproval,
20} from "./tool-explorer-derived";
21import { ToolExplorerLoading } from "./tool-explorer-loading";
38import {22import {
39 Tooltip,23 EmptyState,
40 TooltipContent,24 VirtualFlatList,
41 TooltipProvider,25} from "./tool-explorer-rows";
42 TooltipTrigger,26import { GroupNode, SourceSidebar } from "./tool-explorer-groups";
43} from "@/components/ui/tooltip";
44import {27import {
45 Collapsible,28 ToolExplorerToolbar,
46 CollapsibleContent,29 type GroupBy,
47 CollapsibleTrigger,30 type ViewMode,
48} from "@/components/ui/collapsible";31} from "./tool-explorer-toolbar";
49import { Streamdown } from "streamdown";
50import { createCodePlugin } from "@streamdown/code";
51import { cn } from "@/lib/utils";
52import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types";
53
54// ── Streamdown code plugin (light + dark dual-theme) ──
55const codePlugin = createCodePlugin({
56 themes: ["github-light", "github-dark"],
57});
58
59// ── Helpers ──
60
61function 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
67function 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
73function 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
79function toolOperation(path: string): string {
80 const parts = path.split(".");
81 return parts[parts.length - 1];
82}
83
84function 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
92function 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
120interface 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
130type GroupBy = "source" | "namespace" | "approval";
131type ViewMode = "tree" | "flat";
132
133// ── Build grouped data structures ──
134
135function 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
190function 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
214function 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
243function 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
262function 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
311function 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
358function 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
396function 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
441const 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
527function 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 */
670const 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
708function 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}
787 32
788// ── Main Explorer ──33// ── Main Explorer ──
789 34
@@ -811,16 +56,12 @@ export function ToolExplorer({ · 21 unmodified lines
811 const [search, setSearch] = useState("");56 const [search, setSearch] = useState("");
812 const [viewMode, setViewMode] = useState<ViewMode>("tree");57 const [viewMode, setViewMode] = useState<ViewMode>("tree");
813 const [groupBy, setGroupBy] = useState<GroupBy>("source");58 const [groupBy, setGroupBy] = useState<GroupBy>("source");
814 const [internalActiveSource, setInternalActiveSource] = useState<string | null>(59 const [internalActiveSource, setInternalActiveSource] = useState<string | null>(initialSource);
815 initialSource,
816 );
817 const [expandedKeys, setExpandedKeys] = useState<Set<string>>(60 const [expandedKeys, setExpandedKeys] = useState<Set<string>>(
818 () => (initialSource ? new Set([`source:${initialSource}`]) : new Set()),61 () => expandedKeysForSource(initialSource),
819 );62 );
820 const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());63 const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
821 const [filterApproval, setFilterApproval] = useState<64 const [filterApproval, setFilterApproval] = useState<FilterApproval>("all");
822 "all" | "required" | "auto"
823 >("all");
824 const treeListRef = useRef<HTMLDivElement>(null);65 const treeListRef = useRef<HTMLDivElement>(null);
825 const flatListRef = useRef<HTMLDivElement>(null);66 const flatListRef = useRef<HTMLDivElement>(null);
826 const resolvedActiveSource =67 const resolvedActiveSource =
@@ -830,78 +71,35 @@ export function ToolExplorer({ · 3 unmodified lines
830 if (activeSource === undefined) {71 if (activeSource === undefined) {
831 setInternalActiveSource(source);72 setInternalActiveSource(source);
832 }73 }
74
833 onActiveSourceChange?.(source);75 onActiveSourceChange?.(source);
834 setExpandedKeys(source ? new Set([`source:${source}`]) : new Set());76 setExpandedKeys(expandedKeysForSource(source));
835 }, [activeSource, onActiveSourceChange]);77 }, [activeSource, onActiveSourceChange]);
836 78
837 // Filter tools by active source and approval filter
838 const filteredTools = useMemo(() => {79 const filteredTools = useMemo(() => {
839 let result = tools;80 return filterToolsBySourceAndApproval(
840 81 tools,
841 if (resolvedActiveSource) {82 resolvedActiveSource,
842 result = result.filter(83 filterApproval,
843 (t) => sourceLabel(t.source) === resolvedActiveSource,84 );
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;
854 }, [tools, resolvedActiveSource, filterApproval]);85 }, [tools, resolvedActiveSource, filterApproval]);
855 86
856 // Apply search filter
857 const searchedTools = useMemo(() => {87 const searchedTools = useMemo(() => {
858 if (!search) return filteredTools;88 return filterToolsBySearch(filteredTools, search);
859 const lowerSearch = search.toLowerCase();
860 return filteredTools.filter(
861 (t) =>
862 t.path.toLowerCase().includes(lowerSearch) ||
863 t.description.toLowerCase().includes(lowerSearch),
864 );
865 }, [filteredTools, search]);89 }, [filteredTools, search]);
866 90
867 // Build grouped tree
868 const treeGroups = useMemo(() => {91 const treeGroups = useMemo(() => {
869 if (viewMode === "flat") return [];92 return treeGroupsForView(searchedTools, viewMode, groupBy);
870 if (groupBy === "source") return buildSourceTree(searchedTools);
871 if (groupBy === "namespace") return buildNamespaceTree(searchedTools);
872 return buildApprovalTree(searchedTools);
873 }, [searchedTools, viewMode, groupBy]);93 }, [searchedTools, viewMode, groupBy]);
874 94
875 // Flat list
876 const flatTools = useMemo(() => {95 const flatTools = useMemo(() => {
877 if (viewMode !== "flat") return [];96 return flatToolsForView(searchedTools, viewMode);
878 return [...searchedTools].sort((a, b) => a.path.localeCompare(b.path));
879 }, [searchedTools, viewMode]);97 }, [searchedTools, viewMode]);
880 98
881 // Auto-expand matching groups while searching without mutating user state.
882 const autoExpandedKeys = useMemo(() => {99 const autoExpandedKeys = useMemo(() => {
883 if (search.length < 2 || viewMode !== "tree") {100 return autoExpandedKeysForSearch(search, filteredTools, viewMode);
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;
904 }, [search, filteredTools, viewMode]);101 }, [search, filteredTools, viewMode]);
102
905 const visibleExpandedKeys = autoExpandedKeys ?? expandedKeys;103 const visibleExpandedKeys = autoExpandedKeys ?? expandedKeys;
906 104
907 const toggleExpand = useCallback((key: string) => {105 const toggleExpand = useCallback((key: string) => {
@@ -939,44 +137,7 @@ export function ToolExplorer({ · 31 unmodified lines
939 e.preventDefault();137 e.preventDefault();
940 setSelectedKeys((prev) => {138 setSelectedKeys((prev) => {
941 const next = new Set(prev);139 const next = new Set(prev);
942 140 const childTools = findToolsInGroupByKey(treeGroups, key);
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);
980 const allSelected =141 const allSelected =
981 childTools.length > 0 &&142 childTools.length > 0 &&
982 childTools.every((t) => prev.has(t.path));143 childTools.every((t) => prev.has(t.path));
@@ -1003,11 +164,19 @@ export function ToolExplorer({ · 20 unmodified lines
1003 }, [filteredTools]);164 }, [filteredTools]);
1004 165
1005 const selectedToolCount = useMemo(() => {166 const selectedToolCount = useMemo(() => {
1006 return Array.from(selectedKeys).filter((k) =>167 return countSelectedTools(selectedKeys, filteredTools);
1007 filteredTools.some((t) => t.path === k),
1008 ).length;
1009 }, [selectedKeys, filteredTools]);168 }, [selectedKeys, filteredTools]);
1010 169
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
1011 const handleExplorerWheel = useCallback(180 const handleExplorerWheel = useCallback(
1012 (e: React.WheelEvent<HTMLDivElement>) => {181 (e: React.WheelEvent<HTMLDivElement>) => {
1013 const listEl = viewMode === "flat" ? flatListRef.current : treeListRef.current;182 const listEl = viewMode === "flat" ? flatListRef.current : treeListRef.current;
@@ -1028,33 +197,7 @@ export function ToolExplorer({ · 14 unmodified lines
1028 [viewMode],197 [viewMode],
1029 );198 );
1030 199
1031 if (loading) {200 if (loading) return <ToolExplorerLoading sources={sources} />;
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 }
1058 201
1059 return (202 return (
1060 <div className="flex" onWheelCapture={handleExplorerWheel}>203 <div className="flex" onWheelCapture={handleExplorerWheel}>
@@ -1066,284 +209,37 @@ export function ToolExplorer({ · 5 unmodified lines
1066 />209 />
1067 ) : null}210 ) : null}
1068 211
1069 {/* Main content */}212 <div
1070 <div className={cn("flex-1 min-w-0 flex flex-col", showSourceSidebar ? "pl-0 lg:pl-4" : "pl-0")}>213 className={cn(
1071 <div className="shrink-0">214 "flex-1 min-w-0 flex flex-col",
1072 {/* Toolbar */}215 showSourceSidebar ? "pl-0 lg:pl-4" : "pl-0",
1073 <div className="flex items-center gap-2 pb-3 flex-wrap">216 )}
1074 {/* Search */}217 >
1075 <div className="relative flex-1 min-w-[200px]">218 <ToolExplorerToolbar
1076 <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />219 search={search}
1077 <Input220 filteredToolCount={filteredTools.length}
1078 value={search}221 hasSearch={search.length > 0}
1079 onChange={(e) => setSearch(e.target.value)}222 resultCount={searchedTools.length}
1080 placeholder={`Search ${filteredTools.length} tools...`}223 viewMode={viewMode}
1081 className="h-8 text-xs pl-8 bg-background/50 border-border/50 focus:border-primary/30"224 groupBy={groupBy}
1082 />225 filterApproval={filterApproval}
1083 {search && (226 showSourceSidebar={showSourceSidebar}
1084 <button227 activeSource={resolvedActiveSource}
1085 onClick={() => setSearch("")}228 sourceOptions={sourceOptions}
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"229 selectedToolCount={selectedToolCount}
1087 >230 warningsCount={warnings.length}
1088 <X className="h-3 w-3" />231 onSearchChange={setSearch}
1089 </button>232 onClearSearch={() => setSearch("")}
1090 )}233 onViewModeChange={setViewMode}
1091 </div>234 onGroupByChange={setGroupBy}
1092 235 onFilterApprovalChange={setFilterApproval}
1093 {/* View mode toggle */}236 onSourceSelect={handleSourceSelect}
1094 <div className="flex h-8 rounded-md border border-border/50 overflow-hidden">237 onSelectAll={selectAll}
1095 <button238 onClearSelection={clearSelection}
1096 onClick={() => setViewMode("tree")}239 onExpandAll={handleExpandAll}
1097 className={cn(240 onCollapseAll={handleCollapseAll}
1098 "px-2 flex items-center gap-1 text-[11px] transition-colors",241 />
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>
1345 242
1346 {/* Scrollable list */}
1347 {viewMode === "flat" ? (243 {viewMode === "flat" ? (
1348 flatTools.length === 0 ? (244 flatTools.length === 0 ? (
1349 <div245 <div
@@ -1390,52 +286,3 @@ export function ToolExplorer({ · 40 unmodified lines
1390 </div>286 </div>
1391 );287 );
1392}288}
1393
1394// ── Flat list ──
1395
1396function 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
1430function 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 @@
1import {
2 type CatalogCollectionItem,
3 HARD_CODED_CATALOG_ITEMS,
4} from "@/lib/catalog-collections";
5
6export type SourceCatalogSort = "popular" | "recent";
7export type SourceType = "mcp" | "openapi" | "graphql";
8
9export const DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY = "userId";
10
11export 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
38export 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
50export 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
62export 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 @@
1import { ChevronRight, Plus } from "lucide-react";
2import Image from "next/image";
3import { Streamdown } from "streamdown";
4import { Button } from "@/components/ui/button";
5import { Input } from "@/components/ui/input";
6import { Label } from "@/components/ui/label";
7import {
8 Select,
9 SelectContent,
10 SelectItem,
11 SelectTrigger,
12 SelectValue,
13} from "@/components/ui/select";
14import { Separator } from "@/components/ui/separator";
15import type { CatalogCollectionItem } from "@/lib/catalog-collections";
16import {
17 endpointLabelForType,
18 endpointPlaceholderForType,
19 type SourceCatalogSort,
20 type SourceType,
21} from "./tools-view-add-source-dialog-helpers";
22
23export 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
140export 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
3import { useMemo, useState } from "react";
4import { Plus } from "lucide-react";
5import { useMutation } from "convex/react";
6import { toast } from "sonner";
7import { Button } from "@/components/ui/button";
8import {
9 Dialog,
10 DialogContent,
11 DialogHeader,
12 DialogTitle,
13 DialogTrigger,
14} from "@/components/ui/dialog";
15import { useSession } from "@/lib/session-context";
16import { convexApi } from "@/lib/convex-api";
17import type { ToolSourceRecord } from "@/lib/types";
18import {
19 type CatalogCollectionItem,
20} from "@/lib/catalog-collections";
21import {
22 catalogSourceName,
23 inferNameFromUrl,
24 withUniqueSourceName,
25} from "@/lib/tools-source-helpers";
26import {
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";
33import {
34 CatalogViewSection,
35 CustomViewSection,
36} from "./tools-view-add-source-dialog-sections";
37
38export 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
3import { Input } from "@/components/ui/input";
4import { Label } from "@/components/ui/label";
5import type { SourceAuthType } from "@/lib/types";
6import type { ConnectionMode } from "./tools-view-connection-form-dialog-state";
7
8type SelectedAuth = {
9 type: SourceAuthType;
10};
11
12type 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
26export 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 @@
1import { useEffect, useMemo, useReducer } from "react";
2import type {
3 CredentialRecord,
4 CredentialScope,
5 SourceAuthProfile,
6 ToolSourceRecord,
7} from "@/lib/types";
8import {
9 formatHeaderOverrides,
10 sourceAuthForKey,
11} from "@/lib/credentials-source-helpers";
12import {
13 buildConnectionOptions,
14 buildSourceOptions,
15 compatibleConnections,
16 selectedAuthBadge,
17 type ConnectionMode,
18} from "./tools-view-connection-form-dialog-state";
19
20type 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
30type 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
43type FormAction =
44 | { type: "patch"; patch: Partial<FormState> }
45 | { type: "reset"; next: FormState };
46
47function formReducer(state: FormState, action: FormAction): FormState {
48 if (action.type === "reset") {
49 return action.next;
50 }
51 return { ...state, ...action.patch };
52}
53
54function 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
98export 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 @@
1import type {
2 CredentialRecord,
3 CredentialScope,
4 SourceAuthType,
5 ToolSourceRecord,
6} from "@/lib/types";
7import {
8 sourceOptionLabel,
9 type SourceOption,
10} from "@/lib/credentials-source-helpers";
11import { sourceKeyForSource } from "@/lib/tools-source-helpers";
12
13export type ConnectionMode = "new" | "existing";
14
15export type ConnectionOption = {
16 id: string;
17 scope: CredentialScope;
18 actorId?: string;
19 sourceKeys: Set<string>;
20 updatedAt: number;
21};
22
23export 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
36export 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
58export 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
74export 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
3import { useState } from "react";
4import { useAction } from "convex/react";
5import { toast } from "sonner";
6import { Button } from "@/components/ui/button";
7import { Input } from "@/components/ui/input";
8import { Label } from "@/components/ui/label";
9import { Textarea } from "@/components/ui/textarea";
10import { Badge } from "@/components/ui/badge";
11import {
12 Select,
13 SelectContent,
14 SelectItem,
15 SelectTrigger,
16 SelectValue,
17} from "@/components/ui/select";
18import {
19 Dialog,
20 DialogContent,
21 DialogHeader,
22 DialogTitle,
23} from "@/components/ui/dialog";
24import { useSession } from "@/lib/session-context";
25import { convexApi } from "@/lib/convex-api";
26import type {
27 CredentialRecord,
28 CredentialScope,
29 SourceAuthProfile,
30 ToolSourceRecord,
31} from "@/lib/types";
32import {
33 connectionDisplayName,
34 parseHeaderOverrides,
35} from "@/lib/credentials-source-helpers";
36import { ConnectionAuthFields } from "./tools-view-connection-auth-fields";
37import { type ConnectionMode } from "./tools-view-connection-form-dialog-state";
38import { useConnectionFormDialogForm } from "./tools-view-connection-form-dialog-form";
39import {
40 buildSecretJson,
41 connectionSubmitCopy,
42 connectionSuccessCopy,
43} from "./tools-view-connection-form-save";
44
45export 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 @@
1import type { SourceAuthType } from "@/lib/types";
2
3type 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
14export 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
64export function connectionSuccessCopy(editing: boolean, linkExisting: boolean): string {
65 return editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved";
66}
67
68export 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
3import { useMemo, useState } from "react";
4import { KeyRound, Plus } from "lucide-react";
5import Image from "next/image";
6import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7import { Button } from "@/components/ui/button";
8import { Badge } from "@/components/ui/badge";
9import { Skeleton } from "@/components/ui/skeleton";
10import { useSession } from "@/lib/session-context";
11import type {
12 CredentialRecord,
13 CredentialScope,
14 SourceAuthProfile,
15 ToolSourceRecord,
16} from "@/lib/types";
17import {
18 connectionDisplayName,
19 providerLabel,
20} from "@/lib/credentials-source-helpers";
21import {
22 getSourceFavicon,
23 sourceForCredentialKey,
24} from "@/lib/tools-source-helpers";
25import { ConnectionFormDialog } from "@/components/tools-view-connection-form-dialog";
26
27export 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
3import { useState } from "react";
4import { Pencil } from "lucide-react";
5import { useMutation } from "convex/react";
6import { toast } from "sonner";
7import { Button } from "@/components/ui/button";
8import { Input } from "@/components/ui/input";
9import { Label } from "@/components/ui/label";
10import {
11 Select,
12 SelectContent,
13 SelectItem,
14 SelectTrigger,
15 SelectValue,
16} from "@/components/ui/select";
17import {
18 Dialog,
19 DialogContent,
20 DialogHeader,
21 DialogTitle,
22 DialogTrigger,
23} from "@/components/ui/dialog";
24import { useSession } from "@/lib/session-context";
25import { convexApi } from "@/lib/convex-api";
26import type { SourceAuthProfile, ToolSourceRecord } from "@/lib/types";
27import {
28 readSourceAuth,
29 sourceKeyForSource,
30 type SourceAuthMode,
31 type SourceAuthType,
32} from "@/lib/tools-source-helpers";
33
34export 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
3import { useState } from "react";
4import { Globe, Server, Trash2 } from "lucide-react";
5import { useMutation } from "convex/react";
6import Image from "next/image";
7import { toast } from "sonner";
8import { Button } from "@/components/ui/button";
9import { Badge } from "@/components/ui/badge";
10import { useSession } from "@/lib/session-context";
11import { convexApi } from "@/lib/convex-api";
12import type {
13 OpenApiSourceQuality,
14 SourceAuthProfile,
15 ToolSourceRecord,
16} from "@/lib/types";
17import { cn } from "@/lib/utils";
18import { displaySourceName } from "@/lib/tool-source-utils";
19import {
20 compactEndpointLabel,
21 formatSourceAuthBadge,
22 getSourceFavicon,
23 readSourceAuth,
24 sourceEndpointLabel,
25 sourceKeyForSource,
26} from "@/lib/tools-source-helpers";
27import { ConfigureSourceAuthDialog } from "./tools-view-source-auth-dialog";
28import {
29 OpenApiQualityDetails,
30 SourceQualitySummary,
31} from "./tools-view-source-quality-details";
32
33export 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
3import { useState } from "react";
4import { ChevronRight } from "lucide-react";
5import { Badge } from "@/components/ui/badge";
6import { Button } from "@/components/ui/button";
7import {
8 Collapsible,
9 CollapsibleContent,
10 CollapsibleTrigger,
11} from "@/components/ui/collapsible";
12import type { OpenApiSourceQuality } from "@/lib/types";
13import { cn } from "@/lib/utils";
14import {
15 formatQualityPercent,
16 qualitySummaryLabel,
17 qualityToneClass,
18} from "@/lib/tools-source-helpers";
19
20export 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
49export 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 @@
1export { AddSourceDialog } from "./tools-view-add-source-dialog";
2export { SourceCard } from "./tools-view-source-card";
executor/apps/web/src/components/tools-view.tsx+12−1846
@@ -1,1867 +1,33 @@
1"use client";1"use client";
2 2
3import { useEffect, useMemo, useState } from "react";3import { useState } from "react";
4import {4import {
5 Wrench,
6 Plus,
7 Trash2,
8 Globe,5 Globe,
9 Server,6 Server,
10 ChevronRight,
11 AlertTriangle,
12 KeyRound,
13 Pencil,
14} from "lucide-react";7} from "lucide-react";
15import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";8import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
16import { Button } from "@/components/ui/button";9import { Button } from "@/components/ui/button";
17import { Input } from "@/components/ui/input";
18import { Label } from "@/components/ui/label";
19import { Textarea } from "@/components/ui/textarea";
20import { Badge } from "@/components/ui/badge";
21import {
22 Select,
23 SelectContent,
24 SelectItem,
25 SelectTrigger,
26 SelectValue,
27} from "@/components/ui/select";
28import {
29 Dialog,
30 DialogContent,
31 DialogHeader,
32 DialogTitle,
33 DialogTrigger,
34} from "@/components/ui/dialog";
35import {
36 Collapsible,
37 CollapsibleContent,
38 CollapsibleTrigger,
39} from "@/components/ui/collapsible";
40import { Separator } from "@/components/ui/separator";
41import { Skeleton } from "@/components/ui/skeleton";10import { Skeleton } from "@/components/ui/skeleton";
42import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";11import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
43import { PageHeader } from "@/components/page-header";12import { PageHeader } from "@/components/page-header";
44import { McpSetupCard } from "@/components/mcp-setup-card";13import { McpSetupCard } from "@/components/mcp-setup-card";
45import { ToolExplorer } from "@/components/tool-explorer";14import { ToolExplorer } from "@/components/tool-explorer";
15import { AddSourceDialog, SourceCard } from "@/components/tools-view-sources";
16import { CredentialsPanel } from "@/components/tools-view-credentials";
46import { useSession } from "@/lib/session-context";17import { useSession } from "@/lib/session-context";
47import { useWorkspaceTools } from "@/hooks/use-workspace-tools";18import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
48import { useAction, useMutation, useQuery } from "convex/react";19import { useQuery } from "convex/react";
49import { convexApi } from "@/lib/convex-api";20import { convexApi } from "@/lib/convex-api";
50import type {21import type {
51 ToolSourceRecord,22 ToolSourceRecord,
52 CredentialRecord,23 CredentialRecord,
53 CredentialScope,
54 SourceAuthProfile,
55 OpenApiSourceQuality,
56} from "@/lib/types";24} from "@/lib/types";
57import { parse as parseDomain } from "tldts";25import {
58import { toast } from "sonner";26 credentialStatsForSource,
59import { cn } from "@/lib/utils";27 sourceKeyForSource,
60import { Streamdown } from "streamdown";28 toolSourceLabelForSource,
61 29} from "@/lib/tools-source-helpers";
62interface CatalogCollectionItem {30import { workspaceQueryArgs } from "@/lib/workspace-query-args";
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
76const 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. */
176function 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
186function 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
203function 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
219function 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
225function toolSourceLabelForSource(source: ToolSourceRecord): string {
226 return `${source.type}:${source.name}`;
227}
228
229function 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
237type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
238type SourceAuthMode = "workspace" | "actor";
239
240function 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
273function 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
311function 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
327function 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
347function formatQualityPercent(value: number): string {
348 return `${Math.round(value * 100)}%`;
349}
350
351function 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
361function 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
371function 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
397function 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
411const RAW_HOSTS = new Set([
412 "raw.githubusercontent.com",
413 "cdn.jsdelivr.net",
414 "unpkg.com",
415 "raw.github.com",
416]);
417
418function 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
442function 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
452function 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
469function catalogSourceName(item: CatalogCollectionItem): string {
470 const owner = sanitizeSourceName(item.providerName || "catalog");
471 const title = sanitizeSourceName(item.name);
472 return sanitizeSourceName(`${owner}-${title}`);
473}
474
475function 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
869function 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
1021function 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
1224type SourceOption = { source: ToolSourceRecord; key: string; label: string };
1225
1226function 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
1243function sourceOptionLabel(source: ToolSourceRecord): string {
1244 return `${source.name} (${source.type})`;
1245}
1246
1247function providerLabel(provider: "local-convex" | "workos-vault"): string {
1248 return provider === "workos-vault" ? "encrypted" : "local";
1249}
1250
1251function 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
1278function 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
1304function 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
1311function 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}
1865 31
1866// ── Tool Inventory (legacy removed — replaced by ToolExplorer) ──32// ── Tool Inventory (legacy removed — replaced by ToolExplorer) ──
1867 33
@@ -1875,14 +41,14 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 7 unmodified lines
1875 41
1876 const sources = useQuery(42 const sources = useQuery(
1877 convexApi.workspace.listToolSources,43 convexApi.workspace.listToolSources,
1878 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",44 workspaceQueryArgs(context),
1879 );45 );
1880 const sourceItems: ToolSourceRecord[] = sources ?? [];46 const sourceItems: ToolSourceRecord[] = sources ?? [];
1881 const sourcesLoading = !!context && sources === undefined;47 const sourcesLoading = !!context && sources === undefined;
1882 48
1883 const credentials = useQuery(49 const credentials = useQuery(
1884 convexApi.workspace.listCredentials,50 convexApi.workspace.listCredentials,
1885 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",51 workspaceQueryArgs(context),
1886 );52 );
1887 const credentialItems: CredentialRecord[] = credentials ?? [];53 const credentialItems: CredentialRecord[] = credentials ?? [];
1888 const credentialsLoading = !!context && credentials === undefined;54 const credentialsLoading = !!context && credentials === undefined;
executor/apps/web/src/components/workspace-selector.tsx+351−0
@@ -0,0 +1,351 @@
1"use client";
2
3import { useMemo, useState, type FormEvent } from "react";
4import { useNavigate } from "react-router";
5import { useMutation } from "convex/react";
6import { ChevronsUpDown, Plus, Check, Settings } from "lucide-react";
7import Image from "next/image";
8import { Button } from "@/components/ui/button";
9import {
10 DropdownMenu,
11 DropdownMenuContent,
12 DropdownMenuItem,
13 DropdownMenuSeparator,
14 DropdownMenuTrigger,
15} from "@/components/ui/dropdown-menu";
16import {
17 Dialog,
18 DialogContent,
19 DialogDescription,
20 DialogFooter,
21 DialogHeader,
22 DialogTitle,
23} from "@/components/ui/dialog";
24import { Input } from "@/components/ui/input";
25import { useSession } from "@/lib/session-context";
26import { convexApi } from "@/lib/convex-api";
27import { cn } from "@/lib/utils";
28
29export 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 @@
1export type FormattedApprovalInput = {
2 content: string;
3 language: "json" | "text";
4};
5
6export type FormatApprovalInputOptions = {
7 hideSerializedNull?: boolean;
8 hideSerializedEmptyObject?: boolean;
9};
10
11function 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
26export 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 @@
1export 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
15export 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 @@
1import { displaySourceName } from "@/lib/tool-source-utils";
2import { readSourceAuth, sourceForCredentialKey, type SourceAuthMode, type SourceAuthType } from "@/lib/tools-source-helpers";
3import type { CredentialScope, SourceAuthProfile, ToolSourceRecord } from "@/lib/types";
4
5export type SourceOption = { source: ToolSourceRecord; key: string; label: string };
6
7export 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
24export function sourceOptionLabel(source: ToolSourceRecord): string {
25 return `${source.name} (${source.type})`;
26}
27
28export function providerLabel(provider: "local-convex" | "workos-vault"): string {
29 return provider === "workos-vault" ? "encrypted" : "local";
30}
31
32export 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
59export 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
85export 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 @@
1import type { Id } from "@executor/convex/_generated/dataModel";
2import type { AnonymousContext } from "@/lib/types";
3
4export 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
13export 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
24export 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
34export 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
57export 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
97export 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
132export 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
13import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";13import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";
14import { useWorkosAuthState } from "@/lib/convex-provider";14import { useWorkosAuthState } from "@/lib/convex-provider";
15import { convexApi } from "@/lib/convex-api";15import { convexApi } from "@/lib/convex-api";
16import {
17 buildAccountWorkspaceContext,
18 buildWorkspaceOptions,
19 queryErrorMessage,
20 resolveActiveWorkspaceId,
21 type WorkosAccount,
22 type WorkspaceListItem,
23} from "@/lib/session-context-derivation";
24import {
25 clearSessionStorage,
26 persistActiveWorkspaceId,
27 persistSessionId,
28 persistWorkspaceForAccount,
29 readStoredActiveWorkspaceId,
30 readStoredSessionId,
31 readWorkspaceByAccount,
32} from "@/lib/session-storage";
16import type { AnonymousContext } from "./types";33import type { AnonymousContext } from "./types";
17import type { Id } from "@executor/convex/_generated/dataModel";34import type { Id } from "@executor/convex/_generated/dataModel";
18 35
@@ -65,26 +82,6 @@ interface SessionState { · 46 unmodified lines
65 resetWorkspace: () => Promise<void>;82 resetWorkspace: () => Promise<void>;
66}83}
67 84
68interface WorkosAccount {
69 _id: Id<"accounts">;
70 provider: "workos" | "anonymous";
71 providerAccountId: string;
72 email: string;
73 name: string;
74 avatarUrl?: string | null;
75}
76
77interface 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
88const SessionContext = createContext<SessionState>({85const SessionContext = createContext<SessionState>({
89 context: null,86 context: null,
90 loading: true,87 loading: true,
@@ -104,41 +101,13 @@ const SessionContext = createContext<SessionState>({ · 13 unmodified lines
104 resetWorkspace: async () => {},101 resetWorkspace: async () => {},
105});102});
106 103
107const SESSION_KEY = "executor_session_id";
108const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id";
109const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account";
110
111function 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
122function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) {
123 localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value));
124}
125
126export function SessionProvider({ children }: { children: ReactNode }) {104export function SessionProvider({ children }: { children: ReactNode }) {
127 const { loading: workosAuthLoading, authenticated: workosAuthenticated } = useWorkosAuthState();105 const { loading: workosAuthLoading, authenticated: workosAuthenticated } = useWorkosAuthState();
128 const bootstrapAnonymousSession = useMutation(convexApi.workspace.bootstrapAnonymousSession);106 const bootstrapAnonymousSession = useMutation(convexApi.workspace.bootstrapAnonymousSession);
129 const [storedSessionId, setStoredSessionId] = useState<string | null>(() => {107 const [storedSessionId, setStoredSessionId] = useState<string | null>(readStoredSessionId);
130 if (typeof window === "undefined") {108 const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>(
131 return null;109 readStoredActiveWorkspaceId,
132 }110 );
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 });
142 const [runtimeError, setRuntimeError] = useState<string | null>(null);111 const [runtimeError, setRuntimeError] = useState<string | null>(null);
143 112
144 const clientConfig = useConvexQuery(convexApi.app.getClientConfig, {});113 const clientConfig = useConvexQuery(convexApi.app.getClientConfig, {});
@@ -161,7 +130,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 16 unmodified lines
161 }130 }
162 131
163 const context = await bootstrapAnonymousSession({ sessionId: storedSessionId });132 const context = await bootstrapAnonymousSession({ sessionId: storedSessionId });
164 localStorage.setItem(SESSION_KEY, context.sessionId);133 persistSessionId(context.sessionId);
165 if (context.sessionId !== storedSessionId) {134 if (context.sessionId !== storedSessionId) {
166 setStoredSessionId(context.sessionId);135 setStoredSessionId(context.sessionId);
167 }136 }
@@ -185,21 +154,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 17 unmodified lines
185 );154 );
186 155
187 const resolvedActiveWorkspaceId = useMemo(() => {156 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
196 const accountId = account?._id ?? null;157 const accountId = account?._id ?? null;
197 const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null;158 const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null;
198 if (accountStoredWorkspace && workspaces.some((workspace) => workspace.id === accountStoredWorkspace)) {159 return resolveActiveWorkspaceId(workspaces, activeWorkspaceId, accountStoredWorkspace);
199 return accountStoredWorkspace;
200 }
201
202 return workspaces[0]?.id ?? null;
203 }, [workspaces, activeWorkspaceId, account]);160 }, [workspaces, activeWorkspaceId, account]);
204 161
205 const bootstrapWorkosAccountQuery = useTanstackQuery({162 const bootstrapWorkosAccountQuery = useTanstackQuery({
@@ -219,8 +176,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 13 unmodified lines
219 });176 });
220 177
221 const resetWorkspace = useCallback(async () => {178 const resetWorkspace = useCallback(async () => {
222 localStorage.removeItem(SESSION_KEY);179 clearSessionStorage();
223 localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
224 setStoredSessionId(null);180 setStoredSessionId(null);
225 setActiveWorkspaceId(null);181 setActiveWorkspaceId(null);
226 setManualGuestContext(null);182 setManualGuestContext(null);
@@ -236,8 +192,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 9 unmodified lines
236 setCreatingAnonymousOrganization(true);192 setCreatingAnonymousOrganization(true);
237 try {193 try {
238 const context = await bootstrapAnonymousSession({});194 const context = await bootstrapAnonymousSession({});
239 localStorage.setItem(SESSION_KEY, context.sessionId);195 persistSessionId(context.sessionId);
240 localStorage.setItem(ACTIVE_WORKSPACE_KEY, context.workspaceId);196 persistActiveWorkspaceId(context.workspaceId);
241 setStoredSessionId(context.sessionId);197 setStoredSessionId(context.sessionId);
242 setActiveWorkspaceId(context.workspaceId);198 setActiveWorkspaceId(context.workspaceId);
243 setManualGuestContext(context);199 setManualGuestContext(context);
@@ -252,15 +208,10 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 8 unmodified lines
252 208
253 const switchWorkspace = useCallback((workspaceId: Id<"workspaces">) => {209 const switchWorkspace = useCallback((workspaceId: Id<"workspaces">) => {
254 setActiveWorkspaceId(workspaceId);210 setActiveWorkspaceId(workspaceId);
255 localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId);211 persistActiveWorkspaceId(workspaceId);
256 212
257 if (account) {213 if (account) {
258 const accountId = account._id;214 persistWorkspaceForAccount(account._id, workspaceId);
259 const byAccount = readWorkspaceByAccount();
260 writeWorkspaceByAccount({
261 ...byAccount,
262 [accountId]: workspaceId,
263 });
264 }215 }
265 }, [account]);216 }, [account]);
266 217
@@ -323,37 +274,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 56 unmodified lines
323 ]);274 ]);
324 275
325 const accountWorkspaceContext = useMemo<AnonymousContext | null>(() => {276 const accountWorkspaceContext = useMemo<AnonymousContext | null>(() => {
326 if (!account || !workspaces || workspaces.length === 0) {277 return buildAccountWorkspaceContext(
327 return null;278 account,
328 }279 workspaces,
329 280 resolvedActiveWorkspaceId,
330 const activeWorkspace =281 storedSessionId,
331 workspaces.find((workspace) => workspace.id === resolvedActiveWorkspaceId)282 guestContext?.sessionId ?? null,
332 ?? workspaces[0]283 );
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 };
357 }, [account, guestContext?.sessionId, resolvedActiveWorkspaceId, storedSessionId, workspaces]);284 }, [account, guestContext?.sessionId, resolvedActiveWorkspaceId, storedSessionId, workspaces]);
358 285
359 // When WorkOS is enabled, don't fall back to guest context while WorkOS286 // 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
372 const context = accountWorkspaceContext ?? ((workosStillLoading || shouldBlockGuestFallback) ? null : guestContext);299 const context = accountWorkspaceContext ?? ((workosStillLoading || shouldBlockGuestFallback) ? null : guestContext);
373 300
374 const bootstrapSessionError =301 const bootstrapSessionError =
375 storedSessionId && bootstrapSessionQuery.error instanceof Error302 storedSessionId
376 ? bootstrapSessionQuery.error.message303 ? queryErrorMessage(bootstrapSessionQuery.error, "Failed to bootstrap session")
377 : storedSessionId && bootstrapSessionQuery.error304 : null;
378 ? "Failed to bootstrap session"305 const bootstrapWorkosError = queryErrorMessage(
379 : null;306 bootstrapWorkosAccountQuery.error,
380 const bootstrapWorkosError =307 "Failed to bootstrap WorkOS account",
381 bootstrapWorkosAccountQuery.error instanceof Error308 );
382 ? bootstrapWorkosAccountQuery.error.message
383 : bootstrapWorkosAccountQuery.error
384 ? "Failed to bootstrap WorkOS account"
385 : null;
386 const error = runtimeError ?? bootstrapSessionError ?? bootstrapWorkosError;309 const error = runtimeError ?? bootstrapSessionError ?? bootstrapWorkosError;
387 310
388 const effectiveLoading = !context && !error && (311 const effectiveLoading = !context && !error && (
@@ -392,34 +315,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 3 unmodified lines
392 || bootstrapWorkosAccountQuery.isFetching315 || bootstrapWorkosAccountQuery.isFetching
393 );316 );
394 const workspaceOptions = useMemo(() => {317 const workspaceOptions = useMemo(() => {
395 if (mode !== "guest" && workspaces) {318 return buildWorkspaceOptions(mode, workspaces, guestContext);
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 [];
423 }, [mode, workspaces, guestContext]);319 }, [mode, workspaces, guestContext]);
424 320
425 return (321 return (
executor/apps/web/src/lib/session-storage.ts+60−0
@@ -0,0 +1,60 @@
1import type { Id } from "@executor/convex/_generated/dataModel";
2
3const SESSION_KEY = "executor_session_id";
4const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id";
5const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account";
6
7export function readStoredSessionId() {
8 if (typeof window === "undefined") {
9 return null;
10 }
11
12 return localStorage.getItem(SESSION_KEY);
13}
14
15export 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
23export function persistSessionId(sessionId: string) {
24 localStorage.setItem(SESSION_KEY, sessionId);
25}
26
27export function persistActiveWorkspaceId(workspaceId: Id<"workspaces">) {
28 localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId);
29}
30
31export function clearSessionStorage() {
32 localStorage.removeItem(SESSION_KEY);
33 localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
34}
35
36export 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
50export function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) {
51 localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value));
52}
53
54export 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 @@
1import { sourceLabel, sourceType } from "@/lib/tool-source-utils";
2import type { ToolDescriptor } from "@/lib/types";
3
4export 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
14export 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
20function 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
28export function toolOperation(path: string): string {
29 const parts = path.split(".");
30 return parts[parts.length - 1];
31}
32
33export 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
88export 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
112export 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
141export 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 @@
1export 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
7export 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
13export 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 @@
1import { parse as parseDomain } from "tldts";
2import type {
3 CredentialRecord,
4 OpenApiSourceQuality,
5 SourceAuthProfile,
6 ToolSourceRecord,
7} from "@/lib/types";
8
9export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
10export type SourceAuthMode = "workspace" | "actor";
11
12const RAW_HOSTS = new Set([
13 "raw.githubusercontent.com",
14 "cdn.jsdelivr.net",
15 "unpkg.com",
16 "raw.github.com",
17]);
18
19export 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
29export 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
46export 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
62export 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
68export function toolSourceLabelForSource(source: ToolSourceRecord): string {
69 return `${source.type}:${source.name}`;
70}
71
72export 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
80export 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
113export 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
151export 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
167export 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
187export function formatQualityPercent(value: number): string {
188 return `${Math.round(value * 100)}%`;
189}
190
191export 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
201export 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
211export 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
223export 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
247export 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
257export 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
274export 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 @@
1import type { AnonymousContext } from "@/lib/types";
2
3export 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 @@
1import { getAccountByWorkosId } from "./auth-db-queries";
2import type { DbCtx } from "./auth-types";
3
4type 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
15export 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 @@
1import type { MutationCtx } from "./_generated/server";
2import { upsertWorkosAccount } from "./auth-accounts";
3import { getOrganizationByWorkosOrgId } from "./auth-db-queries";
4import { getAuthKitUserProfile, resolveIdentityProfile } from "./auth-identity";
5import {
6 ensureWorkspaceMembership,
7 mapOrganizationRoleToWorkspaceRole,
8 markPendingInvitesAcceptedByEmail,
9 upsertOrganizationMembership,
10} from "./auth-memberships";
11import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./auth-personal-workspace";
12import type { AccountId } from "./auth-types";
13
14async 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
54async 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
79async 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
89export 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 @@
1import type { Doc } from "./_generated/dataModel";
2import type { AccountId, DbCtx, OrganizationId } from "./auth-types";
3
4export 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
11export 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
18export 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
25export 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
32export async function getWorkspaceMembershipByWorkspaceAndAccount(
33 ctx: DbCtx,
34 workspaceId: Doc<"workspaces">["_id"],
35 accountId: AccountId,
36) {
37 return await ctx.db
38 .query("workspaceMembers")
39 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))
40 .unique();
41}
executor/packages/convex/auth-event-handlers.ts+335−0
@@ -0,0 +1,335 @@
1import { type AuthKit } from "@convex-dev/workos-authkit";
2import type { DataModel, Doc } from "./_generated/dataModel";
3import type { MutationCtx } from "./_generated/server";
4import { upsertWorkosAccount } from "./auth-accounts";
5import {
6 getAccountByWorkosId,
7 getFirstWorkspaceByOrganizationId,
8 getOrganizationByWorkosOrgId,
9 getWorkspaceByWorkosOrgId,
10} from "./auth-db-queries";
11import {
12 deriveOrganizationMembershipState,
13 deriveWorkspaceMembershipRole,
14 markPendingInvitesAcceptedByEmail,
15 upsertOrganizationMembership,
16} from "./auth-memberships";
17import { ensureUniqueOrganizationSlug } from "./auth-naming";
18
19type 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
29async 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
78export 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 @@
1import { components } from "./_generated/api";
2import type { RunQueryCtx } from "./auth-types";
3
4type WorkosProfile = {
5 email?: string;
6 firstName?: string | null;
7 lastName?: string | null;
8 profilePictureUrl?: string | null;
9};
10
11export 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
21export 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
31export 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 @@
1import type { Doc } from "./_generated/dataModel";
2import type {
3 AccountId,
4 DbCtx,
5 OrganizationId,
6 OrganizationMemberStatus,
7 OrganizationRole,
8 WorkspaceId,
9 WorkspaceMemberRole,
10} from "./auth-types";
11
12export 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
22export 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
64export 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
98export 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
127export function deriveWorkspaceMembershipRole(workosRoleSlug?: string): Doc<"workspaceMembers">["role"] {
128 return workosRoleSlug === "admin" ? "admin" : "member";
129}
130
131export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus {
132 return status === "active" ? "active" : "pending";
133}
executor/packages/convex/auth-naming.ts+89−0
@@ -0,0 +1,89 @@
1import { slugify } from "../core/src/identity";
2import { ensureUniqueSlug } from "../core/src/slug";
3import type { DbCtx } from "./auth-types";
4
5function 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
13function escapeRegex(value: string): string {
14 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15}
16
17export 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
29export 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
41function 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
67export 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
75export function buildPersonalWorkspaceSlugSeed(email: string, workosUserId: string): string {
76 const baseSlug = slugify(email.split("@")[0] ?? workosUserId, "workspace");
77 return `${baseSlug}-${workosUserId.slice(-6)}`;
78}
79
80export 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 @@
1import type { Id } from "./_generated/dataModel";
2import { upsertOrganizationMembership } from "./auth-memberships";
3import {
4 buildPersonalWorkspaceSlugSeed,
5 derivePersonalNames,
6 ensureUniqueOrganizationSlug,
7 isGeneratedPersonalOrganizationName,
8 isGeneratedPersonalWorkspaceName,
9} from "./auth-naming";
10import type { AccountId, DbCtx } from "./auth-types";
11
12type PersonalWorkspaceOptions = {
13 email: string;
14 firstName?: string;
15 fullName?: string;
16 workosUserId: string;
17 now: number;
18};
19
20async 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
60export 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
147export 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 @@
1import type { Doc, Id } from "./_generated/dataModel";
2import type { MutationCtx } from "./_generated/server";
3
4export type DbCtx = Pick<MutationCtx, "db">;
5export type RunQueryCtx = Pick<MutationCtx, "runQuery">;
6
7export type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
8export type OrganizationMemberStatus = "active" | "pending" | "removed";
9export type WorkspaceMemberRole = Doc<"workspaceMembers">["role"];
10
11export type OrganizationId = Id<"organizations">;
12export type WorkspaceId = Id<"workspaces">;
13export type AccountId = Id<"accounts">;
executor/packages/convex/auth.ts+5−894
@@ -1,26 +1,9 @@
1import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";1import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";
2import { components, internal } from "./_generated/api";2import { components, internal } from "./_generated/api";
3import type { DataModel, Doc, Id } from "./_generated/dataModel";3import type { DataModel } from "./_generated/dataModel";
4import type { MutationCtx } from "./_generated/server";
5import { internalMutation, mutation } from "./_generated/server";4import { internalMutation, mutation } from "./_generated/server";
6import { slugify } from "../core/src/identity";5import { bootstrapCurrentWorkosAccountImpl } from "./auth-bootstrap";
7import { ensureUniqueSlug } from "../core/src/slug";6import { workosEventHandlers } from "./auth-event-handlers";
8
9type DbCtx = Pick<MutationCtx, "db">;
10type RunQueryCtx = Pick<MutationCtx, "runQuery">;
11type WorkosEventCtx = Pick<MutationCtx, "db" | "runQuery">;
12type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
13type OrganizationMemberStatus = "active" | "pending" | "removed";
14
15function 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}
24 7
25const workosEnabled = Boolean(8const workosEnabled = Boolean(
26 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,9 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
49 registerRoutes: () => {},32 registerRoutes: () => {},
50 } as Pick<AuthKit<DataModel>, "registerRoutes">);33 } as Pick<AuthKit<DataModel>, "registerRoutes">);
51 34
52function titleCaseWords(input: string): string {35const authKitEvents = workosEnabled && authKitInstance ? authKitInstance.events(workosEventHandlers) : null;
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
60function escapeRegex(value: string): string {
61 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62}
63
64function 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
76function 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
88function 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
114function 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
122async 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
129async 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
136async 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
143async 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
150async 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
161async 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
203async 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
237async 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
266async 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
363async 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
413function 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
423async 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
433const 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
761const authKitEvents = workosEnabled && authKitInstance
762 ? authKitInstance.events(workosEventHandlers)
763 : null;
764 36
765export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({37export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({
766 args: {},38 args: {},
@@ -769,166 +41,5 @@ export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ · 2 unmodified lines
769 41
770export const bootstrapCurrentWorkosAccount = mutation({42export const bootstrapCurrentWorkosAccount = mutation({
771 args: {},43 args: {},
772 handler: async (ctx) => {44 handler: bootstrapCurrentWorkosAccountImpl,
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 },
934});45});
executor/packages/convex/credentialsNode.ts+1−7
@@ -4,19 +4,13 @@ import { WorkOS } from "@workos-inc/node"; · 3 unmodified lines
4import { v } from "convex/values";4import { v } from "convex/values";
5import { internal } from "./_generated/api";5import { internal } from "./_generated/api";
6import { action } from "./_generated/server";6import { action } from "./_generated/server";
7import { asRecord } from "./lib/object";
7 8
8const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));9const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
9const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));10const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));
10 11
11type SecretBackend = "local-convex" | "workos-vault";12type SecretBackend = "local-convex" | "workos-vault";
12 13
13function 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
20function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string {14function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string {
21 if (scope !== "actor") return "";15 if (scope !== "actor") return "";
22 if (typeof actorId !== "string") return "";16 if (typeof actorId !== "string") return "";
executor/packages/convex/database-anonymous-session.ts+81−0
@@ -0,0 +1,81 @@
1import { v } from "convex/values";
2import { internalMutation } from "./_generated/server";
3import { ensureAnonymousIdentity } from "./database-anonymous";
4import { mapAnonymousContext } from "./database-readers";
5
6export 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 @@
1import type { Doc } from "./_generated/dataModel";
2import type { MutationCtx } from "./_generated/server";
3import { slugify } from "../core/src/identity";
4import { ensureUniqueSlug } from "../core/src/slug";
5
6type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
7type OrganizationMemberStatus = "active" | "pending" | "removed";
8
9async 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
20async 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
59export 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3import { mapApproval } from "./database-mappers";
4import { getApprovalDoc, getTaskDoc } from "./database-readers";
5import { approvalStatusValidator } from "./database-validators";
6import type { TaskStatus } from "../core/src/types";
7
8export 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
45export 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
53export 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
80export 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
121export 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
147export 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3import { mapCredential } from "./database-mappers";
4import { computeBoundAuthFingerprint } from "./database-readers";
5import {
6 credentialProviderValidator,
7 credentialScopeValidator,
8} from "./database-validators";
9import { asRecord } from "./lib/object";
10
11export 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
132export 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
144export 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
160export 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 @@
1import type { Doc } from "./_generated/dataModel";
2import { asRecord } from "./lib/object";
3import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants";
4
5export 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
17export 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
29export 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
45export 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
66export 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
80export 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
95export 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
109export 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
126export 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3
4export 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
26export 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
56export 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
80export 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
101export 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
128export 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
158export 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
176export 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3import { mapPolicy } from "./database-mappers";
4import { policyDecisionValidator } from "./database-validators";
5
6export 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
24export 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
77export 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 @@
1import type { Doc } from "./_generated/dataModel";
2import type { QueryCtx } from "./_generated/server";
3import { normalizeSourceAuthFingerprint } from "./database-mappers";
4import { asRecord } from "./lib/object";
5
6export 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
19export 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
30export 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
34export 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
41export 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
52export 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3import { getTaskDoc, mapTaskEvent } from "./database-readers";
4import { asRecord } from "./lib/object";
5
6export 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
50export 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3import { mapTask } from "./database-mappers";
4import { getTaskDoc } from "./database-readers";
5import { completedTaskStatusValidator } from "./database-validators";
6import { asRecord } from "./lib/object";
7import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants";
8import { isTerminalTaskStatus } from "./task-status";
9
10export 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
50export 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
58export 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
70export 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
83export 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
94export 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
114export 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3import { mapToolCall } from "./database-mappers";
4import { getToolCallDoc } from "./database-readers";
5import { terminalToolCallStatusValidator } from "./database-validators";
6
7export 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
39export 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
50export 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
81export 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
114export 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3import {
4 computeSourceSpecHash,
5 mapSource,
6 normalizeSourceAuthFingerprint,
7} from "./database-mappers";
8import { toolSourceTypeValidator } from "./database-validators";
9import { asRecord } from "./lib/object";
10
11export 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
78export 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
90export 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 @@
1import { v } from "convex/values";
2
3export const completedTaskStatusValidator = v.union(
4 v.literal("completed"),
5 v.literal("failed"),
6 v.literal("timed_out"),
7 v.literal("denied"),
8);
9
10export const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
11
12export const terminalToolCallStatusValidator = v.union(
13 v.literal("completed"),
14 v.literal("failed"),
15 v.literal("denied"),
16);
17
18export const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
19
20export const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
21
22export const credentialProviderValidator = v.union(
23 v.literal("local-convex"),
24 v.literal("workos-vault"),
25);
26
27export const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
executor/packages/convex/database.ts+52−1531
@@ -1,1531 +1,52 @@
1import { v } from "convex/values";1export {
2import type { Doc } from "./_generated/dataModel";2 createTask,
3import { type MutationCtx, type QueryCtx } from "./_generated/server";3 getTask,
4import { internalMutation, internalQuery } from "./_generated/server";4 listTasks,
5import { slugify } from "../core/src/identity";5 listQueuedTaskIds,
6import { ensureUniqueSlug } from "../core/src/slug";6 getTaskInWorkspace,
7import type { TaskStatus } from "../core/src/types";7 markTaskRunning,
8 8 markTaskFinished,
9const DEFAULT_TIMEOUT_MS = 300_000;9} from "./database-tasks";
10type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";10export {
11type OrganizationMemberStatus = "active" | "pending" | "removed";11 createApproval,
12 12 getApproval,
13const completedTaskStatusValidator = v.union(13 listApprovals,
14 v.literal("completed"),14 listPendingApprovals,
15 v.literal("failed"),15 resolveApproval,
16 v.literal("timed_out"),16 getApprovalInWorkspace,
17 v.literal("denied"),17} from "./database-approvals";
18);18export {
19const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));19 upsertToolCallRequested,
20const terminalToolCallStatusValidator = v.union(20 getToolCall,
21 v.literal("completed"),21 setToolCallPendingApproval,
22 v.literal("failed"),22 finishToolCall,
23 v.literal("denied"),23 listToolCalls,
24);24} from "./database-tool-calls";
25const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));25export { bootstrapAnonymousSession } from "./database-anonymous-session";
26const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));26export {
27const credentialProviderValidator = v.union(27 listRuntimeTargets,
28 v.literal("local-convex"),28 upsertAccessPolicy,
29 v.literal("workos-vault"),29 listAccessPolicies,
30);30} from "./database-policies";
31const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));31export {
32 32 upsertCredential,
33async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {33 listCredentials,
34 const baseSlug = slugify(baseName, "workspace");34 listCredentialProviders,
35 return await ensureUniqueSlug(baseSlug, async (candidate) => {35 resolveCredential,
36 const collision = await ctx.db36} from "./database-credentials";
37 .query("organizations")37export {
38 .withIndex("by_slug", (q) => q.eq("slug", candidate))38 upsertToolSource,
39 .unique();39 listToolSources,
40 return collision !== null;40 deleteToolSource,
41 });41} from "./database-tool-sources";
42}42export { createTaskEvent, listTaskEvents } from "./database-task-events";
43 43export {
44async function upsertOrganizationMembership(44 getActiveAnonymousOauthSigningKey,
45 ctx: Pick<MutationCtx, "db">,45 storeAnonymousOauthSigningKey,
46 args: {46 registerAnonymousOauthClient,
47 organizationId: Doc<"organizations">["_id"];47 getAnonymousOauthClient,
48 accountId: Doc<"accounts">["_id"];48 storeAnonymousOauthAuthorizationCode,
49 role: OrganizationRole;49 consumeAnonymousOauthAuthorizationCode,
50 status: OrganizationMemberStatus;50 purgeExpiredAnonymousOauthAuthorizationCodes,
51 billable: boolean;51 countAnonymousOauthAuthorizationCodes,
52 now: number;52} from "./database-oauth";
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.
85function 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
92function 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
104function 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
116function 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
132function 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
153function 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
167function 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
182function 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
196function 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
213function 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
228async 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
334function 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
347function 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
358async 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
362async 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
369async 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
380export 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
420export 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
428export 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
440export 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
453export 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
471export 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
482export 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
502export 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
535export 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
572export 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
580export 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
607export 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
648export 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
674export 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
685export 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
717export 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
728export 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
759export 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
792export 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
807export 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
884export 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
937export 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
956async 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
983export 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
1100export 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
1112export 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
1128export 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
1171export 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
1238export 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
1250export 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
1279export 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
1323export 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 */
1342export 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 */
1365export 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 */
1402export 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 */
1429export 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
1450export 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
1477export 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
1507export 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
1525export 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});
executor/packages/convex/executor.ts+12−30
@@ -7,28 +7,10 @@ import { workspaceMutation } from "../core/src/function-builders"; · 6 unmodified lines
7import { actorIdForAccount } from "../core/src/identity";7import { actorIdForAccount } from "../core/src/identity";
8import { isKnownRuntimeId } from "../core/src/runtimes/runtime-catalog";8import { isKnownRuntimeId } from "../core/src/runtimes/runtime-catalog";
9import type { ApprovalRecord, TaskRecord } from "../core/src/types";9import type { ApprovalRecord, TaskRecord } from "../core/src/types";
10 10import { isTerminalTaskStatus, taskTerminalEventType } from "./task-status";
11const DEFAULT_TIMEOUT_MS = 300_000;11import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants";
12 12import { createTaskEvent } from "./task-events";
13async function publishTaskEvent(13import { markTaskFinished } from "./task-finish";
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
25function 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}
32 14
33async function createTaskRecord(15async function createTaskRecord(
34 ctx: MutationCtx,16 ctx: MutationCtx,
@@ -57,14 +39,14 @@ async function createTaskRecord( · 22 unmodified lines
57 id: taskId,39 id: taskId,
58 code: args.code,40 code: args.code,
59 runtimeId,41 runtimeId,
60 timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,42 timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS,
61 metadata: args.metadata,43 metadata: args.metadata,
62 workspaceId: args.workspaceId,44 workspaceId: args.workspaceId,
63 actorId: args.actorId,45 actorId: args.actorId,
64 clientId: args.clientId,46 clientId: args.clientId,
65 })) as TaskRecord;47 })) as TaskRecord;
66 48
67 await publishTaskEvent(ctx, {49 await createTaskEvent(ctx, {
68 taskId,50 taskId,
69 eventName: "task",51 eventName: "task",
70 type: "task.created",52 type: "task.created",
@@ -80,7 +62,7 @@ async function createTaskRecord( · 9 unmodified lines
80 },62 },
81 });63 });
82 64
83 await publishTaskEvent(ctx, {65 await createTaskEvent(ctx, {
84 taskId,66 taskId,
85 eventName: "task",67 eventName: "task",
86 type: "task.queued",68 type: "task.queued",
@@ -127,7 +109,7 @@ async function resolveApprovalRecord( · 40 unmodified lines
127 return null;109 return null;
128 }110 }
129 111
130 await publishTaskEvent(ctx, {112 await createTaskEvent(ctx, {
131 taskId: approval.taskId,113 taskId: approval.taskId,
132 eventName: "approval",114 eventName: "approval",
133 type: "approval.resolved",115 type: "approval.resolved",
@@ -241,11 +223,11 @@ export const completeRuntimeRun = internalMutation({ · 107 unmodified lines
241 return { ok: false as const, error: `Run not found: ${args.runId}` };223 return { ok: false as const, error: `Run not found: ${args.runId}` };
242 }224 }
243 225
244 if (task.status === "completed" || task.status === "failed" || task.status === "timed_out" || task.status === "denied") {226 if (isTerminalTaskStatus(task.status)) {
245 return { ok: true as const, alreadyFinal: true as const, task };227 return { ok: true as const, alreadyFinal: true as const, task };
246 }228 }
247 229
248 const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {230 const finished = await markTaskFinished(ctx, {
249 taskId: args.runId,231 taskId: args.runId,
250 status: args.status,232 status: args.status,
251 result: args.result,233 result: args.result,
@@ -257,10 +239,10 @@ export const completeRuntimeRun = internalMutation({ · 5 unmodified lines
257 return { ok: false as const, error: `Failed to mark run finished: ${args.runId}` };239 return { ok: false as const, error: `Failed to mark run finished: ${args.runId}` };
258 }240 }
259 241
260 await publishTaskEvent(ctx, {242 await createTaskEvent(ctx, {
261 taskId: args.runId,243 taskId: args.runId,
262 eventName: "task",244 eventName: "task",
263 type: terminalEventForStatus(args.status),245 type: taskTerminalEventType(args.status),
264 payload: {246 payload: {
265 taskId: args.runId,247 taskId: args.runId,
266 status: finished.status,248 status: finished.status,
executor/packages/convex/executor_node/events.ts+3−3
@@ -1,16 +1,16 @@
1"use node";1"use node";
2 2
3import type { ActionCtx } from "../_generated/server";3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";4import { createTaskEvent, type TaskEventName } from "../task-events";
5 5
6export async function publishTaskEvent(6export async function publishTaskEvent(
7 ctx: ActionCtx,7 ctx: ActionCtx,
8 taskId: string,8 taskId: string,
9 eventName: "task" | "approval",9 eventName: TaskEventName,
10 type: string,10 type: string,
11 payload: Record<string, unknown>,11 payload: Record<string, unknown>,
12): Promise<void> {12): Promise<void> {
13 await ctx.runMutation(internal.database.createTaskEvent, {13 await createTaskEvent(ctx, {
14 taskId,14 taskId,
15 eventName,15 eventName,
16 type,16 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
14import type { TaskRecord } from "../../core/src/types";14import type { TaskRecord } from "../../core/src/types";
15import { describeError } from "../../core/src/utils";15import { describeError } from "../../core/src/utils";
16import { publishTaskEvent } from "./events";16import { publishTaskEvent } from "./events";
17import { taskTerminalEventType } from "../task-status";
18import { markTaskFinished } from "../task-finish";
17import { invokeTool } from "./tool_invocation";19import { invokeTool } from "./tool_invocation";
18 20
21async 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
43async 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
19export async function runQueuedTask(62export async function runQueuedTask(
20 ctx: ActionCtx,63 ctx: ActionCtx,
21 args: { taskId: string },64 args: { taskId: string },
@@ -26,36 +69,18 @@ export async function runQueuedTask( · 4 unmodified lines
26 }69 }
27 70
28 if (!isKnownRuntimeId(task.runtimeId)) {71 if (!isKnownRuntimeId(task.runtimeId)) {
29 const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {72 await markTaskFailedAndPublish(ctx, {
30 taskId: args.taskId,73 taskId: args.taskId,
31 status: "failed",
32 error: `Runtime not found: ${task.runtimeId}`,74 error: `Runtime not found: ${task.runtimeId}`,
33 });75 });
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 }
42 return null;76 return null;
43 }77 }
44 78
45 if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) {79 if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) {
46 const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {80 await markTaskFailedAndPublish(ctx, {
47 taskId: args.taskId,81 taskId: args.taskId,
48 status: "failed",
49 error: `Runtime is not configured: ${task.runtimeId}`,82 error: `Runtime is not configured: ${task.runtimeId}`,
50 });83 });
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 }
59 return null;84 return null;
60 }85 }
61 86
@@ -81,24 +106,14 @@ export async function runQueuedTask( · 19 unmodified lines
81 });106 });
82 107
83 if (!dispatchResult.ok) {108 if (!dispatchResult.ok) {
84 const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {109 await markTaskFailedAndPublish(ctx, {
85 taskId: args.taskId,110 taskId: args.taskId,
86 status: "failed",
87 error: dispatchResult.error,111 error: dispatchResult.error,
88 });112 });
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 }
98 return null;113 return null;
99 }114 }
100 115
101 const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {116 const finished = await markTaskFinished(ctx, {
102 taskId: args.taskId,117 taskId: args.taskId,
103 status: dispatchResult.status,118 status: dispatchResult.status,
104 result: dispatchResult.result,119 result: dispatchResult.result,
@@ -110,22 +125,11 @@ export async function runQueuedTask( · 5 unmodified lines
110 return null;125 return null;
111 }126 }
112 127
113 const terminalEvent =128 await publishTerminalTaskResult(ctx, {
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, {
123 taskId: args.taskId,129 taskId: args.taskId,
124 status: finished.status,130 status: dispatchResult.status,
125 exitCode: finished.exitCode,131 finished,
126 durationMs: dispatchResult.durationMs,132 durationMs: dispatchResult.durationMs,
127 error: finished.error,
128 completedAt: finished.completedAt,
129 });133 });
130 return null;134 return null;
131 }135 }
@@ -146,7 +150,7 @@ export async function runQueuedTask( · 14 unmodified lines
146 );150 );
147 })();151 })();
148 152
149 const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {153 const finished = await markTaskFinished(ctx, {
150 taskId: args.taskId,154 taskId: args.taskId,
151 status: runtimeResult.status,155 status: runtimeResult.status,
152 result: runtimeResult.result,156 result: runtimeResult.result,
@@ -158,38 +162,26 @@ export async function runQueuedTask( · 5 unmodified lines
158 return null;162 return null;
159 }163 }
160 164
161 const terminalEvent =165 await publishTerminalTaskResult(ctx, {
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, {
171 taskId: args.taskId,166 taskId: args.taskId,
172 status: finished.status,167 status: runtimeResult.status,
173 exitCode: finished.exitCode,168 finished,
174 durationMs: runtimeResult.durationMs,169 durationMs: runtimeResult.durationMs,
175 error: finished.error,
176 completedAt: finished.completedAt,
177 });170 });
178 } catch (error) {171 } catch (error) {
179 const message = describeError(error);172 const message = describeError(error);
180 const denied = message.startsWith(APPROVAL_DENIED_PREFIX);173 const denied = message.startsWith(APPROVAL_DENIED_PREFIX);
181 const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {174 const finished = await markTaskFinished(ctx, {
182 taskId: args.taskId,175 taskId: args.taskId,
183 status: denied ? "denied" : "failed",176 status: denied ? "denied" : "failed",
184 error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message,177 error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message,
185 });178 });
186 179
187 if (finished) {180 if (finished) {
188 await publishTaskEvent(ctx, args.taskId, "task", denied ? "task.denied" : "task.failed", {181 await publishTerminalTaskResult(ctx, {
189 taskId: args.taskId,182 taskId: args.taskId,
190 status: finished.status,183 status: denied ? "denied" : "failed",
191 error: finished.error,184 finished,
192 completedAt: finished.completedAt,
193 });185 });
194 }186 }
195 }187 }
executor/packages/convex/executor_node/tool_call_credentials.ts+74−0
@@ -0,0 +1,74 @@
1import type { ActionCtx } from "../_generated/server";
2import { internal } from "../_generated/api";
3import { resolveCredentialPayload } from "../../core/src/credential-providers";
4import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants";
5import type { CredentialScope, ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../core/src/types";
6import { asPayload } from "../../core/src/utils";
7
8export 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
62export 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
3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
5import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants";
6import type { TaskRecord } from "../../core/src/types";
7import { publishTaskEvent } from "./events";
8
9async 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
21export 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
48export 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
69export 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 @@
1import type { ActionCtx } from "../_generated/server";
2import { parseGraphqlOperationPaths } from "../../core/src/graphql-operation-paths";
3import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types";
4import { getDecisionForContext, getToolDecision } from "./policy";
5import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";
6import { baseTools, getWorkspaceTools } from "./workspace_tools";
7
8export 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
64function 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
77export 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
89export 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
2 2
3import type { ActionCtx } from "../_generated/server";3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";4import { internal } from "../_generated/api";
5import { resolveCredentialPayload } from "../../core/src/credential-providers";5import { APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants";
6import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants";
7import { parseGraphqlOperationPaths } from "../../core/src/tool-sources";
8import type {6import type {
9 AccessPolicyRecord,7 AccessPolicyRecord,
10 CredentialScope,
11 PolicyDecision,8 PolicyDecision,
12 ResolvedToolCredential,9 ResolvedToolCredential,
13 TaskRecord,10 TaskRecord,
14 ToolCallRecord,11 ToolCallRecord,
15 ToolCallRequest,12 ToolCallRequest,
16 ToolCredentialSpec,
17 ToolDefinition,
18 ToolRunContext,13 ToolRunContext,
19} from "../../core/src/types";14} from "../../core/src/types";
20import { asPayload, describeError } from "../../core/src/utils";15import { asPayload, describeError } from "../../core/src/utils";
21import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy";16import { getToolDecision, isToolAllowedForTask } from "./policy";
22import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";17import { baseTools } from "./workspace_tools";
23import { baseTools, getWorkspaceTools } from "./workspace_tools";
24import { publishTaskEvent } from "./events";18import { publishTaskEvent } from "./events";
19import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
20import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";
21import { ensureWorkspaceTools, getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
25 22
26function createApprovalId(): string {23function createApprovalId(): string {
27 return `approval_${crypto.randomUUID()}`;24 return `approval_${crypto.randomUUID()}`;
28}25}
29 26
30async function resolveCredentialHeaders(27async function denyToolCallForApproval(
31 ctx: ActionCtx,28 ctx: ActionCtx,
32 spec: ToolCredentialSpec,29 args: {
33 task: TaskRecord,30 task: TaskRecord;
34): Promise<ResolvedToolCredential | null> {31 callId: string;
35 const record = await ctx.runQuery(internal.database.resolveCredential, {32 toolPath: string;
36 workspaceId: task.workspaceId,33 approvalId: string;
37 sourceKey: spec.sourceKey,34 },
38 scope: spec.mode as CredentialScope,35): Promise<never> {
39 actorId: task.actorId,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,
40 });43 });
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
92function 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 };
146}44}
147 45
148export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {46export 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
153 workspaceId: task.workspaceId,51 workspaceId: task.workspaceId,
154 toolPath,52 toolPath,
155 })) as ToolCallRecord;53 })) as ToolCallRecord;
156 54 assertPersistedCallRunnable(persistedCall, callId);
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 }
168 55
169 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });56 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
170 const typedPolicies = policies as AccessPolicyRecord[];57 const typedPolicies = policies as AccessPolicyRecord[];
171 58
172 let workspaceTools: Map<string, ToolDefinition> | undefined;59 let { tool, resolvedToolPath, workspaceTools } = await resolveToolForCall(ctx, task, toolPath);
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 }
211 60
212 let decision: PolicyDecision;61 let decision: PolicyDecision;
213 let effectiveToolPath = resolvedToolPath;62 let effectiveToolPath = resolvedToolPath;
214 if (tool._graphqlSource) {63 if (tool._graphqlSource) {
215 if (!workspaceTools) {64 workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);
216 const result = await getWorkspaceTools(ctx, task.workspaceId);
217 workspaceTools = result.tools;
218 }
219 const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);65 const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);
220 decision = result.decision;66 decision = result.decision;
221 if (result.effectivePaths.length > 0) {67 if (result.effectivePaths.length > 0) {
@@ -229,19 +75,13 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 7 unmodified lines
229 75
230 if (decision === "deny") {76 if (decision === "deny") {
231 const deniedMessage = `${effectiveToolPath} (policy denied)`;77 const deniedMessage = `${effectiveToolPath} (policy denied)`;
232 await ctx.runMutation(internal.database.finishToolCall, {78 await denyToolCall(ctx, {
233 taskId: task.id,79 task,
234 callId,
235 status: "denied",
236 error: deniedMessage,
237 });
238 await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", {
239 taskId: task.id,
240 callId,80 callId,
241 toolPath: effectiveToolPath,81 toolPath: effectiveToolPath,
82 deniedMessage,
242 reason: "policy_deny",83 reason: "policy_deny",
243 });84 });
244 throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
245 }85 }
246 86
247 let credential: ResolvedToolCredential | undefined;87 let credential: ResolvedToolCredential | undefined;
@@ -276,20 +116,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 28 unmodified lines
276 }116 }
277 117
278 if (existingApproval.status === "denied") {118 if (existingApproval.status === "denied") {
279 const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`;119 await denyToolCallForApproval(ctx, {
280 await ctx.runMutation(internal.database.finishToolCall, {120 task,
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,
288 callId,121 callId,
289 toolPath: effectiveToolPath,122 toolPath: effectiveToolPath,
290 approvalId: existingApproval.id,123 approvalId: existingApproval.id,
291 });124 });
292 throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
293 }125 }
294 126
295 approvalSatisfied = existingApproval.status === "approved";127 approvalSatisfied = existingApproval.status === "approved";
@@ -330,20 +162,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 34 unmodified lines
330 }162 }
331 163
332 if (approval.status === "denied") {164 if (approval.status === "denied") {
333 const deniedMessage = `${effectiveToolPath} (${approval.id})`;165 await denyToolCallForApproval(ctx, {
334 await ctx.runMutation(internal.database.finishToolCall, {166 task,
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,
342 callId,167 callId,
343 toolPath: effectiveToolPath,168 toolPath: effectiveToolPath,
344 approvalId: approval.id,169 approvalId: approval.id,
345 });170 });
346 throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
347 }171 }
348 }172 }
349 173
@@ -357,31 +181,19 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 7 unmodified lines
357 isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies),181 isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies),
358 };182 };
359 const value = await tool.run(input, context);183 const value = await tool.run(input, context);
360 await ctx.runMutation(internal.database.finishToolCall, {184 await completeToolCall(ctx, {
361 taskId: task.id,
362 callId,
363 status: "completed",
364 });
365 await publishTaskEvent(ctx, task.id, "task", "tool.call.completed", {
366 taskId: task.id,185 taskId: task.id,
367 callId,186 callId,
368 toolPath: effectiveToolPath,187 toolPath: effectiveToolPath,
369 outputRedacted: true,
370 });188 });
371 return value;189 return value;
372 } catch (error) {190 } catch (error) {
373 const message = describeError(error);191 const message = describeError(error);
374 await ctx.runMutation(internal.database.finishToolCall, {192 await failToolCall(ctx, {
375 taskId: task.id,193 taskId: task.id,
376 callId,194 callId,
377 status: "failed",
378 error: message,195 error: message,
379 });
380 await publishTaskEvent(ctx, task.id, "task", "tool.call.failed", {
381 taskId: task.id,
382 callId,
383 toolPath: effectiveToolPath,196 toolPath: effectiveToolPath,
384 error: message,
385 });197 });
386 throw error;198 throw error;
387 }199 }
executor/packages/convex/executor_node/tool_source_loading.ts+11−10
@@ -7,25 +7,26 @@ import { · 6 unmodified lines
7 compileOpenApiToolSourceFromPrepared,7 compileOpenApiToolSourceFromPrepared,
8 prepareOpenApiSpec,8 prepareOpenApiSpec,
9 type CompiledToolSourceArtifact,9 type CompiledToolSourceArtifact,
10 type ExternalToolSourceConfig,
11 type GraphqlToolSourceConfig,
12 type McpToolSourceConfig,
13 type OpenApiToolSourceConfig,
14 type PreparedOpenApiSpec,
15} from "../../core/src/tool-sources";10} from "../../core/src/tool-sources";
11import type {
12 ExternalToolSourceConfig,
13 GraphqlToolSourceConfig,
14 McpToolSourceConfig,
15 OpenApiToolSourceConfig,
16 PreparedOpenApiSpec,
17} from "../../core/src/tool-source-types";
16import type { ToolSourceRecord } from "../../core/src/types";18import type { ToolSourceRecord } from "../../core/src/types";
17 19
18const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;20const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
19 21
20/** Cache version - bump when PreparedOpenApiSpec shape changes. */22/** Cache version - bump when PreparedOpenApiSpec shape changes. */
21const OPENAPI_CACHE_VERSION = "v15";23const TOOL_SOURCE_CACHE_VERSION = "v15";
22 24
23export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {25export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
24 const signatureVersion = "v15";
25 const parts = sources26 const parts = sources
26 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)27 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
27 .sort();28 .sort();
28 return `${signatureVersion}|${workspaceId}|${parts.join(",")}`;29 return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`;
29}30}
30 31
31export function normalizeExternalToolSource(raw: {32export function normalizeExternalToolSource(raw: {
@@ -124,7 +125,7 @@ async function loadCachedOpenApiSpec( · 92 unmodified lines
124 try {125 try {
125 const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {126 const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {
126 specUrl,127 specUrl,
127 version: OPENAPI_CACHE_VERSION,128 version: TOOL_SOURCE_CACHE_VERSION,
128 maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,129 maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,
129 });130 });
130 131
@@ -148,7 +149,7 @@ async function loadCachedOpenApiSpec( · 17 unmodified lines
148 const storageId = await ctx.storage.store(blob);149 const storageId = await ctx.storage.store(blob);
149 await ctx.runMutation(internal.openApiSpecCache.putEntry, {150 await ctx.runMutation(internal.openApiSpecCache.putEntry, {
150 specUrl,151 specUrl,
151 version: OPENAPI_CACHE_VERSION,152 version: TOOL_SOURCE_CACHE_VERSION,
152 storageId,153 storageId,
153 sizeBytes: json.length,154 sizeBytes: json.length,
154 });155 });
executor/packages/convex/executor_node/workspace_tools.ts+26−34
@@ -8,9 +8,9 @@ import { · 7 unmodified lines
8 materializeCompiledToolSource,8 materializeCompiledToolSource,
9 materializeWorkspaceSnapshot,9 materializeWorkspaceSnapshot,
10 type CompiledToolSourceArtifact,10 type CompiledToolSourceArtifact,
11 type ExternalToolSourceConfig,
12 type WorkspaceToolSnapshot,11 type WorkspaceToolSnapshot,
13} from "../../core/src/tool-sources";12} from "../../core/src/tool-sources";
13import type { ExternalToolSourceConfig } from "../../core/src/tool-source-types";
14import { DEFAULT_TOOLS } from "../../core/src/tools";14import { DEFAULT_TOOLS } from "../../core/src/tools";
15import type {15import type {
16 AccessPolicyRecord,16 AccessPolicyRecord,
@@ -75,6 +75,29 @@ function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<s · 58 unmodified lines
75 return profiles;75 return profiles;
76}76}
77 77
78function 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
78export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> {101export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> {
79 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))102 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
80 .filter((source: { enabled: boolean }) => source.enabled);103 .filter((source: { enabled: boolean }) => source.enabled);
@@ -91,22 +114,7 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 10 unmodified lines
91 if (blob) {114 if (blob) {
92 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;115 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
93 const restored = materializeWorkspaceSnapshot(snapshot);116 const restored = materializeWorkspaceSnapshot(snapshot);
94 117 const merged = mergeToolsWithCatalog(restored);
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);
110 118
111 const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];119 const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
112 120
@@ -135,23 +143,7 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 22 unmodified lines
135 .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact));143 .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact));
136 const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact));144 const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact));
137 warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));145 warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));
138 146 const merged = mergeToolsWithCatalog(externalTools);
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);
155 147
156 let dtsStorageIds: DtsStorageEntry[] = [];148 let dtsStorageIds: DtsStorageEntry[] = [];
157 try {149 try {
executor/packages/convex/http/mcp_service.ts+15−13
@@ -18,37 +18,39 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 17 unmodified lines
18 workspaceId: Id<"workspaces">;18 workspaceId: Id<"workspaces">;
19 actorId: string;19 actorId: string;
20 clientId?: string;20 clientId?: string;
21 }) => {21 }): Promise<{ task: TaskRecord }> => {
22 const taskInput = {22 const taskInput = {
23 ...input,23 ...input,
24 scheduleAfterCreate: false,24 scheduleAfterCreate: false,
25 } as Parameters<typeof ctx.runMutation<typeof internal.executor.createTaskInternal>>[1];25 };
26 return (await ctx.runMutation(internal.executor.createTaskInternal, taskInput)) as { task: TaskRecord };26 return await ctx.runMutation(internal.executor.createTaskInternal, taskInput);
27 },27 },
28 runTaskNow: async (taskId: string) => {28 runTaskNow: async (taskId: string): Promise<null> => {
29 return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null;29 return await ctx.runAction(internal.executorNode.runTask, { taskId });
30 },30 },
31 getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => {31 getTask: async (taskId: string, workspaceId?: Id<"workspaces">): Promise<TaskRecord | null> => {
32 if (workspaceId) {32 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 });
34 }34 }
35 return null;35 return null;
36 },36 },
37 subscribe: () => {37 subscribe: () => {
38 return () => {};38 return () => {};
39 },39 },
40 bootstrapAnonymousContext: async (sessionId?: string) => {40 bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => {
41 return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext;41 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId });
42 },42 },
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[]> => {
44 if (!toolContext) {46 if (!toolContext) {
45 return [];47 return [];
46 }48 }
47 49
48 return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[];50 return await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext });
49 },51 },
50 listPendingApprovals: async (workspaceId: Id<"workspaces">) => {52 listPendingApprovals: async (workspaceId: Id<"workspaces">): Promise<PendingApprovalRecord[]> => {
51 return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[];53 return await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId });
52 },54 },
53 resolveApproval: async (input: {55 resolveApproval: async (input: {
54 workspaceId: Id<"workspaces">;56 workspaceId: Id<"workspaces">;
executor/packages/convex/invites-workos.ts+100−0
@@ -0,0 +1,100 @@
1import { WorkOS } from "@workos-inc/node";
2
3const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null;
4
5export const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);
6
7type WorkosInvitationResponse = {
8 id: string;
9 state: string;
10 expires_at?: string;
11};
12
13function requireWorkosClient(): WorkOS {
14 if (!workosClient) {
15 throw new Error("WORKOS_API_KEY is required for WorkOS invite operations");
16 }
17 return workosClient;
18}
19
20export 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.