Unified · Split
| @@ -2,4 +2,44 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | Assistant-side code lives here. | 3 | Assistant-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 | |||
| 13 | 1. User mentions the bot in Discord. | ||
| 14 | 2. Bot subscribes to the thread and sends prompts to `@assistant/server`. | ||
| 15 | 3. Server resolves MCP context per chat user: | ||
| 16 | - default: deterministic anonymous `sessionId` via executor bootstrap; | ||
| 17 | - linked: saved anonymous session or WorkOS bearer token. | ||
| 18 | 4. `@assistant/core` runs the prompt via MCP `run_code` and returns text. | ||
| 19 | 5. Bot posts the response back to the thread. | ||
| 20 | |||
| 21 | ## Link commands (Discord) | ||
| 22 | |||
| 23 | Preferred slash commands: | ||
| 24 | |||
| 25 | - `/whoami` | ||
| 26 | - `/link-workos` (opens a private modal for workspace/token) | ||
| 27 | - `/link-anon [session_id]` | ||
| 28 | - `/unlink` | ||
| 29 | |||
| 30 | Prompt commands: | ||
| 31 | |||
| 32 | - `/ask <prompt>` | ||
| 33 | - mention-based thread mode (mention bot once, then keep chatting) | ||
| 34 | |||
| 35 | Prefix fallback (optional): | ||
| 36 | |||
| 37 | - `!whoami` | ||
| 38 | - `!link-anon [sessionId]` | ||
| 39 | - `!unlink` | ||
| 40 | |||
| 41 | Links persist in `assistant/.chat-links.json` by default. Override with `ASSISTANT_LINKS_FILE`. | ||
| 42 | |||
| 43 | Bot env requirements: `DISCORD_TOKEN` (optional prefix override via `ASSISTANT_COMMAND_PREFIX`). | ||
| 44 | |||
| 45 | For better linking UX, set `EXECUTOR_WEB_URL` so `/link-workos` can send a one-click "Open Executor" button. | ||
| @@ -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 | } |
| @@ -1,59 +0,0 @@ | |||
| 1 | /** | ||
| 2 | * /ask command handler | ||
| 3 | */ | ||
| 4 | |||
| 5 | import type { ChatInputCommandInteraction, CommandInteraction } from "discord.js"; | ||
| 6 | import type { Client } from "@assistant/server/client"; | ||
| 7 | import { unwrap } from "@assistant/server/client"; | ||
| 8 | import type { ConvexReactClient } from "convex/react"; | ||
| 9 | import { ConvexProvider } from "convex/react"; | ||
| 10 | import type { ReacordInstance } from "@openassistant/reacord"; | ||
| 11 | import { Effect, Runtime } from "effect"; | ||
| 12 | import type { Id } from "@executor/convex/_generated/dataModel"; | ||
| 13 | import { TaskMessage } from "../views/task-message"; | ||
| 14 | |||
| 15 | interface AskCommandDeps { | ||
| 16 | readonly api: Client; | ||
| 17 | readonly convex: ConvexReactClient; | ||
| 18 | readonly reacord: { | ||
| 19 | reply: (interaction: CommandInteraction, content: React.ReactNode) => Effect.Effect<ReacordInstance>; | ||
| 20 | }; | ||
| 21 | } | ||
| 22 | |||
| 23 | export async function handleAskCommand( | ||
| 24 | interaction: ChatInputCommandInteraction, | ||
| 25 | deps: AskCommandDeps, | ||
| 26 | ): Promise<void> { | ||
| 27 | const prompt = interaction.options.getString("prompt", true); | ||
| 28 | const requesterId = interaction.user.id; | ||
| 29 | |||
| 30 | await interaction.deferReply(); | ||
| 31 | |||
| 32 | let agentTaskId: string; | ||
| 33 | let workspaceId: Id<"workspaces">; | ||
| 34 | try { | ||
| 35 | const data = await unwrap( | ||
| 36 | deps.api.api.tasks.post({ prompt, requesterId }), | ||
| 37 | ); | ||
| 38 | agentTaskId = data.agentTaskId; | ||
| 39 | workspaceId = data.workspaceId as Id<"workspaces">; | ||
| 40 | } catch (error) { | ||
| 41 | await interaction.editReply({ | ||
| 42 | content: `\u274c Failed to create task: ${error instanceof Error ? error.message : String(error)}`, | ||
| 43 | }); | ||
| 44 | return; | ||
| 45 | } | ||
| 46 | |||
| 47 | await Runtime.runPromise(Runtime.defaultRuntime)( | ||
| 48 | deps.reacord.reply( | ||
| 49 | interaction, | ||
| 50 | <ConvexProvider client={deps.convex}> | ||
| 51 | <TaskMessage | ||
| 52 | agentTaskId={agentTaskId} | ||
| 53 | prompt={prompt} | ||
| 54 | workspaceId={workspaceId} | ||
| 55 | /> | ||
| 56 | </ConvexProvider>, | ||
| 57 | ), | ||
| 58 | ); | ||
| 59 | } | ||
| @@ -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 | |||
| 8 | import { | 1 | import { |
| 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"; |
| 15 | import { makeReacord } from "@openassistant/reacord"; | 18 | import { createClient, unwrap } from "@assistant/server/client"; |
| 16 | import { createClient } from "@assistant/server/client"; | 19 | |
| 17 | import { ConvexReactClient } from "convex/react"; | 20 | type ChatIdentity = { |
| 18 | import { handleAskCommand } from "./commands/ask"; | 21 | readonly platform: "discord"; |
| 22 | readonly userId: string; | ||
| 23 | }; | ||
| 19 | 24 | ||
| 20 | // --------------------------------------------------------------------------- | 25 | const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${Bun.env.ASSISTANT_PORT ?? "3002"}`; |
| 21 | // Config | 26 | const DISCORD_TOKEN = Bun.env.DISCORD_TOKEN ?? Bun.env.DISCORD_BOT_TOKEN; |
| 22 | // --------------------------------------------------------------------------- | 27 | const DISCORD_GUILD_ID = Bun.env.DISCORD_GUILD_ID; |
| 28 | const COMMAND_PREFIX = Bun.env.ASSISTANT_COMMAND_PREFIX ?? "!"; | ||
| 29 | const EXECUTOR_WEB_URL = Bun.env.EXECUTOR_WEB_URL | ||
| 30 | ?? Bun.env.OPENASSISTANT_EXECUTOR_WEB_URL | ||
| 31 | ?? Bun.env.OPENASSISTANT_EXECUTOR_URL | ||
| 32 | ?? "http://localhost:3000"; | ||
| 23 | 33 | ||
| 24 | const DISCORD_TOKEN = Bun.env.DISCORD_BOT_TOKEN; | ||
| 25 | if (!DISCORD_TOKEN) { | 34 | if (!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 | } |
| 37 | const BOT_TOKEN = DISCORD_TOKEN; | ||
| 28 | 38 | ||
| 29 | const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${Bun.env.ASSISTANT_PORT ?? "3002"}`; | 39 | const api = createClient(SERVER_URL); |
| 30 | const CONVEX_URL = Bun.env.CONVEX_URL; | 40 | const subscribedChannels = new Set<string>(); |
| 31 | if (!CONVEX_URL) { | 41 | |
| 32 | throw new Error("CONVEX_URL is required. Set it in your environment."); | 42 | const LINK_MODAL_ID = "assistant_link_workos_modal"; |
| 33 | } | 43 | const LINK_OPEN_MODAL_BUTTON_ID = "assistant_link_workos_open_modal"; |
| 44 | const LINK_FIELD_WORKSPACE = "workspace_id"; | ||
| 45 | const LINK_FIELD_TOKEN = "access_token"; | ||
| 46 | const LINK_FIELD_ACTOR = "actor_id"; | ||
| 34 | 47 | ||
| 35 | // --------------------------------------------------------------------------- | 48 | const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [ |
| 36 | // Clients | 49 | 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 | ||
| 39 | const client = new Client({ | 78 | const 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 | ||
| 46 | const reacord = makeReacord(client, { maxInstances: 50 }); | 88 | function identityFromUser(userId: string): ChatIdentity { |
| 47 | const api = createClient(SERVER_URL); | 89 | return { |
| 48 | const convex = new ConvexReactClient(CONVEX_URL, { | 90 | platform: "discord", |
| 49 | unsavedChangesWarning: false, | 91 | userId, |
| 50 | }); | 92 | }; |
| 93 | } | ||
| 51 | 94 | ||
| 52 | // --------------------------------------------------------------------------- | 95 | function truncate(value: string, max = 3500): string { |
| 53 | // Slash commands | 96 | if (value.length <= max) return value; |
| 54 | // --------------------------------------------------------------------------- | 97 | return `${value.slice(0, max)}...`; |
| 98 | } | ||
| 55 | 99 | ||
| 56 | const commands = [ | 100 | function 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 | |||
| 114 | async function reply(message: Message<boolean>, text: string) { | ||
| 115 | const normalized = truncate(text, 12_000); | ||
| 116 | const chunks = chunkMessage(normalized); | ||
| 117 | for (const chunk of chunks) { | ||
| 118 | await message.reply(chunk); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | function commandParts(text: string): readonly string[] { | ||
| 123 | return text.trim().split(/\s+/).filter((part) => part.length > 0); | ||
| 124 | } | ||
| 125 | |||
| 126 | function stripBotMention(text: string, botUserId: string): string { | ||
| 127 | return text | ||
| 128 | .replaceAll(`<@${botUserId}>`, "") | ||
| 129 | .replaceAll(`<@!${botUserId}>`, "") | ||
| 130 | .trim(); | ||
| 131 | } | ||
| 132 | |||
| 133 | function isCommand(text: string): boolean { | ||
| 134 | return text.startsWith(COMMAND_PREFIX); | ||
| 135 | } | ||
| 136 | |||
| 137 | function parseCommand(text: string): { command: string; args: readonly string[] } { | ||
| 138 | const withoutPrefix = text.slice(COMMAND_PREFIX.length).trim(); | ||
| 139 | const [command = "", ...args] = commandParts(withoutPrefix); | ||
| 140 | return { | ||
| 141 | command: command.toLowerCase(), | ||
| 142 | args, | ||
| 143 | }; | ||
| 144 | } | ||
| 145 | |||
| 146 | async function getContext(identity: ChatIdentity) { | ||
| 147 | const data = await unwrap( | ||
| 148 | api.api.context.resolve.post({ | ||
| 149 | platform: identity.platform, | ||
| 150 | userId: identity.userId, | ||
| 151 | }), | ||
| 152 | ); | ||
| 153 | return data.context; | ||
| 154 | } | ||
| 155 | |||
| 156 | async function linkWorkos(identity: ChatIdentity, workspaceId: string, accessToken: string, actorId?: string) { | ||
| 157 | const data = await unwrap( | ||
| 158 | api.api.context.link.post({ | ||
| 159 | platform: identity.platform, | ||
| 160 | userId: identity.userId, | ||
| 161 | provider: "workos", | ||
| 162 | workspaceId, | ||
| 163 | accessToken, | ||
| 164 | actorId, | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | return data.context; | ||
| 168 | } | ||
| 169 | |||
| 170 | async function linkAnonymous(identity: ChatIdentity, sessionId?: string) { | ||
| 171 | const data = await unwrap( | ||
| 172 | api.api.context.link.post({ | ||
| 173 | platform: identity.platform, | ||
| 174 | userId: identity.userId, | ||
| 175 | provider: "anonymous", | ||
| 176 | sessionId, | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | return data.context; | ||
| 180 | } | ||
| 181 | |||
| 182 | async function unlink(identity: ChatIdentity) { | ||
| 183 | const data = await unwrap( | ||
| 184 | api.api.context.unlink.post({ | ||
| 185 | platform: identity.platform, | ||
| 186 | userId: identity.userId, | ||
| 187 | }), | ||
| 188 | ); | ||
| 189 | return data.removed; | ||
| 190 | } | ||
| 191 | |||
| 192 | async function runPrompt(identity: ChatIdentity, prompt: string) { | ||
| 193 | const data = await unwrap( | ||
| 194 | api.api.chat.run.post({ | ||
| 195 | platform: identity.platform, | ||
| 196 | userId: identity.userId, | ||
| 197 | prompt, | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | return data; | ||
| 201 | } | ||
| 202 | |||
| 203 | function contextLabel(context: { | ||
| 204 | source: string; | ||
| 205 | workspaceId: string; | ||
| 206 | actorId?: string; | ||
| 207 | sessionId?: string; | ||
| 208 | hasAccessToken: boolean; | ||
| 209 | }) { | ||
| 210 | return [ | ||
| 211 | `source=${context.source}`, | ||
| 212 | `workspace=${context.workspaceId}`, | ||
| 213 | context.actorId ? `actor=${context.actorId}` : undefined, | ||
| 214 | context.sessionId ? `session=${context.sessionId}` : undefined, | ||
| 215 | context.hasAccessToken ? "auth=token" : "auth=session", | ||
| 216 | ] | ||
| 217 | .filter(Boolean) | ||
| 218 | .join(" | "); | ||
| 219 | } | ||
| 220 | |||
| 221 | function createWorkosLinkModal() { | ||
| 222 | const modal = new ModalBuilder() | ||
| 223 | .setCustomId(LINK_MODAL_ID) | ||
| 224 | .setTitle("Link WorkOS MCP Context"); | ||
| 225 | |||
| 226 | const workspaceInput = new TextInputBuilder() | ||
| 227 | .setCustomId(LINK_FIELD_WORKSPACE) | ||
| 228 | .setLabel("Workspace ID") | ||
| 229 | .setStyle(TextInputStyle.Short) | ||
| 230 | .setRequired(true) | ||
| 231 | .setPlaceholder("e.g. j57...workspace id"); | ||
| 232 | |||
| 233 | const tokenInput = new TextInputBuilder() | ||
| 234 | .setCustomId(LINK_FIELD_TOKEN) | ||
| 235 | .setLabel("Access Token") | ||
| 236 | .setStyle(TextInputStyle.Paragraph) | ||
| 237 | .setRequired(true) | ||
| 238 | .setPlaceholder("Paste MCP bearer token"); | ||
| 239 | |||
| 240 | const actorInput = new TextInputBuilder() | ||
| 241 | .setCustomId(LINK_FIELD_ACTOR) | ||
| 242 | .setLabel("Actor ID (optional)") | ||
| 243 | .setStyle(TextInputStyle.Short) | ||
| 244 | .setRequired(false) | ||
| 245 | .setPlaceholder("Optional override"); | ||
| 246 | |||
| 247 | modal.addComponents( | ||
| 248 | new ActionRowBuilder<TextInputBuilder>().addComponents(workspaceInput), | ||
| 249 | new ActionRowBuilder<TextInputBuilder>().addComponents(tokenInput), | ||
| 250 | new ActionRowBuilder<TextInputBuilder>().addComponents(actorInput), | ||
| 251 | ); | ||
| 252 | |||
| 253 | return modal; | ||
| 254 | } | ||
| 67 | 255 | ||
| 68 | async function registerCommands() { | 256 | function 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 | // --------------------------------------------------------------------------- | 267 | function buildLinkWorkosPanel(identity: ChatIdentity) { |
| 82 | // Command handler | 268 | 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 | |||
| 300 | async function registerSlashCommands() { | ||
| 301 | const appId = bot.user?.id; | ||
| 302 | if (!appId) return; | ||
| 303 | |||
| 304 | const rest = new REST({ version: "10" }).setToken(BOT_TOKEN); | ||
| 84 | 305 | ||
| 85 | client.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: | 322 | async 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 | |||
| 367 | bot.once("clientReady", async () => { | ||
| 368 | console.log(`[assistant-bot] discord bot online as ${bot.user?.tag ?? "unknown"} (server: ${SERVER_URL})`); | ||
| 369 | try { | ||
| 370 | await registerSlashCommands(); | ||
| 371 | } catch (error) { | ||
| 372 | console.error("[assistant-bot] failed to register slash commands", error); | ||
| 97 | } | 373 | } |
| 98 | }); | 374 | }); |
| 99 | 375 | ||
| 100 | // --------------------------------------------------------------------------- | 376 | bot.on("interactionCreate", async (interaction) => { |
| 101 | // Start | 377 | 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 | |||
| 472 | bot.on("messageCreate", async (message) => { | ||
| 473 | if (!message.inGuild() && message.channel.type !== ChannelType.DM) return; | ||
| 474 | if (message.author.bot) return; | ||
| 475 | |||
| 476 | const botUserId = bot.user?.id; | ||
| 477 | if (!botUserId) return; | ||
| 103 | 478 | ||
| 104 | client.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 | ||
| 111 | client.login(DISCORD_TOKEN); | 515 | await bot.login(BOT_TOKEN); |
| @@ -1,137 +0,0 @@ | |||
| 1 | /** | ||
| 2 | * TaskMessage — live-updating Discord message powered by Convex reactivity. | ||
| 3 | * | ||
| 4 | * - Watches agentTask in Convex for status, result, error (reactive, no polling) | ||
| 5 | * - Watches pending approvals in Convex (reactive) | ||
| 6 | * - Approval buttons resolve via Convex mutation | ||
| 7 | */ | ||
| 8 | |||
| 9 | import { useState, useCallback } from "react"; | ||
| 10 | import { | ||
| 11 | Container, | ||
| 12 | TextDisplay, | ||
| 13 | Separator, | ||
| 14 | ActionRow, | ||
| 15 | Button, | ||
| 16 | Loading, | ||
| 17 | useInstance, | ||
| 18 | } from "@openassistant/reacord"; | ||
| 19 | import { useMutation, useQuery } from "convex/react"; | ||
| 20 | import { api } from "@executor/convex/_generated/api"; | ||
| 21 | import type { Id } from "@executor/convex/_generated/dataModel"; | ||
| 22 | |||
| 23 | // --------------------------------------------------------------------------- | ||
| 24 | // Component | ||
| 25 | // --------------------------------------------------------------------------- | ||
| 26 | |||
| 27 | export interface TaskMessageProps { | ||
| 28 | readonly agentTaskId: string; | ||
| 29 | readonly prompt: string; | ||
| 30 | readonly workspaceId: Id<"workspaces">; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function TaskMessage({ agentTaskId, prompt, workspaceId }: TaskMessageProps) { | ||
| 34 | const instance = useInstance(); | ||
| 35 | const resolveApproval = useMutation(api.executor.resolveApproval); | ||
| 36 | |||
| 37 | // Reactive queries — no polling! | ||
| 38 | const agentTask = useQuery(api.database.getAgentTask, { agentTaskId }); | ||
| 39 | const pendingApprovals = useQuery(api.workspace.listPendingApprovals, { workspaceId }); | ||
| 40 | |||
| 41 | const status = agentTask?.status ?? "running"; | ||
| 42 | const isDone = status !== "running"; | ||
| 43 | |||
| 44 | // Deactivate instance after completion (give Discord a moment to render) | ||
| 45 | if (isDone) { | ||
| 46 | setTimeout(() => instance.deactivate(), 5000); | ||
| 47 | } | ||
| 48 | |||
| 49 | const handleApproval = useCallback(async (approvalId: string, decision: "approved" | "denied") => { | ||
| 50 | try { | ||
| 51 | await resolveApproval({ | ||
| 52 | workspaceId, | ||
| 53 | approvalId, | ||
| 54 | decision, | ||
| 55 | }); | ||
| 56 | } catch (err) { | ||
| 57 | console.error(`[approval ${approvalId}]`, err); | ||
| 58 | } | ||
| 59 | }, [resolveApproval, workspaceId]); | ||
| 60 | |||
| 61 | const accentColor = isDone | ||
| 62 | ? status === "completed" ? 0x57f287 : 0xed4245 | ||
| 63 | : 0x5865f2; | ||
| 64 | |||
| 65 | const statusEmoji = status === "running" ? "\u23f3" : status === "completed" ? "\u2705" : "\u274c"; | ||
| 66 | const statusMessage = status === "running" | ||
| 67 | ? (pendingApprovals && pendingApprovals.length > 0 ? "Waiting for approval..." : "Thinking...") | ||
| 68 | : status === "completed" ? "Completed" : "Failed"; | ||
| 69 | |||
| 70 | return ( | ||
| 71 | <Container accentColor={accentColor}> | ||
| 72 | <TextDisplay>{`${statusEmoji} **${statusMessage}**`}</TextDisplay> | ||
| 73 | <TextDisplay>{`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`}</TextDisplay> | ||
| 74 | |||
| 75 | {pendingApprovals?.map((approval: any) => ( | ||
| 76 | <ApprovalSection | ||
| 77 | key={approval.id} | ||
| 78 | approval={approval} | ||
| 79 | onDecision={(d) => handleApproval(approval.id, d)} | ||
| 80 | /> | ||
| 81 | ))} | ||
| 82 | |||
| 83 | {agentTask?.error && ( | ||
| 84 | <> | ||
| 85 | <Separator /> | ||
| 86 | <TextDisplay>{`\u274c **Error:** ${agentTask.error.slice(0, 500)}`}</TextDisplay> | ||
| 87 | </> | ||
| 88 | )} | ||
| 89 | |||
| 90 | {agentTask?.resultText && ( | ||
| 91 | <> | ||
| 92 | <Separator /> | ||
| 93 | <TextDisplay> | ||
| 94 | {agentTask.resultText.length > 1800 | ||
| 95 | ? agentTask.resultText.slice(0, 1800) + "..." | ||
| 96 | : agentTask.resultText} | ||
| 97 | </TextDisplay> | ||
| 98 | </> | ||
| 99 | )} | ||
| 100 | |||
| 101 | {status === "running" && (!pendingApprovals || pendingApprovals.length === 0) && <Loading />} | ||
| 102 | </Container> | ||
| 103 | ); | ||
| 104 | } | ||
| 105 | |||
| 106 | // --------------------------------------------------------------------------- | ||
| 107 | // Approval section | ||
| 108 | // --------------------------------------------------------------------------- | ||
| 109 | |||
| 110 | function ApprovalSection({ | ||
| 111 | approval, | ||
| 112 | onDecision, | ||
| 113 | }: { | ||
| 114 | approval: { id: string; toolPath: string; input: unknown }; | ||
| 115 | onDecision: (decision: "approved" | "denied") => void; | ||
| 116 | }) { | ||
| 117 | const [resolved, setResolved] = useState(false); | ||
| 118 | const toolName = approval.toolPath.split(".").pop() ?? approval.toolPath; | ||
| 119 | |||
| 120 | const handle = (decision: "approved" | "denied") => { | ||
| 121 | setResolved(true); | ||
| 122 | onDecision(decision); | ||
| 123 | }; | ||
| 124 | |||
| 125 | return ( | ||
| 126 | <> | ||
| 127 | <Separator /> | ||
| 128 | <TextDisplay>{`\u{1f6e1}\ufe0f **Approval Required:** \`${toolName}\``}</TextDisplay> | ||
| 129 | {!resolved && ( | ||
| 130 | <ActionRow> | ||
| 131 | <Button label="Approve" style="success" onClick={() => handle("approved")} /> | ||
| 132 | <Button label="Deny" style="danger" onClick={() => handle("denied")} /> | ||
| 133 | </ActionRow> | ||
| 134 | )} | ||
| 135 | </> | ||
| 136 | ); | ||
| 137 | } | ||
| @@ -77,8 +77,10 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str · 76 unmodified lines | |||
| 77 | export interface AgentOptions { | 77 | export 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; |
| @@ -1,81 +1,36 @@ | |||
| 1 | /** | ||
| 2 | * Assistant Server entry point. | ||
| 3 | */ | ||
| 4 | |||
| 5 | import { createApp } from "./routes"; | 1 | import { createApp } from "./routes"; |
| 6 | import { ConvexHttpClient } from "convex/browser"; | 2 | import { defaultLinksFilePath } from "./link-store"; |
| 7 | import { api } from "@executor/convex/_generated/api"; | ||
| 8 | import type { Id } from "@executor/convex/_generated/dataModel"; | ||
| 9 | 3 | ||
| 10 | const PORT = Number(Bun.env.ASSISTANT_PORT ?? Bun.env.PORT ?? 3002); | 4 | const PORT = Number(Bun.env.ASSISTANT_PORT ?? Bun.env.PORT ?? 3002); |
| 11 | const CONVEX_URL = Bun.env.CONVEX_URL; | 5 | const CONVEX_URL = Bun.env.CONVEX_URL; |
| 12 | if (!CONVEX_URL) { | 6 | if (!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 | |||
| 15 | const EXECUTOR_URL = Bun.env.EXECUTOR_URL | 10 | const EXECUTOR_URL = Bun.env.EXECUTOR_URL |
| 16 | ?? Bun.env.CONVEX_SITE_URL | 11 | ?? 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); |
| 20 | const PINNED_WORKSPACE_ID = Bun.env.EXECUTOR_WORKSPACE_ID?.trim(); | ||
| 21 | const PINNED_ACTOR_ID = Bun.env.EXECUTOR_ACTOR_ID?.trim(); | ||
| 22 | const PINNED_CLIENT_ID = Bun.env.EXECUTOR_CLIENT_ID?.trim(); | ||
| 23 | const ANON_SESSION_ID = Bun.env.EXECUTOR_ANON_SESSION_ID?.trim(); | ||
| 24 | |||
| 25 | // Bootstrap anonymous context on executor Convex backend | ||
| 26 | const convex = new ConvexHttpClient(CONVEX_URL); | ||
| 27 | let ctx: { workspaceId: Id<"workspaces">; actorId: string; clientId?: string }; | ||
| 28 | |||
| 29 | if (PINNED_WORKSPACE_ID && PINNED_ACTOR_ID) { | ||
| 30 | ctx = { | ||
| 31 | workspaceId: PINNED_WORKSPACE_ID as Id<"workspaces">, | ||
| 32 | actorId: PINNED_ACTOR_ID, | ||
| 33 | clientId: PINNED_CLIENT_ID, | ||
| 34 | }; | ||
| 35 | } else { | ||
| 36 | const MAX_RETRIES = 30; | ||
| 37 | const RETRY_DELAY_MS = 2000; | ||
| 38 | for (let attempt = 1; ; attempt++) { | ||
| 39 | try { | ||
| 40 | const bootstrap = await convex.mutation(api.workspace.bootstrapAnonymousSession, { | ||
| 41 | sessionId: ANON_SESSION_ID, | ||
| 42 | }); | ||
| 43 | ctx = { | ||
| 44 | workspaceId: bootstrap.workspaceId, | ||
| 45 | actorId: bootstrap.actorId, | ||
| 46 | clientId: PINNED_CLIENT_ID ?? bootstrap.clientId, | ||
| 47 | }; | ||
| 48 | break; | ||
| 49 | } catch (error) { | ||
| 50 | if (attempt >= MAX_RETRIES) { | ||
| 51 | console.error("Failed to bootstrap executor context from Convex at", CONVEX_URL, error); | ||
| 52 | process.exit(1); | ||
| 53 | } | ||
| 54 | console.log(`[assistant] Waiting for Convex functions... (attempt ${attempt}/${MAX_RETRIES})`); | ||
| 55 | await Bun.sleep(RETRY_DELAY_MS); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | 15 | ||
| 60 | console.log(`[assistant] executor context: workspace=${ctx.workspaceId} actor=${ctx.actorId}`); | 16 | const contextLines: string[] = []; |
| 61 | if (ANON_SESSION_ID) { | 17 | if (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 | ||
| 65 | const contextLines: string[] = []; | 21 | const linksFile = Bun.env.ASSISTANT_LINKS_FILE ?? defaultLinksFilePath; |
| 66 | if (Bun.env.POSTHOG_PROJECT_ID) contextLines.push(`- PostHog project ID: ${Bun.env.POSTHOG_PROJECT_ID}`); | ||
| 67 | 22 | ||
| 68 | const app = createApp({ | 23 | const 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 | ||
| 77 | app.listen(PORT); | 31 | app.listen(PORT); |
| 78 | console.log(`[assistant] server running at http://localhost:${PORT}`); | 32 | console.log(`[assistant] server running at http://localhost:${PORT}`); |
| 79 | console.log(`[assistant] executor at ${EXECUTOR_URL}`); | 33 | console.log(`[assistant] executor at ${EXECUTOR_URL}`); |
| 34 | console.log(`[assistant] link store at ${linksFile}`); | ||
| 80 | 35 | ||
| 81 | export type { App } from "./routes"; | 36 | export type { App } from "./routes"; |
| @@ -0,0 +1,76 @@ | |||
| 1 | import { mkdir } from "node:fs/promises"; | ||
| 2 | import { dirname } from "node:path"; | ||
| 3 | import type { Id } from "@executor/convex/_generated/dataModel"; | ||
| 4 | |||
| 5 | export type LinkedProvider = "anonymous" | "workos"; | ||
| 6 | |||
| 7 | export interface LinkedMcpContext { | ||
| 8 | readonly provider: LinkedProvider; | ||
| 9 | readonly workspaceId: Id<"workspaces">; | ||
| 10 | readonly actorId?: string; | ||
| 11 | readonly sessionId?: string; | ||
| 12 | readonly accessToken?: string; | ||
| 13 | readonly clientId?: string; | ||
| 14 | readonly linkedAt: number; | ||
| 15 | } | ||
| 16 | |||
| 17 | interface LinkStoreFile { | ||
| 18 | readonly version: 1; | ||
| 19 | readonly links: Record<string, LinkedMcpContext>; | ||
| 20 | } | ||
| 21 | |||
| 22 | export const defaultLinksFilePath = new URL("../../../.chat-links.json", import.meta.url).pathname; | ||
| 23 | |||
| 24 | export function createFileLinkStore(filePath = Bun.env.ASSISTANT_LINKS_FILE ?? defaultLinksFilePath) { | ||
| 25 | let loaded = false; | ||
| 26 | let links: Record<string, LinkedMcpContext> = {}; | ||
| 27 | |||
| 28 | async function ensureLoaded() { | ||
| 29 | if (loaded) return; | ||
| 30 | loaded = true; | ||
| 31 | |||
| 32 | const file = Bun.file(filePath); | ||
| 33 | if (!(await file.exists())) return; | ||
| 34 | |||
| 35 | const text = await file.text(); | ||
| 36 | if (!text.trim()) return; | ||
| 37 | |||
| 38 | try { | ||
| 39 | const parsed = JSON.parse(text) as Partial<LinkStoreFile>; | ||
| 40 | if (parsed && parsed.version === 1 && parsed.links && typeof parsed.links === "object") { | ||
| 41 | links = parsed.links; | ||
| 42 | } | ||
| 43 | } catch (error) { | ||
| 44 | console.error(`[assistant] Failed to parse link store '${filePath}':`, error); | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | async function persist() { | ||
| 49 | await mkdir(dirname(filePath), { recursive: true }); | ||
| 50 | const payload: LinkStoreFile = { version: 1, links }; | ||
| 51 | await Bun.write(filePath, JSON.stringify(payload, null, 2)); | ||
| 52 | } | ||
| 53 | |||
| 54 | return { | ||
| 55 | filePath, | ||
| 56 | |||
| 57 | async get(identityKey: string): Promise<LinkedMcpContext | undefined> { | ||
| 58 | await ensureLoaded(); | ||
| 59 | return links[identityKey]; | ||
| 60 | }, | ||
| 61 | |||
| 62 | async set(identityKey: string, value: LinkedMcpContext): Promise<void> { | ||
| 63 | await ensureLoaded(); | ||
| 64 | links[identityKey] = value; | ||
| 65 | await persist(); | ||
| 66 | }, | ||
| 67 | |||
| 68 | async delete(identityKey: string): Promise<boolean> { | ||
| 69 | await ensureLoaded(); | ||
| 70 | if (!links[identityKey]) return false; | ||
| 71 | delete links[identityKey]; | ||
| 72 | await persist(); | ||
| 73 | return true; | ||
| 74 | }, | ||
| 75 | }; | ||
| 76 | } | ||
| @@ -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 | |||
| 8 | import { Elysia, t } from "elysia"; | 1 | import { Elysia, t } from "elysia"; |
| 9 | import { createAgent } from "@assistant/core"; | 2 | import { createAgent } from "@assistant/core"; |
| 10 | import type { TaskEvent } from "@assistant/core"; | ||
| 11 | import { ConvexHttpClient } from "convex/browser"; | 3 | import { ConvexHttpClient } from "convex/browser"; |
| 12 | import { api } from "@executor/convex/_generated/api"; | 4 | import { api } from "@executor/convex/_generated/api"; |
| 13 | import type { Id } from "@executor/convex/_generated/dataModel"; | 5 | import type { Id } from "@executor/convex/_generated/dataModel"; |
| 14 | 6 | import { createFileLinkStore, type LinkedMcpContext } from "./link-store"; | |
| 15 | // --------------------------------------------------------------------------- | ||
| 16 | // Config | ||
| 17 | // --------------------------------------------------------------------------- | ||
| 18 | 7 | ||
| 19 | export interface ServerOptions { | 8 | export 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 | |||
| 16 | interface ChatIdentity { | ||
| 17 | readonly platform: string; | ||
| 18 | readonly userId: string; | ||
| 19 | } | ||
| 20 | |||
| 21 | interface 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 | // --------------------------------------------------------------------------- | 31 | function normalizeIdentityPart(value: string): string { |
| 29 | // App | 32 | return value |
| 30 | // --------------------------------------------------------------------------- | 33 | .toLowerCase() |
| 34 | .trim() | ||
| 35 | .replace(/[^a-z0-9]+/g, "_") | ||
| 36 | .replace(/^_+|_+$/g, "") | ||
| 37 | .slice(0, 64); | ||
| 38 | } | ||
| 31 | 39 | ||
| 32 | export function createApp(options: ServerOptions) { | 40 | function 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, | 44 | function 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 | ||
| 50 | export 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 | } |
| @@ -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=="], |
| @@ -1,45 +1,16 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { Suspense, useEffect, useMemo, useState, type FormEvent } from "react"; | 3 | import { Suspense, useEffect, useState } from "react"; |
| 4 | import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; | 4 | import { Link, useLocation, useNavigate } from "react-router"; |
| 5 | import { useMutation } from "convex/react"; | 5 | import { LayoutDashboard, Play, ShieldCheck, Wrench, Menu, X } from "lucide-react"; |
| 6 | import { | ||
| 7 | LayoutDashboard, | ||
| 8 | Play, | ||
| 9 | ShieldCheck, | ||
| 10 | Wrench, | ||
| 11 | Menu, | ||
| 12 | X, | ||
| 13 | ChevronsUpDown, | ||
| 14 | Plus, | ||
| 15 | Check, | ||
| 16 | Settings, | ||
| 17 | LogIn, | ||
| 18 | } from "lucide-react"; | ||
| 19 | import { Button } from "@/components/ui/button"; | 6 | import { Button } from "@/components/ui/button"; |
| 20 | import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"; | 7 | import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"; |
| 21 | import { | ||
| 22 | DropdownMenu, | ||
| 23 | DropdownMenuContent, | ||
| 24 | DropdownMenuItem, | ||
| 25 | DropdownMenuLabel, | ||
| 26 | DropdownMenuSeparator, | ||
| 27 | DropdownMenuTrigger, | ||
| 28 | } from "@/components/ui/dropdown-menu"; | ||
| 29 | import { | ||
| 30 | Dialog, | ||
| 31 | DialogContent, | ||
| 32 | DialogDescription, | ||
| 33 | DialogFooter, | ||
| 34 | DialogHeader, | ||
| 35 | DialogTitle, | ||
| 36 | } from "@/components/ui/dialog"; | ||
| 37 | import { Input } from "@/components/ui/input"; | ||
| 38 | import { useSession } from "@/lib/session-context"; | 8 | import { useSession } from "@/lib/session-context"; |
| 39 | import { convexApi } from "@/lib/convex-api"; | ||
| 40 | import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities"; | ||
| 41 | import { ThemeSwitcher } from "@/components/theme-switcher"; | ||
| 42 | import { ApprovalNotifier } from "@/components/approval-notifier"; | 9 | import { ApprovalNotifier } from "@/components/approval-notifier"; |
| 10 | import { ThemeSwitcher } from "@/components/theme-switcher"; | ||
| 11 | import { WorkspaceSelector } from "@/components/workspace-selector"; | ||
| 12 | import { SessionInfo } from "@/components/session-info"; | ||
| 13 | import { NoOrganizationModal } from "@/components/no-organization-modal"; | ||
| 43 | import { cn } from "@/lib/utils"; | 14 | import { cn } from "@/lib/utils"; |
| 44 | 15 | ||
| 45 | const NAV_ITEMS = [ | 16 | const NAV_ITEMS = [ |
| @@ -81,530 +52,6 @@ function NavLinks({ onClick }: { onClick?: () => void }) { · 35 unmodified lines | |||
| 81 | ); | 52 | ); |
| 82 | } | 53 | } |
| 83 | 54 | ||
| 84 | function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { | ||
| 85 | const navigate = useNavigate(); | ||
| 86 | const createOrganizationMutation = useMutation(convexApi.organizations.create); | ||
| 87 | const { | ||
| 88 | context, | ||
| 89 | mode, | ||
| 90 | workspaces, | ||
| 91 | switchWorkspace, | ||
| 92 | creatingWorkspace, | ||
| 93 | createWorkspace, | ||
| 94 | } = useSession(); | ||
| 95 | const [createOpen, setCreateOpen] = useState(false); | ||
| 96 | const [newWorkspaceName, setNewWorkspaceName] = useState(""); | ||
| 97 | const [newWorkspaceIcon, setNewWorkspaceIcon] = useState<File | null>(null); | ||
| 98 | const [createScope, setCreateScope] = useState<"current_org" | "new_org">("current_org"); | ||
| 99 | const [createTargetOrganizationId, setCreateTargetOrganizationId] = useState<(typeof workspaces)[number]["organizationId"]>(null); | ||
| 100 | const [createTargetOrganizationName, setCreateTargetOrganizationName] = useState<string | null>(null); | ||
| 101 | const [creatingOrganization, setCreatingOrganization] = useState(false); | ||
| 102 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 103 | |||
| 104 | const activeWorkspace = context | ||
| 105 | ? workspaces.find((workspace) => workspace.id === context.workspaceId) | ||
| 106 | : null; | ||
| 107 | const supportsOrganizationManagement = mode !== "guest"; | ||
| 108 | const activeOrganizationLabel = activeWorkspace?.organizationName ?? "Organization"; | ||
| 109 | const activeWorkspaceLabel = activeWorkspace?.name ?? (mode === "guest" ? "Anonymous Workspace" : "Select workspace"); | ||
| 110 | const activeWorkspaceInitial = (activeWorkspaceLabel[0] ?? "W").toUpperCase(); | ||
| 111 | |||
| 112 | const workspaceGroups = useMemo(() => { | ||
| 113 | if (!supportsOrganizationManagement) { | ||
| 114 | return []; | ||
| 115 | } | ||
| 116 | |||
| 117 | const groups: Array<{ | ||
| 118 | key: string; | ||
| 119 | organizationId: (typeof workspaces)[number]["organizationId"]; | ||
| 120 | organizationName: string; | ||
| 121 | workspaces: Array<(typeof workspaces)[number]>; | ||
| 122 | }> = []; | ||
| 123 | const byKey = new Map<string, number>(); | ||
| 124 | |||
| 125 | for (const workspace of workspaces) { | ||
| 126 | const key = workspace.organizationId ? `org:${workspace.organizationId}` : `workspace:${workspace.id}`; | ||
| 127 | const existingIndex = byKey.get(key); | ||
| 128 | if (existingIndex === undefined) { | ||
| 129 | byKey.set(key, groups.length); | ||
| 130 | groups.push({ | ||
| 131 | key, | ||
| 132 | organizationId: workspace.organizationId, | ||
| 133 | organizationName: workspace.organizationName, | ||
| 134 | workspaces: [workspace], | ||
| 135 | }); | ||
| 136 | continue; | ||
| 137 | } | ||
| 138 | |||
| 139 | groups[existingIndex].workspaces.push(workspace); | ||
| 140 | } | ||
| 141 | |||
| 142 | return groups; | ||
| 143 | }, [supportsOrganizationManagement, workspaces]); | ||
| 144 | |||
| 145 | const openCreateWorkspaceForOrganization = ( | ||
| 146 | organizationId: (typeof workspaces)[number]["organizationId"], | ||
| 147 | organizationName: string, | ||
| 148 | ) => { | ||
| 149 | setCreateError(null); | ||
| 150 | setNewWorkspaceName(""); | ||
| 151 | setNewWorkspaceIcon(null); | ||
| 152 | setCreateScope("current_org"); | ||
| 153 | setCreateTargetOrganizationId(organizationId); | ||
| 154 | setCreateTargetOrganizationName(organizationName); | ||
| 155 | setCreateOpen(true); | ||
| 156 | }; | ||
| 157 | |||
| 158 | const openCreateOrganization = () => { | ||
| 159 | setCreateError(null); | ||
| 160 | setNewWorkspaceName(""); | ||
| 161 | setNewWorkspaceIcon(null); | ||
| 162 | setCreateScope("new_org"); | ||
| 163 | setCreateTargetOrganizationId(null); | ||
| 164 | setCreateTargetOrganizationName(null); | ||
| 165 | setCreateOpen(true); | ||
| 166 | }; | ||
| 167 | |||
| 168 | const handleCreateWorkspace = async (event?: FormEvent<HTMLFormElement>) => { | ||
| 169 | event?.preventDefault(); | ||
| 170 | |||
| 171 | const trimmed = newWorkspaceName.trim(); | ||
| 172 | if (trimmed.length < 2) { | ||
| 173 | setCreateError("Workspace name must be at least 2 characters."); | ||
| 174 | return; | ||
| 175 | } | ||
| 176 | |||
| 177 | try { | ||
| 178 | if (createScope === "new_org") { | ||
| 179 | setCreatingOrganization(true); | ||
| 180 | const created = await createOrganizationMutation({ name: trimmed }); | ||
| 181 | switchWorkspace(created.workspace.id); | ||
| 182 | } else { | ||
| 183 | if (!createTargetOrganizationId) { | ||
| 184 | throw new Error("Select an organization first"); | ||
| 185 | } | ||
| 186 | |||
| 187 | await createWorkspace(trimmed, newWorkspaceIcon, createTargetOrganizationId); | ||
| 188 | } | ||
| 189 | |||
| 190 | setCreateError(null); | ||
| 191 | setNewWorkspaceName(""); | ||
| 192 | setNewWorkspaceIcon(null); | ||
| 193 | setCreateOpen(false); | ||
| 194 | } catch (cause) { | ||
| 195 | const fallback = createScope === "new_org" ? "Failed to create organization" : "Failed to create workspace"; | ||
| 196 | setCreateError(cause instanceof Error ? cause.message : fallback); | ||
| 197 | } finally { | ||
| 198 | setCreatingOrganization(false); | ||
| 199 | } | ||
| 200 | }; | ||
| 201 | |||
| 202 | const triggerClassName = inHeader | ||
| 203 | ? "h-full w-full justify-between rounded-none border-0 bg-transparent px-3 text-[12px] font-medium shadow-none hover:bg-accent/40" | ||
| 204 | : "h-8 w-full justify-between text-[11px]"; | ||
| 205 | const isCreating = creatingWorkspace || creatingOrganization; | ||
| 206 | |||
| 207 | return ( | ||
| 208 | <> | ||
| 209 | <DropdownMenu> | ||
| 210 | <DropdownMenuTrigger asChild> | ||
| 211 | <Button | ||
| 212 | variant={inHeader ? "ghost" : "outline"} | ||
| 213 | size="sm" | ||
| 214 | className={triggerClassName} | ||
| 215 | > | ||
| 216 | <span className="flex items-center gap-2 min-w-0"> | ||
| 217 | {activeWorkspace?.iconUrl ? ( | ||
| 218 | <img | ||
| 219 | src={activeWorkspace.iconUrl} | ||
| 220 | alt={activeWorkspaceLabel} | ||
| 221 | className="h-4 w-4 rounded-sm border border-border object-cover" | ||
| 222 | /> | ||
| 223 | ) : ( | ||
| 224 | <span className="h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground"> | ||
| 225 | {activeWorkspaceInitial} | ||
| 226 | </span> | ||
| 227 | )} | ||
| 228 | <span className="min-w-0"> | ||
| 229 | {supportsOrganizationManagement ? ( | ||
| 230 | <> | ||
| 231 | <span className="truncate block text-[10px] text-muted-foreground">{activeOrganizationLabel}</span> | ||
| 232 | <span className="truncate block">{activeWorkspaceLabel}</span> | ||
| 233 | </> | ||
| 234 | ) : ( | ||
| 235 | <span className="truncate block">{activeWorkspaceLabel}</span> | ||
| 236 | )} | ||
| 237 | </span> | ||
| 238 | </span> | ||
| 239 | <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 240 | </Button> | ||
| 241 | </DropdownMenuTrigger> | ||
| 242 | <DropdownMenuContent align="end" className="w-72"> | ||
| 243 | {supportsOrganizationManagement | ||
| 244 | ? ( | ||
| 245 | <> | ||
| 246 | {workspaceGroups.map((group, groupIndex) => { | ||
| 247 | const settingsWorkspace = group.workspaces.find((workspace) => workspace.id === context?.workspaceId) | ||
| 248 | ?? group.workspaces[0] | ||
| 249 | ?? null; | ||
| 250 | |||
| 251 | return ( | ||
| 252 | <div key={group.key}> | ||
| 253 | {groupIndex > 0 ? <DropdownMenuSeparator /> : null} | ||
| 254 | <div className="px-2 py-1.5 flex items-center justify-between gap-2"> | ||
| 255 | <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground truncate"> | ||
| 256 | {group.organizationName} | ||
| 257 | </span> | ||
| 258 | {settingsWorkspace ? ( | ||
| 259 | <button | ||
| 260 | type="button" | ||
| 261 | className="inline-flex h-5 w-5 items-center justify-center rounded-sm border border-border text-muted-foreground hover:bg-accent hover:text-foreground" | ||
| 262 | onClick={() => { | ||
| 263 | switchWorkspace(settingsWorkspace.id); | ||
| 264 | navigate("/organization?tab=members"); | ||
| 265 | }} | ||
| 266 | aria-label={`Open ${group.organizationName} settings`} | ||
| 267 | title="Organization settings" | ||
| 268 | > | ||
| 269 | <Settings className="h-3 w-3" /> | ||
| 270 | </button> | ||
| 271 | ) : null} | ||
| 272 | </div> | ||
| 273 | {group.workspaces.map((workspace) => { | ||
| 274 | const isActive = workspace.id === context?.workspaceId; | ||
| 275 | return ( | ||
| 276 | <DropdownMenuItem | ||
| 277 | key={workspace.id} | ||
| 278 | onSelect={() => switchWorkspace(workspace.id)} | ||
| 279 | className="text-xs" | ||
| 280 | > | ||
| 281 | <Check className={cn("mr-2 h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} /> | ||
| 282 | {workspace.iconUrl ? ( | ||
| 283 | <img | ||
| 284 | src={workspace.iconUrl} | ||
| 285 | alt={workspace.name} | ||
| 286 | className="mr-2 h-4 w-4 rounded-sm border border-border object-cover" | ||
| 287 | /> | ||
| 288 | ) : ( | ||
| 289 | <span className="mr-2 h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground"> | ||
| 290 | {(workspace.name[0] ?? "W").toUpperCase()} | ||
| 291 | </span> | ||
| 292 | )} | ||
| 293 | <span className="min-w-0 truncate">{workspace.name}</span> | ||
| 294 | </DropdownMenuItem> | ||
| 295 | ); | ||
| 296 | })} | ||
| 297 | |||
| 298 | {group.organizationId ? ( | ||
| 299 | <DropdownMenuItem | ||
| 300 | onSelect={() => openCreateWorkspaceForOrganization(group.organizationId, group.organizationName)} | ||
| 301 | className="text-xs" | ||
| 302 | > | ||
| 303 | <Plus className="mr-2 h-3.5 w-3.5" /> | ||
| 304 | New workspace | ||
| 305 | </DropdownMenuItem> | ||
| 306 | ) : null} | ||
| 307 | </div> | ||
| 308 | ); | ||
| 309 | })} | ||
| 310 | |||
| 311 | {workspaces.length === 0 ? ( | ||
| 312 | <DropdownMenuItem disabled className="text-xs"> | ||
| 313 | No workspaces | ||
| 314 | </DropdownMenuItem> | ||
| 315 | ) : null} | ||
| 316 | </> | ||
| 317 | ) | ||
| 318 | : ( | ||
| 319 | <DropdownMenuItem disabled className="text-xs"> | ||
| 320 | Guest workspace | ||
| 321 | </DropdownMenuItem> | ||
| 322 | )} | ||
| 323 | {supportsOrganizationManagement ? ( | ||
| 324 | <> | ||
| 325 | <DropdownMenuSeparator /> | ||
| 326 | <DropdownMenuItem onSelect={openCreateOrganization} className="text-xs"> | ||
| 327 | <Plus className="mr-2 h-3.5 w-3.5" /> | ||
| 328 | New organization | ||
| 329 | </DropdownMenuItem> | ||
| 330 | |||
| 331 | </> | ||
| 332 | ) : null} | ||
| 333 | </DropdownMenuContent> | ||
| 334 | </DropdownMenu> | ||
| 335 | |||
| 336 | <Dialog open={createOpen} onOpenChange={setCreateOpen}> | ||
| 337 | <DialogContent> | ||
| 338 | <form className="space-y-4" onSubmit={handleCreateWorkspace}> | ||
| 339 | <DialogHeader> | ||
| 340 | <DialogTitle>{createScope === "new_org" ? "Create organization" : "Create workspace"}</DialogTitle> | ||
| 341 | <DialogDescription> | ||
| 342 | {createScope === "new_org" | ||
| 343 | ? "Create a new organization with a default workspace." | ||
| 344 | : `Create a workspace inside ${createTargetOrganizationName ?? "this organization"}.`} | ||
| 345 | </DialogDescription> | ||
| 346 | </DialogHeader> | ||
| 347 | <div className="space-y-2"> | ||
| 348 | <Input | ||
| 349 | value={newWorkspaceName} | ||
| 350 | onChange={(event) => { | ||
| 351 | setCreateError(null); | ||
| 352 | setNewWorkspaceName(event.target.value); | ||
| 353 | }} | ||
| 354 | placeholder={createScope === "new_org" ? "Acme" : "Marketing"} | ||
| 355 | maxLength={64} | ||
| 356 | /> | ||
| 357 | {createScope === "current_org" ? ( | ||
| 358 | <> | ||
| 359 | <Input | ||
| 360 | type="file" | ||
| 361 | accept="image/*" | ||
| 362 | onChange={(event) => { | ||
| 363 | setCreateError(null); | ||
| 364 | setNewWorkspaceIcon(event.target.files?.[0] ?? null); | ||
| 365 | }} | ||
| 366 | /> | ||
| 367 | {newWorkspaceIcon ? ( | ||
| 368 | <p className="text-[11px] text-muted-foreground truncate"> | ||
| 369 | Icon: {newWorkspaceIcon.name} | ||
| 370 | </p> | ||
| 371 | ) : null} | ||
| 372 | </> | ||
| 373 | ) : null} | ||
| 374 | {createError ? ( | ||
| 375 | <p className="text-xs text-destructive">{createError}</p> | ||
| 376 | ) : null} | ||
| 377 | </div> | ||
| 378 | <DialogFooter> | ||
| 379 | <Button | ||
| 380 | type="button" | ||
| 381 | variant="outline" | ||
| 382 | onClick={() => setCreateOpen(false)} | ||
| 383 | disabled={isCreating} | ||
| 384 | > | ||
| 385 | Cancel | ||
| 386 | </Button> | ||
| 387 | <Button type="submit" disabled={isCreating}> | ||
| 388 | {isCreating | ||
| 389 | ? "Creating..." | ||
| 390 | : createScope === "new_org" | ||
| 391 | ? "Create organization" | ||
| 392 | : "Create workspace"} | ||
| 393 | </Button> | ||
| 394 | </DialogFooter> | ||
| 395 | </form> | ||
| 396 | </DialogContent> | ||
| 397 | </Dialog> | ||
| 398 | </> | ||
| 399 | ); | ||
| 400 | } | ||
| 401 | |||
| 402 | function SessionInfo() { | ||
| 403 | const { | ||
| 404 | loading, | ||
| 405 | isSignedInToWorkos, | ||
| 406 | workosProfile, | ||
| 407 | context, | ||
| 408 | workspaces, | ||
| 409 | resetWorkspace, | ||
| 410 | } = useSession(); | ||
| 411 | const [searchParams] = useSearchParams(); | ||
| 412 | const deleteCurrentAccountMutation = useMutation(convexApi.accounts.deleteCurrentAccount); | ||
| 413 | const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); | ||
| 414 | const [deleteConfirmText, setDeleteConfirmText] = useState(""); | ||
| 415 | const [deleteError, setDeleteError] = useState<string | null>(null); | ||
| 416 | const [deletingAccount, setDeletingAccount] = useState(false); | ||
| 417 | const avatarUrl = workosProfile?.avatarUrl ?? null; | ||
| 418 | const avatarLabel = workosProfile?.name || workosProfile?.email || "User"; | ||
| 419 | const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase(); | ||
| 420 | const canDeleteAccount = deleteConfirmText === "DELETE"; | ||
| 421 | |||
| 422 | const activeWorkspace = context | ||
| 423 | ? workspaces.find((workspace) => workspace.id === context.workspaceId) | ||
| 424 | : null; | ||
| 425 | const inferredOrganizationId = activeWorkspace?.organizationId ?? undefined; | ||
| 426 | const hintedOrganizationId = | ||
| 427 | searchParams.get("organization_id") | ||
| 428 | ?? searchParams.get("organizationId") | ||
| 429 | ?? searchParams.get("org_id") | ||
| 430 | ?? searchParams.get("orgId") | ||
| 431 | ?? inferredOrganizationId | ||
| 432 | ?? undefined; | ||
| 433 | const hintedLogin = | ||
| 434 | searchParams.get("login_hint") | ||
| 435 | ?? searchParams.get("loginHint") | ||
| 436 | ?? searchParams.get("email") | ||
| 437 | ?? undefined; | ||
| 438 | const signInParams = new URLSearchParams(); | ||
| 439 | if (hintedOrganizationId) { | ||
| 440 | signInParams.set("organization_id", hintedOrganizationId); | ||
| 441 | } | ||
| 442 | if (hintedLogin) { | ||
| 443 | signInParams.set("login_hint", hintedLogin); | ||
| 444 | } | ||
| 445 | const signInHref = signInParams.size > 0 ? `/sign-in?${signInParams.toString()}` : "/sign-in"; | ||
| 446 | |||
| 447 | const handleDeleteAccount = async (event: FormEvent<HTMLFormElement>) => { | ||
| 448 | event.preventDefault(); | ||
| 449 | if (!canDeleteAccount || deletingAccount) { | ||
| 450 | return; | ||
| 451 | } | ||
| 452 | |||
| 453 | setDeletingAccount(true); | ||
| 454 | setDeleteError(null); | ||
| 455 | try { | ||
| 456 | await deleteCurrentAccountMutation({ | ||
| 457 | sessionId: context?.sessionId ?? undefined, | ||
| 458 | }); | ||
| 459 | await resetWorkspace(); | ||
| 460 | window.location.assign(workosEnabled ? "/sign-out" : "/"); | ||
| 461 | } catch (cause) { | ||
| 462 | const message = cause instanceof Error ? cause.message : "Failed to delete account"; | ||
| 463 | setDeleteError(message); | ||
| 464 | setDeletingAccount(false); | ||
| 465 | } | ||
| 466 | }; | ||
| 467 | |||
| 468 | if (loading) { | ||
| 469 | return ( | ||
| 470 | <div className="border-t border-border px-3 py-2"> | ||
| 471 | <span className="text-[11px] font-mono text-muted-foreground">Loading session...</span> | ||
| 472 | </div> | ||
| 473 | ); | ||
| 474 | } | ||
| 475 | |||
| 476 | return ( | ||
| 477 | <div className="border-t border-border"> | ||
| 478 | {isSignedInToWorkos ? ( | ||
| 479 | <> | ||
| 480 | <DropdownMenu> | ||
| 481 | <DropdownMenuTrigger asChild> | ||
| 482 | <Button | ||
| 483 | variant="ghost" | ||
| 484 | className="h-14 w-full justify-between rounded-none border-0 bg-transparent px-3 py-0 text-left shadow-none hover:bg-accent/40" | ||
| 485 | > | ||
| 486 | <span className="flex items-center gap-2 min-w-0"> | ||
| 487 | {avatarUrl ? ( | ||
| 488 | <img | ||
| 489 | src={avatarUrl} | ||
| 490 | alt={avatarLabel} | ||
| 491 | className="h-6 w-6 rounded-full border border-border object-cover" | ||
| 492 | /> | ||
| 493 | ) : ( | ||
| 494 | <span className="h-6 w-6 rounded-full border border-border bg-muted text-[10px] font-mono text-muted-foreground flex items-center justify-center"> | ||
| 495 | {avatarInitial} | ||
| 496 | </span> | ||
| 497 | )} | ||
| 498 | <span className="min-w-0"> | ||
| 499 | <span className="text-[11px] font-medium truncate block">{avatarLabel}</span> | ||
| 500 | {workosProfile?.email ? ( | ||
| 501 | <span className="text-[10px] text-muted-foreground truncate block">{workosProfile.email}</span> | ||
| 502 | ) : null} | ||
| 503 | </span> | ||
| 504 | </span> | ||
| 505 | <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 506 | </Button> | ||
| 507 | </DropdownMenuTrigger> | ||
| 508 | <DropdownMenuContent align="end" className="w-64"> | ||
| 509 | <DropdownMenuLabel className="text-xs"> | ||
| 510 | Account | ||
| 511 | </DropdownMenuLabel> | ||
| 512 | <DropdownMenuSeparator /> | ||
| 513 | <DropdownMenuItem | ||
| 514 | className="text-xs" | ||
| 515 | onSelect={(event) => { | ||
| 516 | event.preventDefault(); | ||
| 517 | setDeleteConfirmText(""); | ||
| 518 | setDeleteError(null); | ||
| 519 | setAccountSettingsOpen(true); | ||
| 520 | }} | ||
| 521 | > | ||
| 522 | Account Settings | ||
| 523 | </DropdownMenuItem> | ||
| 524 | <DropdownMenuItem asChild className="text-xs"> | ||
| 525 | <Link to="/sign-out" reloadDocument>Sign out</Link> | ||
| 526 | </DropdownMenuItem> | ||
| 527 | </DropdownMenuContent> | ||
| 528 | </DropdownMenu> | ||
| 529 | |||
| 530 | <Dialog | ||
| 531 | open={accountSettingsOpen} | ||
| 532 | onOpenChange={(open) => { | ||
| 533 | setAccountSettingsOpen(open); | ||
| 534 | if (!open) { | ||
| 535 | setDeleteConfirmText(""); | ||
| 536 | setDeleteError(null); | ||
| 537 | setDeletingAccount(false); | ||
| 538 | } | ||
| 539 | }} | ||
| 540 | > | ||
| 541 | <DialogContent className="sm:max-w-md"> | ||
| 542 | <form className="space-y-4" onSubmit={handleDeleteAccount}> | ||
| 543 | <DialogHeader> | ||
| 544 | <DialogTitle>Account Settings</DialogTitle> | ||
| 545 | <DialogDescription> | ||
| 546 | Deleting your account will remove organizations, workspaces, and data you created. | ||
| 547 | This cannot be undone. | ||
| 548 | </DialogDescription> | ||
| 549 | </DialogHeader> | ||
| 550 | <div className="space-y-2"> | ||
| 551 | <label htmlFor="delete-account-confirm" className="text-xs font-medium text-foreground"> | ||
| 552 | Type DELETE to confirm | ||
| 553 | </label> | ||
| 554 | <Input | ||
| 555 | id="delete-account-confirm" | ||
| 556 | value={deleteConfirmText} | ||
| 557 | onChange={(event) => setDeleteConfirmText(event.target.value)} | ||
| 558 | autoComplete="off" | ||
| 559 | spellCheck={false} | ||
| 560 | /> | ||
| 561 | {deleteError ? ( | ||
| 562 | <p className="text-xs text-destructive">{deleteError}</p> | ||
| 563 | ) : null} | ||
| 564 | </div> | ||
| 565 | <DialogFooter> | ||
| 566 | <Button | ||
| 567 | type="button" | ||
| 568 | variant="outline" | ||
| 569 | onClick={() => setAccountSettingsOpen(false)} | ||
| 570 | disabled={deletingAccount} | ||
| 571 | > | ||
| 572 | Cancel | ||
| 573 | </Button> | ||
| 574 | <Button | ||
| 575 | type="submit" | ||
| 576 | variant="destructive" | ||
| 577 | disabled={!canDeleteAccount || deletingAccount} | ||
| 578 | > | ||
| 579 | {deletingAccount ? "Deleting..." : "Delete account"} | ||
| 580 | </Button> | ||
| 581 | </DialogFooter> | ||
| 582 | </form> | ||
| 583 | </DialogContent> | ||
| 584 | </Dialog> | ||
| 585 | </> | ||
| 586 | ) : ( | ||
| 587 | <div className="px-3 py-3 space-y-2"> | ||
| 588 | {workosEnabled ? ( | ||
| 589 | <Link to={signInHref} reloadDocument className="block"> | ||
| 590 | <Button | ||
| 591 | variant="outline" | ||
| 592 | className="w-full h-9 justify-center gap-2 text-xs font-medium" | ||
| 593 | > | ||
| 594 | <LogIn className="h-3.5 w-3.5" /> | ||
| 595 | Sign in | ||
| 596 | </Button> | ||
| 597 | </Link> | ||
| 598 | ) : ( | ||
| 599 | <p className="text-[11px] text-muted-foreground text-center">Guest mode</p> | ||
| 600 | )} | ||
| 601 | </div> | ||
| 602 | )} | ||
| 603 | |||
| 604 | </div> | ||
| 605 | ); | ||
| 606 | } | ||
| 607 | |||
| 608 | function Sidebar() { | 55 | function 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 | ||
| 665 | function NoOrganizationModal({ enabled }: { enabled: boolean }) { | ||
| 666 | const { | ||
| 667 | loading, | ||
| 668 | organizations, | ||
| 669 | organizationsLoading, | ||
| 670 | context, | ||
| 671 | isSignedInToWorkos, | ||
| 672 | createAnonymousOrganization, | ||
| 673 | creatingAnonymousOrganization, | ||
| 674 | } = useSession(); | ||
| 675 | const [error, setError] = useState<string | null>(null); | ||
| 676 | |||
| 677 | const shouldShow = enabled | ||
| 678 | && !loading | ||
| 679 | && !organizationsLoading | ||
| 680 | && !context | ||
| 681 | && !isSignedInToWorkos | ||
| 682 | && organizations.length === 0; | ||
| 683 | |||
| 684 | const handleCreateAnonymousOrganization = async () => { | ||
| 685 | setError(null); | ||
| 686 | try { | ||
| 687 | await createAnonymousOrganization(); | ||
| 688 | } catch (cause) { | ||
| 689 | const message = cause instanceof Error ? cause.message : "Failed to create anonymous organization"; | ||
| 690 | setError(message); | ||
| 691 | } | ||
| 692 | }; | ||
| 693 | |||
| 694 | return ( | ||
| 695 | <Dialog open={shouldShow}> | ||
| 696 | <DialogContent | ||
| 697 | showCloseButton={false} | ||
| 698 | onEscapeKeyDown={(event) => event.preventDefault()} | ||
| 699 | onInteractOutside={(event) => event.preventDefault()} | ||
| 700 | > | ||
| 701 | <DialogHeader> | ||
| 702 | <DialogTitle>Choose how to continue</DialogTitle> | ||
| 703 | <DialogDescription> | ||
| 704 | Sign in to access your organizations, or create an anonymous organization with a default workspace. | ||
| 705 | </DialogDescription> | ||
| 706 | </DialogHeader> | ||
| 707 | <div className="space-y-2"> | ||
| 708 | {workosEnabled ? ( | ||
| 709 | <Button asChild className="w-full" disabled={creatingAnonymousOrganization}> | ||
| 710 | <Link to="/sign-in" reloadDocument className="gap-2"> | ||
| 711 | <LogIn className="h-4 w-4" /> | ||
| 712 | Sign in | ||
| 713 | </Link> | ||
| 714 | </Button> | ||
| 715 | ) : null} | ||
| 716 | <Button | ||
| 717 | variant="outline" | ||
| 718 | className="w-full" | ||
| 719 | onClick={handleCreateAnonymousOrganization} | ||
| 720 | disabled={creatingAnonymousOrganization || !anonymousDemoEnabled} | ||
| 721 | > | ||
| 722 | {creatingAnonymousOrganization ? "Creating anonymous organization..." : "Create anonymous organization"} | ||
| 723 | </Button> | ||
| 724 | {!anonymousDemoEnabled ? ( | ||
| 725 | <p className="text-xs text-muted-foreground">Anonymous organization creation is disabled.</p> | ||
| 726 | ) : null} | ||
| 727 | {error ? <p className="text-xs text-destructive">{error}</p> : null} | ||
| 728 | </div> | ||
| 729 | </DialogContent> | ||
| 730 | </Dialog> | ||
| 731 | ); | ||
| 732 | } | ||
| 733 | |||
| 734 | export function AppShell({ children }: { children: React.ReactNode }) { | 112 | export function AppShell({ children }: { children: React.ReactNode }) { |
| 735 | const location = useLocation(); | 113 | const location = useLocation(); |
| 736 | const pathname = location.pathname; | 114 | const pathname = location.pathname; |
| @@ -7,6 +7,7 @@ import { toast } from "sonner"; · 6 unmodified lines | |||
| 7 | import { useSession } from "@/lib/session-context"; | 7 | import { useSession } from "@/lib/session-context"; |
| 8 | import { convexApi } from "@/lib/convex-api"; | 8 | import { convexApi } from "@/lib/convex-api"; |
| 9 | import type { PendingApprovalRecord } from "@/lib/types"; | 9 | import type { PendingApprovalRecord } from "@/lib/types"; |
| 10 | import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | ||
| 10 | 11 | ||
| 11 | export function ApprovalNotifier() { | 12 | export 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(() => { |
| @@ -21,62 +21,8 @@ import type { PendingApprovalRecord } from "@/lib/types"; · 20 unmodified lines | |||
| 21 | import { toast } from "sonner"; | 21 | import { toast } from "sonner"; |
| 22 | import { formatTimeAgo } from "@/lib/format"; | 22 | import { formatTimeAgo } from "@/lib/format"; |
| 23 | import { FormattedCodeBlock } from "@/components/formatted-code-block"; | 23 | import { FormattedCodeBlock } from "@/components/formatted-code-block"; |
| 24 | 24 | import { formatApprovalInput } from "@/lib/approval-input-format"; | |
| 25 | function formatApprovalInput( | 25 | import { 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 | ||
| 81 | function ApprovalCard({ | 27 | function 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> |
| @@ -0,0 +1,193 @@ | |||
| 1 | import type { EditorProps, Monaco } from "@monaco-editor/react"; | ||
| 2 | |||
| 3 | const DIAGNOSTIC_CODES_TO_IGNORE = [ | ||
| 4 | 1375, | ||
| 5 | 1378, | ||
| 6 | 2307, | ||
| 7 | 80005, | ||
| 8 | ]; | ||
| 9 | |||
| 10 | export function setDiagnosticsOptions(monaco: Monaco, suppressSemantic: boolean) { | ||
| 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| 12 | const ts = (monaco.languages as any).typescript; | ||
| 13 | ts.javascriptDefaults.setDiagnosticsOptions({ | ||
| 14 | noSemanticValidation: suppressSemantic, | ||
| 15 | noSyntaxValidation: false, | ||
| 16 | diagnosticCodesToIgnore: DIAGNOSTIC_CODES_TO_IGNORE, | ||
| 17 | }); | ||
| 18 | } | ||
| 19 | |||
| 20 | export function configureJavascriptDefaults(monaco: Monaco, suppressSemantic: boolean) { | ||
| 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| 22 | const ts = (monaco.languages as any).typescript; | ||
| 23 | |||
| 24 | setDiagnosticsOptions(monaco, suppressSemantic); | ||
| 25 | |||
| 26 | ts.javascriptDefaults.setCompilerOptions({ | ||
| 27 | target: ts.ScriptTarget.ESNext, | ||
| 28 | module: ts.ModuleKind.ESNext, | ||
| 29 | moduleResolution: ts.ModuleResolutionKind.NodeJs, | ||
| 30 | allowNonTsExtensions: true, | ||
| 31 | checkJs: true, | ||
| 32 | strict: false, | ||
| 33 | noEmit: true, | ||
| 34 | allowJs: true, | ||
| 35 | lib: ["esnext"], | ||
| 36 | }); | ||
| 37 | |||
| 38 | ts.javascriptDefaults.setEagerModelSync(true); | ||
| 39 | |||
| 40 | return ts; | ||
| 41 | } | ||
| 42 | |||
| 43 | export function defineExecutorThemes(monaco: Monaco) { | ||
| 44 | monaco.editor.defineTheme("executor-light", { | ||
| 45 | base: "vs", | ||
| 46 | inherit: true, | ||
| 47 | rules: [ | ||
| 48 | { token: "comment", foreground: "7f8692", fontStyle: "italic" }, | ||
| 49 | { token: "keyword", foreground: "0f8a6a" }, | ||
| 50 | { token: "string", foreground: "a46822" }, | ||
| 51 | { token: "number", foreground: "a46822" }, | ||
| 52 | { token: "type", foreground: "0f8a6a" }, | ||
| 53 | { token: "function", foreground: "5c470f" }, | ||
| 54 | { token: "variable", foreground: "1f2430" }, | ||
| 55 | { token: "operator", foreground: "6f7785" }, | ||
| 56 | ], | ||
| 57 | colors: { | ||
| 58 | "editor.background": "#ffffff", | ||
| 59 | "editor.foreground": "#1f2430", | ||
| 60 | "editor.lineHighlightBackground": "#f4f7fb", | ||
| 61 | "editor.selectionBackground": "#c7def5", | ||
| 62 | "editor.inactiveSelectionBackground": "#dbe8f7", | ||
| 63 | "editorCursor.foreground": "#0f8a6a", | ||
| 64 | "editorLineNumber.foreground": "#9aa3b2", | ||
| 65 | "editorLineNumber.activeForeground": "#6f7785", | ||
| 66 | "editorIndentGuide.background": "#e3e8ef", | ||
| 67 | "editorIndentGuide.activeBackground": "#ccd5e2", | ||
| 68 | "editor.selectionHighlightBackground": "#c7def540", | ||
| 69 | "editorWidget.background": "#ffffff", | ||
| 70 | "editorWidget.border": "#d6dde8", | ||
| 71 | "editorSuggestWidget.background": "#ffffff", | ||
| 72 | "editorSuggestWidget.border": "#d6dde8", | ||
| 73 | "editorSuggestWidget.selectedBackground": "#cfe1f6", | ||
| 74 | "editorSuggestWidget.selectedForeground": "#111827", | ||
| 75 | "editorSuggestWidget.selectedIconForeground": "#0f8a6a", | ||
| 76 | "editorSuggestWidget.highlightForeground": "#0f8a6a", | ||
| 77 | "editorHoverWidget.background": "#ffffff", | ||
| 78 | "editorHoverWidget.border": "#d6dde8", | ||
| 79 | "list.focusBackground": "#cfe1f6", | ||
| 80 | "list.focusForeground": "#111827", | ||
| 81 | "list.highlightForeground": "#0f8a6a", | ||
| 82 | "input.background": "#ffffff", | ||
| 83 | "input.border": "#d6dde8", | ||
| 84 | "scrollbar.shadow": "#00000000", | ||
| 85 | "scrollbarSlider.background": "#c2cad880", | ||
| 86 | "scrollbarSlider.hoverBackground": "#a6afbe", | ||
| 87 | "scrollbarSlider.activeBackground": "#8f98a8", | ||
| 88 | "focusBorder": "#0f8a6a30", | ||
| 89 | }, | ||
| 90 | }); | ||
| 91 | |||
| 92 | monaco.editor.defineTheme("executor-dark", { | ||
| 93 | base: "vs-dark", | ||
| 94 | inherit: true, | ||
| 95 | rules: [ | ||
| 96 | { token: "comment", foreground: "5a6370", fontStyle: "italic" }, | ||
| 97 | { token: "keyword", foreground: "6bdfb8" }, | ||
| 98 | { token: "string", foreground: "c4a46c" }, | ||
| 99 | { token: "number", foreground: "c4a46c" }, | ||
| 100 | { token: "type", foreground: "6bdfb8" }, | ||
| 101 | { token: "function", foreground: "dcdcaa" }, | ||
| 102 | { token: "variable", foreground: "c8ccd4" }, | ||
| 103 | { token: "operator", foreground: "8a93a5" }, | ||
| 104 | ], | ||
| 105 | colors: { | ||
| 106 | "editor.background": "#0f1117", | ||
| 107 | "editor.foreground": "#c8ccd4", | ||
| 108 | "editor.lineHighlightBackground": "#161922", | ||
| 109 | "editor.selectionBackground": "#264f78", | ||
| 110 | "editor.inactiveSelectionBackground": "#1d2536", | ||
| 111 | "editorCursor.foreground": "#6bdfb8", | ||
| 112 | "editorLineNumber.foreground": "#3a3f4b", | ||
| 113 | "editorLineNumber.activeForeground": "#5a6370", | ||
| 114 | "editorIndentGuide.background": "#1e2230", | ||
| 115 | "editorIndentGuide.activeBackground": "#2a3040", | ||
| 116 | "editor.selectionHighlightBackground": "#264f7830", | ||
| 117 | "editorWidget.background": "#161922", | ||
| 118 | "editorWidget.border": "#2a3040", | ||
| 119 | "editorSuggestWidget.background": "#161922", | ||
| 120 | "editorSuggestWidget.border": "#2a3040", | ||
| 121 | "editorSuggestWidget.selectedBackground": "#1d2a3a", | ||
| 122 | "editorSuggestWidget.highlightForeground": "#6bdfb8", | ||
| 123 | "editorHoverWidget.background": "#161922", | ||
| 124 | "editorHoverWidget.border": "#2a3040", | ||
| 125 | "input.background": "#0f1117", | ||
| 126 | "input.border": "#2a3040", | ||
| 127 | "scrollbar.shadow": "#00000000", | ||
| 128 | "scrollbarSlider.background": "#2a304080", | ||
| 129 | "scrollbarSlider.hoverBackground": "#3a4050", | ||
| 130 | "scrollbarSlider.activeBackground": "#4a5060", | ||
| 131 | "focusBorder": "#6bdfb830", | ||
| 132 | }, | ||
| 133 | }); | ||
| 134 | } | ||
| 135 | |||
| 136 | export const CODE_EDITOR_OPTIONS = { | ||
| 137 | minimap: { enabled: false }, | ||
| 138 | fixedOverflowWidgets: true, | ||
| 139 | fontSize: 13, | ||
| 140 | lineHeight: 22, | ||
| 141 | fontFamily: "var(--font-geist-mono), 'JetBrains Mono', monospace", | ||
| 142 | fontLigatures: true, | ||
| 143 | tabSize: 2, | ||
| 144 | padding: { top: 12, bottom: 12 }, | ||
| 145 | scrollBeyondLastLine: false, | ||
| 146 | renderLineHighlight: "gutter", | ||
| 147 | guides: { | ||
| 148 | indentation: true, | ||
| 149 | bracketPairs: true, | ||
| 150 | }, | ||
| 151 | bracketPairColorization: { | ||
| 152 | enabled: true, | ||
| 153 | }, | ||
| 154 | suggest: { | ||
| 155 | showMethods: true, | ||
| 156 | showFunctions: true, | ||
| 157 | showFields: true, | ||
| 158 | showVariables: true, | ||
| 159 | showModules: true, | ||
| 160 | showProperties: true, | ||
| 161 | showKeywords: true, | ||
| 162 | preview: true, | ||
| 163 | shareSuggestSelections: true, | ||
| 164 | }, | ||
| 165 | quickSuggestions: { | ||
| 166 | other: true, | ||
| 167 | comments: false, | ||
| 168 | strings: true, | ||
| 169 | }, | ||
| 170 | acceptSuggestionOnCommitCharacter: true, | ||
| 171 | parameterHints: { | ||
| 172 | enabled: true, | ||
| 173 | cycle: true, | ||
| 174 | }, | ||
| 175 | inlineSuggest: { | ||
| 176 | enabled: true, | ||
| 177 | }, | ||
| 178 | wordWrap: "on", | ||
| 179 | automaticLayout: true, | ||
| 180 | overviewRulerLanes: 0, | ||
| 181 | hideCursorInOverviewRuler: true, | ||
| 182 | overviewRulerBorder: false, | ||
| 183 | scrollbar: { | ||
| 184 | vertical: "auto", | ||
| 185 | horizontal: "auto", | ||
| 186 | verticalScrollbarSize: 6, | ||
| 187 | horizontalScrollbarSize: 6, | ||
| 188 | }, | ||
| 189 | roundedSelection: true, | ||
| 190 | cursorBlinking: "smooth", | ||
| 191 | cursorSmoothCaretAnimation: "on", | ||
| 192 | smoothScrolling: true, | ||
| 193 | } satisfies NonNullable<EditorProps["options"]>; | ||
| @@ -0,0 +1,204 @@ | |||
| 1 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 2 | |||
| 3 | interface NamespaceNode { | ||
| 4 | children: Map<string, NamespaceNode>; | ||
| 5 | tools: ToolDescriptor[]; | ||
| 6 | } | ||
| 7 | |||
| 8 | function buildTree(tools: ToolDescriptor[]): NamespaceNode { | ||
| 9 | const root: NamespaceNode = { children: new Map(), tools: [] }; | ||
| 10 | for (const tool of tools) { | ||
| 11 | const parts = tool.path.split("."); | ||
| 12 | if (parts.length === 1) { | ||
| 13 | root.tools.push(tool); | ||
| 14 | } else { | ||
| 15 | let node = root; | ||
| 16 | for (let i = 0; i < parts.length - 1; i++) { | ||
| 17 | if (!node.children.has(parts[i])) { | ||
| 18 | node.children.set(parts[i], { children: new Map(), tools: [] }); | ||
| 19 | } | ||
| 20 | node = node.children.get(parts[i])!; | ||
| 21 | } | ||
| 22 | node.tools.push(tool); | ||
| 23 | } | ||
| 24 | } | ||
| 25 | return root; | ||
| 26 | } | ||
| 27 | |||
| 28 | function countAllTools(node: NamespaceNode): number { | ||
| 29 | let count = node.tools.length; | ||
| 30 | for (const child of node.children.values()) { | ||
| 31 | count += countAllTools(child); | ||
| 32 | } | ||
| 33 | return count; | ||
| 34 | } | ||
| 35 | |||
| 36 | function emitToolMethod(tool: ToolDescriptor, dtsSources: Set<string>): string { | ||
| 37 | const funcName = tool.path.split(".").pop()!; | ||
| 38 | const approvalNote = | ||
| 39 | tool.approval === "required" | ||
| 40 | ? " **Requires approval** - execution will pause until approved." | ||
| 41 | : ""; | ||
| 42 | const desc = tool.description | ||
| 43 | ? `${tool.description}${approvalNote}` | ||
| 44 | : approvalNote || "Call this tool."; | ||
| 45 | |||
| 46 | const hasSourceDts = Boolean(tool.source && dtsSources.has(tool.source)); | ||
| 47 | if (tool.operationId && hasSourceDts) { | ||
| 48 | const opKey = JSON.stringify(tool.operationId); | ||
| 49 | return ` /** | ||
| 50 | * ${desc} | ||
| 51 | *${tool.source ? ` @source ${tool.source}` : ""} | ||
| 52 | */ | ||
| 53 | ${funcName}(input: ToolInput<operations[${opKey}]>): Promise<ToolOutput<operations[${opKey}]>>;`; | ||
| 54 | } | ||
| 55 | |||
| 56 | const strictArgsType = tool.strictArgsType?.trim(); | ||
| 57 | const strictReturnsType = tool.strictReturnsType?.trim(); | ||
| 58 | const fallbackArgsType = tool.argsType?.trim(); | ||
| 59 | const fallbackReturnsType = tool.returnsType?.trim(); | ||
| 60 | const hasArgsType = Boolean(strictArgsType || fallbackArgsType); | ||
| 61 | const argsType = strictArgsType || fallbackArgsType || "Record<string, unknown>"; | ||
| 62 | const returnsType = strictReturnsType || fallbackReturnsType || "unknown"; | ||
| 63 | const inputParam = !hasArgsType || argsType === "{}" ? `input?: ${argsType}` : `input: ${argsType}`; | ||
| 64 | |||
| 65 | return ` /** | ||
| 66 | * ${desc} | ||
| 67 | *${tool.source ? ` @source ${tool.source}` : ""} | ||
| 68 | */ | ||
| 69 | ${funcName}(${inputParam}): Promise<${returnsType}>;`; | ||
| 70 | } | ||
| 71 | |||
| 72 | function emitNamespaceInterface( | ||
| 73 | name: string, | ||
| 74 | node: NamespaceNode, | ||
| 75 | dtsSources: Set<string>, | ||
| 76 | out: string[], | ||
| 77 | ): void { | ||
| 78 | for (const [childName, childNode] of node.children) { | ||
| 79 | emitNamespaceInterface(`${name}_${childName}`, childNode, dtsSources, out); | ||
| 80 | } | ||
| 81 | |||
| 82 | const members: string[] = []; | ||
| 83 | |||
| 84 | for (const [childName, childNode] of node.children) { | ||
| 85 | const toolCount = childNode.tools.length + countAllTools(childNode); | ||
| 86 | members.push(` /** ${toolCount} tool${toolCount !== 1 ? "s" : ""} in the \`${childName}\` namespace */ | ||
| 87 | readonly ${childName}: ToolNS_${name}_${childName};`); | ||
| 88 | } | ||
| 89 | |||
| 90 | for (const tool of node.tools) { | ||
| 91 | members.push(emitToolMethod(tool, dtsSources)); | ||
| 92 | } | ||
| 93 | |||
| 94 | out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`); | ||
| 95 | } | ||
| 96 | |||
| 97 | export function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string { | ||
| 98 | const root = buildTree(tools); | ||
| 99 | |||
| 100 | const interfaces: string[] = []; | ||
| 101 | for (const [name, node] of root.children) { | ||
| 102 | emitNamespaceInterface(name, node, dtsSources, interfaces); | ||
| 103 | } | ||
| 104 | |||
| 105 | const rootMembers: string[] = []; | ||
| 106 | for (const [name] of root.children) { | ||
| 107 | rootMembers.push(` readonly ${name}: ToolNS_${name};`); | ||
| 108 | } | ||
| 109 | for (const tool of root.tools) { | ||
| 110 | rootMembers.push(emitToolMethod(tool, dtsSources)); | ||
| 111 | } | ||
| 112 | |||
| 113 | let dts = ` | ||
| 114 | /** | ||
| 115 | * The \`tools\` object is a proxy that lets you call registered executor tools. | ||
| 116 | * Each call returns a Promise with the tool's result. | ||
| 117 | * Tools marked with "approval: required" will pause execution until approved. | ||
| 118 | */ | ||
| 119 | `; | ||
| 120 | |||
| 121 | dts += interfaces.join("\n\n") + "\n\n"; | ||
| 122 | dts += `interface ToolsProxy {\n${rootMembers.join("\n\n")}\n}\n\n`; | ||
| 123 | dts += "declare const tools: ToolsProxy;\n"; | ||
| 124 | |||
| 125 | return dts; | ||
| 126 | } | ||
| 127 | |||
| 128 | export const OPENAPI_HELPER_TYPES = ` | ||
| 129 | type _Normalize<T> = Exclude<T, undefined | null>; | ||
| 130 | type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; | ||
| 131 | type _Simplify<T> = { [K in keyof T]: T[K] } & {}; | ||
| 132 | type _ParamsOf<Op> = | ||
| 133 | Op extends { parameters: infer P } ? P : | ||
| 134 | Op extends { parameters?: infer P } ? P : | ||
| 135 | never; | ||
| 136 | type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> = | ||
| 137 | _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never; | ||
| 138 | type _BodyOf<Op> = | ||
| 139 | Op extends { requestBody?: infer B } ? B : | ||
| 140 | Op extends { requestBody: infer B } ? B : | ||
| 141 | never; | ||
| 142 | type _BodyContent<B> = | ||
| 143 | B extends { content: infer C } | ||
| 144 | ? C extends Record<string, infer V> ? V : never | ||
| 145 | : never; | ||
| 146 | type ToolInput<Op> = _Simplify< | ||
| 147 | _OrEmpty<_ParamAt<Op, "query">> & | ||
| 148 | _OrEmpty<_ParamAt<Op, "path">> & | ||
| 149 | _OrEmpty<_ParamAt<Op, "header">> & | ||
| 150 | _OrEmpty<_ParamAt<Op, "cookie">> & | ||
| 151 | _OrEmpty<_BodyContent<_BodyOf<Op>>> | ||
| 152 | >; | ||
| 153 | type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never; | ||
| 154 | type _RespAt<Op, Code extends PropertyKey> = | ||
| 155 | _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never; | ||
| 156 | type _ResponsePayload<R> = | ||
| 157 | [R] extends [never] ? never : | ||
| 158 | R extends { content: infer C } | ||
| 159 | ? C extends Record<string, infer V> ? V : unknown | ||
| 160 | : R extends { schema: infer S } ? S : unknown; | ||
| 161 | type _HasStatus<Op, Code extends PropertyKey> = | ||
| 162 | [_RespAt<Op, Code>] extends [never] ? false : true; | ||
| 163 | type _PayloadAt<Op, Code extends PropertyKey> = | ||
| 164 | Code extends 204 | 205 | ||
| 165 | ? (_HasStatus<Op, Code> extends true ? void : never) | ||
| 166 | : _ResponsePayload<_RespAt<Op, Code>>; | ||
| 167 | type _FirstKnown<T extends readonly unknown[]> = | ||
| 168 | T extends readonly [infer H, ...infer Rest] | ||
| 169 | ? [H] extends [never] ? _FirstKnown<Rest> : H | ||
| 170 | : unknown; | ||
| 171 | type ToolOutput<Op> = _FirstKnown<[ | ||
| 172 | _PayloadAt<Op, 200>, | ||
| 173 | _PayloadAt<Op, 201>, | ||
| 174 | _PayloadAt<Op, 202>, | ||
| 175 | _PayloadAt<Op, 203>, | ||
| 176 | _PayloadAt<Op, 204>, | ||
| 177 | _PayloadAt<Op, 205>, | ||
| 178 | _PayloadAt<Op, 206>, | ||
| 179 | _PayloadAt<Op, 207>, | ||
| 180 | _PayloadAt<Op, 208>, | ||
| 181 | _PayloadAt<Op, 226>, | ||
| 182 | _PayloadAt<Op, "default">, | ||
| 183 | unknown | ||
| 184 | ]>; | ||
| 185 | `; | ||
| 186 | |||
| 187 | export const BASE_ENVIRONMENT_DTS = ` | ||
| 188 | interface Console { | ||
| 189 | /** Console output is discarded; use explicit return values for results. */ | ||
| 190 | log(...args: any[]): void; | ||
| 191 | /** Console output is discarded; use explicit return values for results. */ | ||
| 192 | error(...args: any[]): void; | ||
| 193 | /** Console output is discarded; use explicit return values for results. */ | ||
| 194 | warn(...args: any[]): void; | ||
| 195 | info(...args: any[]): void; | ||
| 196 | debug(...args: any[]): void; | ||
| 197 | } | ||
| 198 | declare var console: Console; | ||
| 199 | |||
| 200 | declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number; | ||
| 201 | declare function clearTimeout(id: number): void; | ||
| 202 | declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number; | ||
| 203 | declare function clearInterval(id: number): void; | ||
| 204 | `; | ||
| @@ -1,260 +1,22 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useRef, useEffect, useState } from "react"; | 3 | import { useRef, useEffect, useMemo, useState } from "react"; |
| 4 | import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react"; | 4 | import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react"; |
| 5 | import { Loader2 } from "lucide-react"; | 5 | import { Loader2 } from "lucide-react"; |
| 6 | import { useTheme } from "next-themes"; | 6 | import { useTheme } from "next-themes"; |
| 7 | import type { ToolDescriptor } from "@/lib/types"; | 7 | import type { ToolDescriptor } from "@/lib/types"; |
| 8 | import { cn } from "@/lib/utils"; | 8 | import { cn } from "@/lib/utils"; |
| 9 | 9 | import { | |
| 10 | // ── Generate TypeScript declarations for the tools proxy ── | 10 | BASE_ENVIRONMENT_DTS, |
| 11 | 11 | OPENAPI_HELPER_TYPES, | |
| 12 | interface NamespaceNode { | 12 | generateToolsDts, |
| 13 | children: Map<string, NamespaceNode>; | 13 | } from "./code-editor-types"; |
| 14 | tools: ToolDescriptor[]; | 14 | import { |
| 15 | } | 15 | CODE_EDITOR_OPTIONS, |
| 16 | 16 | configureJavascriptDefaults, | |
| 17 | function 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 | |||
| 38 | function emitToolMethod(tool: ToolDescriptor, dtsSources: Set<string>): string { | ||
| 39 | const funcName = tool.path.split(".").pop()!; | ||
| 40 | const approvalNote = | ||
| 41 | tool.approval === "required" | ||
| 42 | ? " **Requires approval** - execution will pause until approved." | ||
| 43 | : ""; | ||
| 44 | const desc = tool.description | ||
| 45 | ? `${tool.description}${approvalNote}` | ||
| 46 | : approvalNote || "Call this tool."; | ||
| 47 | |||
| 48 | // For OpenAPI tools with operationId, use indexed access types only when | ||
| 49 | // this source has a loaded .d.ts block. | ||
| 50 | const hasSourceDts = Boolean(tool.source && dtsSources.has(tool.source)); | ||
| 51 | if (tool.operationId && hasSourceDts) { | ||
| 52 | const opKey = JSON.stringify(tool.operationId); | ||
| 53 | return ` /** | ||
| 54 | * ${desc} | ||
| 55 | *${tool.source ? ` @source ${tool.source}` : ""} | ||
| 56 | */ | ||
| 57 | ${funcName}(input: ToolInput<operations[${opKey}]>): Promise<ToolOutput<operations[${opKey}]>>;`; | ||
| 58 | } | ||
| 59 | |||
| 60 | // Fallback for MCP, GraphQL, builtins — use argsType/returnsType strings | ||
| 61 | const strictArgsType = tool.strictArgsType?.trim(); | ||
| 62 | const strictReturnsType = tool.strictReturnsType?.trim(); | ||
| 63 | const fallbackArgsType = tool.argsType?.trim(); | ||
| 64 | const fallbackReturnsType = tool.returnsType?.trim(); | ||
| 65 | const hasArgsType = Boolean(strictArgsType || fallbackArgsType); | ||
| 66 | const argsType = strictArgsType || fallbackArgsType || "Record<string, unknown>"; | ||
| 67 | const returnsType = strictReturnsType || fallbackReturnsType || "unknown"; | ||
| 68 | const inputParam = !hasArgsType || argsType === "{}" | ||
| 69 | ? `input?: ${argsType}` | ||
| 70 | : `input: ${argsType}`; | ||
| 71 | |||
| 72 | return ` /** | ||
| 73 | * ${desc} | ||
| 74 | *${tool.source ? ` @source ${tool.source}` : ""} | ||
| 75 | */ | ||
| 76 | ${funcName}(${inputParam}): Promise<${returnsType}>;`; | ||
| 77 | } | ||
| 78 | |||
| 79 | function emitNamespaceInterface( | ||
| 80 | name: string, | ||
| 81 | node: NamespaceNode, | ||
| 82 | dtsSources: Set<string>, | ||
| 83 | out: string[], | ||
| 84 | ): void { | ||
| 85 | // First, recursively emit child namespace interfaces | ||
| 86 | for (const [childName, childNode] of node.children) { | ||
| 87 | emitNamespaceInterface(`${name}_${childName}`, childNode, dtsSources, out); | ||
| 88 | } | ||
| 89 | |||
| 90 | // Build this namespace's interface | ||
| 91 | const members: string[] = []; | ||
| 92 | |||
| 93 | // Add child namespace accessors | ||
| 94 | for (const [childName, childNode] of node.children) { | ||
| 95 | const toolCount = | ||
| 96 | childNode.tools.length + countAllTools(childNode); | ||
| 97 | members.push(` /** ${toolCount} tool${toolCount !== 1 ? "s" : ""} in the \`${childName}\` namespace */ | ||
| 98 | readonly ${childName}: ToolNS_${name}_${childName};`); | ||
| 99 | } | ||
| 100 | |||
| 101 | // Add tool methods | ||
| 102 | for (const tool of node.tools) { | ||
| 103 | members.push(emitToolMethod(tool, dtsSources)); | ||
| 104 | } | ||
| 105 | |||
| 106 | out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`); | ||
| 107 | } | ||
| 108 | |||
| 109 | function countAllTools(node: NamespaceNode): number { | ||
| 110 | let count = node.tools.length; | ||
| 111 | for (const child of node.children.values()) { | ||
| 112 | count += countAllTools(child); | ||
| 113 | } | ||
| 114 | return count; | ||
| 115 | } | ||
| 116 | |||
| 117 | /** TS helper types for OpenAPI indexed access (same as typechecker.ts) */ | ||
| 118 | const OPENAPI_HELPER_TYPES = ` | ||
| 119 | type _Normalize<T> = Exclude<T, undefined | null>; | ||
| 120 | type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; | ||
| 121 | type _Simplify<T> = { [K in keyof T]: T[K] } & {}; | ||
| 122 | type _ParamsOf<Op> = | ||
| 123 | Op extends { parameters: infer P } ? P : | ||
| 124 | Op extends { parameters?: infer P } ? P : | ||
| 125 | never; | ||
| 126 | type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> = | ||
| 127 | _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never; | ||
| 128 | type _BodyOf<Op> = | ||
| 129 | Op extends { requestBody?: infer B } ? B : | ||
| 130 | Op extends { requestBody: infer B } ? B : | ||
| 131 | never; | ||
| 132 | type _BodyContent<B> = | ||
| 133 | B extends { content: infer C } | ||
| 134 | ? C extends Record<string, infer V> ? V : never | ||
| 135 | : never; | ||
| 136 | type ToolInput<Op> = _Simplify< | ||
| 137 | _OrEmpty<_ParamAt<Op, "query">> & | ||
| 138 | _OrEmpty<_ParamAt<Op, "path">> & | ||
| 139 | _OrEmpty<_ParamAt<Op, "header">> & | ||
| 140 | _OrEmpty<_ParamAt<Op, "cookie">> & | ||
| 141 | _OrEmpty<_BodyContent<_BodyOf<Op>>> | ||
| 142 | >; | ||
| 143 | type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never; | ||
| 144 | type _RespAt<Op, Code extends PropertyKey> = | ||
| 145 | _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never; | ||
| 146 | type _ResponsePayload<R> = | ||
| 147 | [R] extends [never] ? never : | ||
| 148 | R extends { content: infer C } | ||
| 149 | ? C extends Record<string, infer V> ? V : unknown | ||
| 150 | : R extends { schema: infer S } ? S : unknown; | ||
| 151 | type _HasStatus<Op, Code extends PropertyKey> = | ||
| 152 | [_RespAt<Op, Code>] extends [never] ? false : true; | ||
| 153 | type _PayloadAt<Op, Code extends PropertyKey> = | ||
| 154 | Code extends 204 | 205 | ||
| 155 | ? (_HasStatus<Op, Code> extends true ? void : never) | ||
| 156 | : _ResponsePayload<_RespAt<Op, Code>>; | ||
| 157 | type _FirstKnown<T extends readonly unknown[]> = | ||
| 158 | T extends readonly [infer H, ...infer Rest] | ||
| 159 | ? [H] extends [never] ? _FirstKnown<Rest> : H | ||
| 160 | : unknown; | ||
| 161 | type ToolOutput<Op> = _FirstKnown<[ | ||
| 162 | _PayloadAt<Op, 200>, | ||
| 163 | _PayloadAt<Op, 201>, | ||
| 164 | _PayloadAt<Op, 202>, | ||
| 165 | _PayloadAt<Op, 203>, | ||
| 166 | _PayloadAt<Op, 204>, | ||
| 167 | _PayloadAt<Op, 205>, | ||
| 168 | _PayloadAt<Op, 206>, | ||
| 169 | _PayloadAt<Op, 207>, | ||
| 170 | _PayloadAt<Op, 208>, | ||
| 171 | _PayloadAt<Op, 226>, | ||
| 172 | _PayloadAt<Op, "default">, | ||
| 173 | unknown | ||
| 174 | ]>; | ||
| 175 | `; | ||
| 176 | |||
| 177 | function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string { | ||
| 178 | const root = buildTree(tools); | ||
| 179 | |||
| 180 | const interfaces: string[] = []; | ||
| 181 | |||
| 182 | // Emit all namespace interfaces recursively | ||
| 183 | for (const [name, node] of root.children) { | ||
| 184 | emitNamespaceInterface(name, node, dtsSources, interfaces); | ||
| 185 | } | ||
| 186 | |||
| 187 | // Build root ToolsProxy interface | ||
| 188 | const rootMembers: string[] = []; | ||
| 189 | |||
| 190 | // Root-level namespace accessors | ||
| 191 | for (const [name] of root.children) { | ||
| 192 | rootMembers.push(` readonly ${name}: ToolNS_${name};`); | ||
| 193 | } | ||
| 194 | |||
| 195 | // Root-level tools (rare but possible) | ||
| 196 | for (const tool of root.tools) { | ||
| 197 | rootMembers.push(emitToolMethod(tool, dtsSources)); | ||
| 198 | } | ||
| 199 | |||
| 200 | let dts = ` | ||
| 201 | /** | ||
| 202 | * The \`tools\` object is a proxy that lets you call registered executor tools. | ||
| 203 | * Each call returns a Promise with the tool's result. | ||
| 204 | * Tools marked with "approval: required" will pause execution until approved. | ||
| 205 | */ | ||
| 206 | `; | ||
| 207 | |||
| 208 | // Note: .d.ts blocks from OpenAPI sources are fetched separately and registered | ||
| 209 | // as a distinct Monaco extra lib (see dtsUrls effect). This keeps generateToolsDts | ||
| 210 | // fast and avoids bundling multi-MB .d.ts content into the tool declarations string. | ||
| 211 | |||
| 212 | dts += interfaces.join("\n\n") + "\n\n"; | ||
| 213 | dts += `interface ToolsProxy {\n${rootMembers.join("\n\n")}\n}\n\n`; | ||
| 214 | dts += `declare const tools: ToolsProxy;\n`; | ||
| 215 | |||
| 216 | return dts; | ||
| 217 | } | ||
| 218 | |||
| 219 | // ── Base environment declarations ── | ||
| 220 | // These provide types for the executor runtime environment | ||
| 221 | // (console, setTimeout, etc.) since we don't include the full DOM lib. | ||
| 222 | |||
| 223 | const BASE_ENVIRONMENT_DTS = ` | ||
| 224 | interface Console { | ||
| 225 | /** Console output is discarded; use explicit return values for results. */ | ||
| 226 | log(...args: any[]): void; | ||
| 227 | /** Console output is discarded; use explicit return values for results. */ | ||
| 228 | error(...args: any[]): void; | ||
| 229 | /** Console output is discarded; use explicit return values for results. */ | ||
| 230 | warn(...args: any[]): void; | ||
| 231 | info(...args: any[]): void; | ||
| 232 | debug(...args: any[]): void; | ||
| 233 | } | ||
| 234 | declare var console: Console; | ||
| 235 | |||
| 236 | declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number; | ||
| 237 | declare function clearTimeout(id: number): void; | ||
| 238 | declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number; | ||
| 239 | declare function clearInterval(id: number): void; | ||
| 240 | `; | ||
| 241 | |||
| 242 | const DIAGNOSTIC_CODES_TO_IGNORE = [ | ||
| 243 | 1375, // 'await' expressions are only allowed at the top level of a file when that file is a module | ||
| 244 | 1378, // Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022'... | ||
| 245 | 2307, // Cannot find module | ||
| 246 | 80005, // 'require' call may be converted to an import | ||
| 247 | ]; | ||
| 248 | |||
| 249 | function setDiagnosticsOptions(monaco: Monaco, suppressSemantic: boolean) { | ||
| 250 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| 251 | const ts = (monaco.languages as any).typescript; | ||
| 252 | ts.javascriptDefaults.setDiagnosticsOptions({ | ||
| 253 | noSemanticValidation: suppressSemantic, | ||
| 254 | noSyntaxValidation: false, | ||
| 255 | diagnosticCodesToIgnore: DIAGNOSTIC_CODES_TO_IGNORE, | ||
| 256 | }); | ||
| 257 | } | ||
| 258 | 20 | ||
| 259 | interface CodeEditorProps { | 21 | interface 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 sources | 53 | // 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-any | 67 | const jsDefaults = m.languages.typescript.javascriptDefaults; |
| 301 | const jsDefaults = (m.languages as any).typescript.javascriptDefaults; | ||
| 302 | 68 | ||
| 303 | // Skip if URLs haven't changed | 69 | // 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 declarations | 99 | // 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-any | 131 | const jsDefaults = m.languages.typescript.javascriptDefaults; |
| 365 | const jsDefaults = (m.languages as any).typescript.javascriptDefaults; | ||
| 366 | 132 | ||
| 367 | // Dispose previous tool type declarations | 133 | // 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-any | 166 | 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... |
| @@ -0,0 +1,67 @@ | |||
| 1 | import { useNavigate } from "react-router"; | ||
| 2 | import { AlertTriangle, ArrowRight, CheckCircle2 } from "lucide-react"; | ||
| 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||
| 4 | import { Button } from "@/components/ui/button"; | ||
| 5 | import type { PendingApprovalRecord } from "@/lib/types"; | ||
| 6 | import { formatTimeAgo } from "@/lib/format"; | ||
| 7 | |||
| 8 | function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) { | ||
| 9 | const navigate = useNavigate(); | ||
| 10 | |||
| 11 | return ( | ||
| 12 | <button | ||
| 13 | onClick={() => navigate(`/tasks?selected=${approval.taskId}`)} | ||
| 14 | className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group" | ||
| 15 | > | ||
| 16 | <div className="h-2 w-2 rounded-full bg-terminal-amber pulse-dot shrink-0" /> | ||
| 17 | <div className="flex-1 min-w-0"> | ||
| 18 | <span className="text-sm font-mono text-foreground">{approval.toolPath}</span> | ||
| 19 | <span className="text-[11px] text-muted-foreground ml-2">{formatTimeAgo(approval.createdAt)}</span> | ||
| 20 | </div> | ||
| 21 | <ArrowRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> | ||
| 22 | </button> | ||
| 23 | ); | ||
| 24 | } | ||
| 25 | |||
| 26 | export function DashboardPendingApprovalsCard({ | ||
| 27 | pendingCount, | ||
| 28 | approvals, | ||
| 29 | }: { | ||
| 30 | pendingCount: number; | ||
| 31 | approvals: PendingApprovalRecord[]; | ||
| 32 | }) { | ||
| 33 | return ( | ||
| 34 | <Card className="bg-card border-border"> | ||
| 35 | <CardHeader className="pb-3"> | ||
| 36 | <div className="flex items-center justify-between"> | ||
| 37 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 38 | <AlertTriangle className="h-4 w-4 text-terminal-amber" /> | ||
| 39 | Pending Approvals | ||
| 40 | {pendingCount > 0 && ( | ||
| 41 | <span className="text-[10px] font-mono bg-terminal-amber/15 text-terminal-amber px-1.5 py-0.5 rounded"> | ||
| 42 | {pendingCount} | ||
| 43 | </span> | ||
| 44 | )} | ||
| 45 | </CardTitle> | ||
| 46 | <Button variant="ghost" size="sm" className="text-xs h-7" asChild> | ||
| 47 | <a href="/approvals">View all</a> | ||
| 48 | </Button> | ||
| 49 | </div> | ||
| 50 | </CardHeader> | ||
| 51 | <CardContent className="pt-0"> | ||
| 52 | {pendingCount === 0 ? ( | ||
| 53 | <div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> | ||
| 54 | <CheckCircle2 className="h-4 w-4 mr-2 text-terminal-green/50" /> | ||
| 55 | No pending approvals | ||
| 56 | </div> | ||
| 57 | ) : ( | ||
| 58 | <div className="space-y-0.5"> | ||
| 59 | {approvals.map((approval) => ( | ||
| 60 | <PendingApprovalRow key={approval.id} approval={approval} /> | ||
| 61 | ))} | ||
| 62 | </div> | ||
| 63 | )} | ||
| 64 | </CardContent> | ||
| 65 | </Card> | ||
| 66 | ); | ||
| 67 | } | ||
| @@ -0,0 +1,57 @@ | |||
| 1 | import { useNavigate } from "react-router"; | ||
| 2 | import { Clock } from "lucide-react"; | ||
| 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||
| 4 | import { Button } from "@/components/ui/button"; | ||
| 5 | import { TaskStatusBadge } from "@/components/status-badge"; | ||
| 6 | import type { TaskRecord } from "@/lib/types"; | ||
| 7 | import { formatTime } from "@/lib/format"; | ||
| 8 | |||
| 9 | function RecentTaskRow({ task }: { task: TaskRecord }) { | ||
| 10 | const navigate = useNavigate(); | ||
| 11 | |||
| 12 | return ( | ||
| 13 | <button | ||
| 14 | onClick={() => navigate(`/tasks?selected=${task.id}`)} | ||
| 15 | className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group" | ||
| 16 | > | ||
| 17 | <div className="flex-1 min-w-0"> | ||
| 18 | <span className="text-sm font-mono text-foreground truncate block">{task.id}</span> | ||
| 19 | <span className="text-[11px] text-muted-foreground"> | ||
| 20 | {task.runtimeId} · {formatTime(task.createdAt)} | ||
| 21 | </span> | ||
| 22 | </div> | ||
| 23 | <TaskStatusBadge status={task.status} /> | ||
| 24 | </button> | ||
| 25 | ); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function DashboardRecentTasksCard({ recentTasks }: { recentTasks: TaskRecord[] }) { | ||
| 29 | return ( | ||
| 30 | <Card className="bg-card border-border"> | ||
| 31 | <CardHeader className="pb-3"> | ||
| 32 | <div className="flex items-center justify-between"> | ||
| 33 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 34 | <Clock className="h-4 w-4 text-muted-foreground" /> | ||
| 35 | Recent Tasks | ||
| 36 | </CardTitle> | ||
| 37 | <Button variant="ghost" size="sm" className="text-xs h-7" asChild> | ||
| 38 | <a href="/tasks">View all</a> | ||
| 39 | </Button> | ||
| 40 | </div> | ||
| 41 | </CardHeader> | ||
| 42 | <CardContent className="pt-0"> | ||
| 43 | {recentTasks.length === 0 ? ( | ||
| 44 | <div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> | ||
| 45 | No tasks yet | ||
| 46 | </div> | ||
| 47 | ) : ( | ||
| 48 | <div className="space-y-0.5"> | ||
| 49 | {recentTasks.map((task) => ( | ||
| 50 | <RecentTaskRow key={task.id} task={task} /> | ||
| 51 | ))} | ||
| 52 | </div> | ||
| 53 | )} | ||
| 54 | </CardContent> | ||
| 55 | </Card> | ||
| 56 | ); | ||
| 57 | } | ||
| @@ -0,0 +1,70 @@ | |||
| 1 | import { ArrowRight, CheckCircle2 } from "lucide-react"; | ||
| 2 | import { Button } from "@/components/ui/button"; | ||
| 3 | import { Card, CardContent } from "@/components/ui/card"; | ||
| 4 | |||
| 5 | type SetupStep = { | ||
| 6 | label: string; | ||
| 7 | done: boolean; | ||
| 8 | href: string; | ||
| 9 | pendingText: string; | ||
| 10 | doneText: string; | ||
| 11 | }; | ||
| 12 | |||
| 13 | export function DashboardSetupCard({ | ||
| 14 | taskCount, | ||
| 15 | setupSteps, | ||
| 16 | }: { | ||
| 17 | taskCount: number; | ||
| 18 | setupSteps: SetupStep[]; | ||
| 19 | }) { | ||
| 20 | return ( | ||
| 21 | <Card className="border-border bg-gradient-to-br from-card to-muted/25"> | ||
| 22 | <CardContent className="p-5 md:p-6 space-y-5"> | ||
| 23 | <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> | ||
| 24 | <div className="space-y-1.5 max-w-2xl"> | ||
| 25 | <p className="text-[11px] uppercase tracking-widest text-muted-foreground">Landing View</p> | ||
| 26 | <h2 className="text-lg font-semibold tracking-tight"> | ||
| 27 | {taskCount === 0 | ||
| 28 | ? "Connect tools and run your first task" | ||
| 29 | : "Your task activity and approval queue are live"} | ||
| 30 | </h2> | ||
| 31 | <p className="text-sm text-muted-foreground"> | ||
| 32 | Tasks is the primary workflow. Use the advanced runner only when you want direct code | ||
| 33 | execution. | ||
| 34 | </p> | ||
| 35 | </div> | ||
| 36 | <div className="flex flex-wrap items-center gap-2"> | ||
| 37 | <Button asChild size="sm" className="h-8 text-xs"> | ||
| 38 | <a href="/tasks">Open task activity</a> | ||
| 39 | </Button> | ||
| 40 | <Button asChild variant="outline" size="sm" className="h-8 text-xs"> | ||
| 41 | <a href="/tools">Manage tools</a> | ||
| 42 | </Button> | ||
| 43 | </div> | ||
| 44 | </div> | ||
| 45 | |||
| 46 | <div className="grid gap-2 md:grid-cols-3"> | ||
| 47 | {setupSteps.map((step) => ( | ||
| 48 | <a | ||
| 49 | key={step.label} | ||
| 50 | href={step.href} | ||
| 51 | className="rounded-md border border-border/70 bg-background/70 px-3 py-2.5 hover:bg-accent/25 transition-colors" | ||
| 52 | > | ||
| 53 | <div className="flex items-center justify-between gap-2"> | ||
| 54 | <span className="text-xs font-medium text-foreground">{step.label}</span> | ||
| 55 | {step.done ? ( | ||
| 56 | <CheckCircle2 className="h-3.5 w-3.5 text-terminal-green" /> | ||
| 57 | ) : ( | ||
| 58 | <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 59 | )} | ||
| 60 | </div> | ||
| 61 | <p className="mt-1 text-[11px] text-muted-foreground"> | ||
| 62 | {step.done ? step.doneText : step.pendingText} | ||
| 63 | </p> | ||
| 64 | </a> | ||
| 65 | ))} | ||
| 66 | </div> | ||
| 67 | </CardContent> | ||
| 68 | </Card> | ||
| 69 | ); | ||
| 70 | } | ||
| @@ -0,0 +1,36 @@ | |||
| 1 | import { Card, CardContent } from "@/components/ui/card"; | ||
| 2 | |||
| 3 | export function DashboardStatCard({ | ||
| 4 | label, | ||
| 5 | value, | ||
| 6 | icon: Icon, | ||
| 7 | accent, | ||
| 8 | }: { | ||
| 9 | label: string; | ||
| 10 | value: number | string; | ||
| 11 | icon: React.ComponentType<{ className?: string }>; | ||
| 12 | accent?: "green" | "amber" | "red" | "default"; | ||
| 13 | }) { | ||
| 14 | const accentClass = { | ||
| 15 | green: "text-terminal-green", | ||
| 16 | amber: "text-terminal-amber", | ||
| 17 | red: "text-terminal-red", | ||
| 18 | default: "text-muted-foreground", | ||
| 19 | }[accent ?? "default"]; | ||
| 20 | |||
| 21 | return ( | ||
| 22 | <Card className="bg-card border-border"> | ||
| 23 | <CardContent className="p-4"> | ||
| 24 | <div className="flex items-center justify-between"> | ||
| 25 | <div> | ||
| 26 | <p className="text-[11px] font-mono uppercase tracking-widest text-muted-foreground">{label}</p> | ||
| 27 | <p className={`text-2xl font-semibold mt-1 font-mono ${accentClass}`}>{value}</p> | ||
| 28 | </div> | ||
| 29 | <div className={`${accentClass} opacity-40`}> | ||
| 30 | <Icon className="h-8 w-8" /> | ||
| 31 | </div> | ||
| 32 | </div> | ||
| 33 | </CardContent> | ||
| 34 | </Card> | ||
| 35 | ); | ||
| 36 | } | ||
| @@ -0,0 +1,83 @@ | |||
| 1 | import { useMemo } from "react"; | ||
| 2 | import { useNavigate } from "react-router"; | ||
| 3 | import { ArrowRight, ChevronRight, Globe, Server, Wrench } from "lucide-react"; | ||
| 4 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||
| 5 | import { Button } from "@/components/ui/button"; | ||
| 6 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 7 | import { groupToolsBySource } from "@/components/dashboard-view-helpers"; | ||
| 8 | |||
| 9 | export function DashboardToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) { | ||
| 10 | const navigate = useNavigate(); | ||
| 11 | const groups = useMemo(() => groupToolsBySource(tools), [tools]); | ||
| 12 | const totalApprovals = tools.filter((tool) => tool.approval === "required").length; | ||
| 13 | |||
| 14 | return ( | ||
| 15 | <Card className="bg-card border-border"> | ||
| 16 | <CardHeader className="pb-3"> | ||
| 17 | <div className="flex items-center justify-between"> | ||
| 18 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 19 | <Wrench className="h-4 w-4 text-muted-foreground" /> | ||
| 20 | Tool Sources | ||
| 21 | <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded"> | ||
| 22 | {tools.length} tools | ||
| 23 | </span> | ||
| 24 | {totalApprovals > 0 && ( | ||
| 25 | <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded"> | ||
| 26 | {totalApprovals} gated | ||
| 27 | </span> | ||
| 28 | )} | ||
| 29 | </CardTitle> | ||
| 30 | <Button | ||
| 31 | variant="ghost" | ||
| 32 | size="sm" | ||
| 33 | className="text-xs h-7" | ||
| 34 | onClick={() => navigate("/tools")} | ||
| 35 | > | ||
| 36 | Manage | ||
| 37 | <ArrowRight className="h-3 w-3 ml-1" /> | ||
| 38 | </Button> | ||
| 39 | </div> | ||
| 40 | </CardHeader> | ||
| 41 | <CardContent className="pt-0"> | ||
| 42 | <div className="grid gap-1"> | ||
| 43 | {groups.map((group) => { | ||
| 44 | const SourceIcon = group.type === "mcp" ? Server : Globe; | ||
| 45 | |||
| 46 | return ( | ||
| 47 | <button | ||
| 48 | key={group.name} | ||
| 49 | onClick={() => navigate(`/tools?source=${encodeURIComponent(group.name)}`)} | ||
| 50 | className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent/40 transition-colors text-left group/row w-full" | ||
| 51 | > | ||
| 52 | <div className="h-7 w-7 rounded bg-muted flex items-center justify-center shrink-0"> | ||
| 53 | <SourceIcon className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 54 | </div> | ||
| 55 | <div className="flex-1 min-w-0"> | ||
| 56 | <div className="flex items-center gap-2"> | ||
| 57 | <span className="text-sm font-mono font-medium text-foreground">{group.name}</span> | ||
| 58 | <span className="text-[10px] font-mono text-muted-foreground/70 uppercase tracking-wider"> | ||
| 59 | {group.type} | ||
| 60 | </span> | ||
| 61 | </div> | ||
| 62 | <span className="text-[11px] text-muted-foreground"> | ||
| 63 | {group.tools.length} tool{group.tools.length !== 1 ? "s" : ""} | ||
| 64 | {group.namespaces.size > 0 && ( | ||
| 65 | <> · {group.namespaces.size} namespace{group.namespaces.size !== 1 ? "s" : ""}</> | ||
| 66 | )} | ||
| 67 | {group.approvalCount > 0 && ( | ||
| 68 | <> | ||
| 69 | {" "} | ||
| 70 | · <span className="text-terminal-amber">{group.approvalCount} gated</span> | ||
| 71 | </> | ||
| 72 | )} | ||
| 73 | </span> | ||
| 74 | </div> | ||
| 75 | <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40 opacity-0 group-hover/row:opacity-100 transition-opacity shrink-0" /> | ||
| 76 | </button> | ||
| 77 | ); | ||
| 78 | })} | ||
| 79 | </div> | ||
| 80 | </CardContent> | ||
| 81 | </Card> | ||
| 82 | ); | ||
| 83 | } | ||
| @@ -0,0 +1,101 @@ | |||
| 1 | import type { PendingApprovalRecord, TaskRecord, ToolDescriptor } from "@/lib/types"; | ||
| 2 | import { sourceLabel, sourceType } from "@/lib/tool-source-utils"; | ||
| 3 | |||
| 4 | type SetupStep = { | ||
| 5 | label: string; | ||
| 6 | done: boolean; | ||
| 7 | href: string; | ||
| 8 | pendingText: string; | ||
| 9 | doneText: string; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export type DashboardTaskStats = { | ||
| 13 | runningCount: number; | ||
| 14 | completedCount: number; | ||
| 15 | failedCount: number; | ||
| 16 | }; | ||
| 17 | |||
| 18 | export type ToolSourceGroup = { | ||
| 19 | name: string; | ||
| 20 | type: string; | ||
| 21 | tools: ToolDescriptor[]; | ||
| 22 | namespaces: Set<string>; | ||
| 23 | approvalCount: number; | ||
| 24 | }; | ||
| 25 | |||
| 26 | export function getTaskStats(tasks: TaskRecord[]): DashboardTaskStats { | ||
| 27 | return { | ||
| 28 | runningCount: tasks.filter((task) => task.status === "running").length, | ||
| 29 | completedCount: tasks.filter((task) => task.status === "completed").length, | ||
| 30 | failedCount: tasks.filter((task) => ["failed", "timed_out", "denied"].includes(task.status)).length, | ||
| 31 | }; | ||
| 32 | } | ||
| 33 | |||
| 34 | export function getSetupSteps({ | ||
| 35 | sourceCount, | ||
| 36 | taskCount, | ||
| 37 | pendingCount, | ||
| 38 | }: { | ||
| 39 | sourceCount: number; | ||
| 40 | taskCount: number; | ||
| 41 | pendingCount: number; | ||
| 42 | }): SetupStep[] { | ||
| 43 | return [ | ||
| 44 | { | ||
| 45 | label: "Connect a tool source", | ||
| 46 | done: sourceCount > 0, | ||
| 47 | href: "/tools", | ||
| 48 | pendingText: "Add MCP, OpenAPI, or GraphQL", | ||
| 49 | doneText: `${sourceCount} source${sourceCount === 1 ? "" : "s"} connected`, | ||
| 50 | }, | ||
| 51 | { | ||
| 52 | label: "Run a first task", | ||
| 53 | done: taskCount > 0, | ||
| 54 | href: "/tasks", | ||
| 55 | pendingText: "Use the advanced runner to execute code", | ||
| 56 | doneText: `${taskCount} task${taskCount === 1 ? "" : "s"} recorded`, | ||
| 57 | }, | ||
| 58 | { | ||
| 59 | label: "Review gated calls", | ||
| 60 | done: pendingCount === 0, | ||
| 61 | href: "/approvals", | ||
| 62 | pendingText: `${pendingCount} approval${pendingCount === 1 ? "" : "s"} waiting`, | ||
| 63 | doneText: "Approval queue is clear", | ||
| 64 | }, | ||
| 65 | ]; | ||
| 66 | } | ||
| 67 | |||
| 68 | export function getRecentTasks(tasks: TaskRecord[]): TaskRecord[] { | ||
| 69 | return tasks.slice(0, 8); | ||
| 70 | } | ||
| 71 | |||
| 72 | export function getPendingApprovals(approvals: PendingApprovalRecord[]): PendingApprovalRecord[] { | ||
| 73 | return approvals.slice(0, 5); | ||
| 74 | } | ||
| 75 | |||
| 76 | export function groupToolsBySource(tools: ToolDescriptor[]): ToolSourceGroup[] { | ||
| 77 | const map = new Map<string, ToolSourceGroup>(); | ||
| 78 | |||
| 79 | for (const tool of tools) { | ||
| 80 | const name = sourceLabel(tool.source); | ||
| 81 | const type = sourceType(tool.source); | ||
| 82 | let group = map.get(name); | ||
| 83 | if (!group) { | ||
| 84 | group = { name, type, tools: [], namespaces: new Set(), approvalCount: 0 }; | ||
| 85 | map.set(name, group); | ||
| 86 | } | ||
| 87 | |||
| 88 | group.tools.push(tool); | ||
| 89 | |||
| 90 | const parts = tool.path.split("."); | ||
| 91 | if (parts.length >= 2) { | ||
| 92 | group.namespaces.add(`${parts[0]}.${parts[1]}`); | ||
| 93 | } | ||
| 94 | |||
| 95 | if (tool.approval === "required") { | ||
| 96 | group.approvalCount++; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | return Array.from(map.values()).sort((a, b) => b.tools.length - a.tools.length); | ||
| 101 | } | ||
| @@ -1,250 +1,41 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useMemo } from "react"; | 3 | import { CheckCircle2, Play, ShieldCheck, XCircle } from "lucide-react"; |
| 4 | import { useNavigate } from "react-router"; | ||
| 5 | import { | ||
| 6 | Play, | ||
| 7 | ShieldCheck, | ||
| 8 | Clock, | ||
| 9 | CheckCircle2, | ||
| 10 | XCircle, | ||
| 11 | AlertTriangle, | ||
| 12 | ArrowRight, | ||
| 13 | Wrench, | ||
| 14 | ChevronRight, | ||
| 15 | Server, | ||
| 16 | Globe, | ||
| 17 | } from "lucide-react"; | ||
| 18 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||
| 19 | import { Button } from "@/components/ui/button"; | ||
| 20 | import { Skeleton } from "@/components/ui/skeleton"; | 4 | import { Skeleton } from "@/components/ui/skeleton"; |
| 21 | import { PageHeader } from "@/components/page-header"; | 5 | import { PageHeader } from "@/components/page-header"; |
| 22 | import { TaskStatusBadge } from "@/components/status-badge"; | 6 | import { DashboardSetupCard } from "@/components/dashboard-setup-card"; |
| 7 | import { DashboardStatCard } from "@/components/dashboard-stat-card"; | ||
| 8 | import { DashboardPendingApprovalsCard } from "@/components/dashboard-pending-approvals-card"; | ||
| 9 | import { DashboardRecentTasksCard } from "@/components/dashboard-recent-tasks-card"; | ||
| 10 | import { DashboardToolsSummaryCard } from "@/components/dashboard-tools-summary-card"; | ||
| 23 | import { useSession } from "@/lib/session-context"; | 11 | import { useSession } from "@/lib/session-context"; |
| 24 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | 12 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 25 | import { useQuery } from "convex/react"; | 13 | import { useQuery } from "convex/react"; |
| 26 | import { convexApi } from "@/lib/convex-api"; | 14 | import { convexApi } from "@/lib/convex-api"; |
| 27 | import type { TaskRecord, PendingApprovalRecord, ToolDescriptor } from "@/lib/types"; | 15 | import { |
| 28 | import { formatTime, formatTimeAgo } from "@/lib/format"; | 16 | getPendingApprovals, |
| 29 | 17 | getRecentTasks, | |
| 30 | function StatCard({ | 18 | getSetupSteps, |
| 31 | label, | 19 | getTaskStats, |
| 32 | value, | 20 | } from "@/components/dashboard-view-helpers"; |
| 33 | icon: Icon, | 21 | import { 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 | |||
| 69 | function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) { | ||
| 70 | const navigate = useNavigate(); | ||
| 71 | return ( | ||
| 72 | <button | ||
| 73 | onClick={() => navigate(`/tasks?selected=${approval.taskId}`)} | ||
| 74 | className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group" | ||
| 75 | > | ||
| 76 | <div className="h-2 w-2 rounded-full bg-terminal-amber pulse-dot shrink-0" /> | ||
| 77 | <div className="flex-1 min-w-0"> | ||
| 78 | <span className="text-sm font-mono text-foreground"> | ||
| 79 | {approval.toolPath} | ||
| 80 | </span> | ||
| 81 | <span className="text-[11px] text-muted-foreground ml-2"> | ||
| 82 | {formatTimeAgo(approval.createdAt)} | ||
| 83 | </span> | ||
| 84 | </div> | ||
| 85 | <ArrowRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> | ||
| 86 | </button> | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | function RecentTaskRow({ task }: { task: TaskRecord }) { | ||
| 91 | const navigate = useNavigate(); | ||
| 92 | return ( | ||
| 93 | <button | ||
| 94 | onClick={() => navigate(`/tasks?selected=${task.id}`)} | ||
| 95 | className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group" | ||
| 96 | > | ||
| 97 | <div className="flex-1 min-w-0"> | ||
| 98 | <span className="text-sm font-mono text-foreground truncate block"> | ||
| 99 | {task.id} | ||
| 100 | </span> | ||
| 101 | <span className="text-[11px] text-muted-foreground"> | ||
| 102 | {task.runtimeId} · {formatTime(task.createdAt)} | ||
| 103 | </span> | ||
| 104 | </div> | ||
| 105 | <TaskStatusBadge status={task.status} /> | ||
| 106 | </button> | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | /** Derive the "source name" from a tool's source field (e.g. "openapi:github" → "github"). */ | ||
| 111 | function sourceLabel(source?: string): string { | ||
| 112 | if (!source) return "built-in"; | ||
| 113 | const colonIdx = source.indexOf(":"); | ||
| 114 | return colonIdx >= 0 ? source.slice(colonIdx + 1) : source; | ||
| 115 | } | ||
| 116 | |||
| 117 | /** Derive the source type prefix (e.g. "openapi", "mcp", "graphql"). */ | ||
| 118 | function sourceType(source?: string): string { | ||
| 119 | if (!source) return "local"; | ||
| 120 | const colonIdx = source.indexOf(":"); | ||
| 121 | return colonIdx >= 0 ? source.slice(0, colonIdx) : "local"; | ||
| 122 | } | ||
| 123 | |||
| 124 | function ToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) { | ||
| 125 | const navigate = useNavigate(); | ||
| 126 | |||
| 127 | const groups = useMemo(() => { | ||
| 128 | const map = new Map< | ||
| 129 | string, | ||
| 130 | { | ||
| 131 | name: string; | ||
| 132 | type: string; | ||
| 133 | tools: ToolDescriptor[]; | ||
| 134 | namespaces: Set<string>; | ||
| 135 | approvalCount: number; | ||
| 136 | } | ||
| 137 | >(); | ||
| 138 | |||
| 139 | for (const tool of tools) { | ||
| 140 | const name = sourceLabel(tool.source); | ||
| 141 | const type = sourceType(tool.source); | ||
| 142 | let group = map.get(name); | ||
| 143 | if (!group) { | ||
| 144 | group = { name, type, tools: [], namespaces: new Set(), approvalCount: 0 }; | ||
| 145 | map.set(name, group); | ||
| 146 | } | ||
| 147 | group.tools.push(tool); | ||
| 148 | // Extract namespace: first two segments of the path (e.g. "github.repos" from "github.repos.list") | ||
| 149 | const parts = tool.path.split("."); | ||
| 150 | if (parts.length >= 2) { | ||
| 151 | group.namespaces.add(`${parts[0]}.${parts[1]}`); | ||
| 152 | } | ||
| 153 | if (tool.approval === "required") { | ||
| 154 | group.approvalCount++; | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | return Array.from(map.values()).sort((a, b) => b.tools.length - a.tools.length); | ||
| 159 | }, [tools]); | ||
| 160 | |||
| 161 | const totalApprovals = tools.filter((t) => t.approval === "required").length; | ||
| 162 | |||
| 163 | return ( | ||
| 164 | <Card className="bg-card border-border"> | ||
| 165 | <CardHeader className="pb-3"> | ||
| 166 | <div className="flex items-center justify-between"> | ||
| 167 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 168 | <Wrench className="h-4 w-4 text-muted-foreground" /> | ||
| 169 | Tool Sources | ||
| 170 | <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded"> | ||
| 171 | {tools.length} tools | ||
| 172 | </span> | ||
| 173 | {totalApprovals > 0 && ( | ||
| 174 | <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded"> | ||
| 175 | {totalApprovals} gated | ||
| 176 | </span> | ||
| 177 | )} | ||
| 178 | </CardTitle> | ||
| 179 | <Button | ||
| 180 | variant="ghost" | ||
| 181 | size="sm" | ||
| 182 | className="text-xs h-7" | ||
| 183 | onClick={() => navigate("/tools")} | ||
| 184 | > | ||
| 185 | Manage | ||
| 186 | <ArrowRight className="h-3 w-3 ml-1" /> | ||
| 187 | </Button> | ||
| 188 | </div> | ||
| 189 | </CardHeader> | ||
| 190 | <CardContent className="pt-0"> | ||
| 191 | <div className="grid gap-1"> | ||
| 192 | {groups.map((group) => { | ||
| 193 | const SourceIcon = group.type === "mcp" ? Server : Globe; | ||
| 194 | return ( | ||
| 195 | <button | ||
| 196 | key={group.name} | ||
| 197 | onClick={() => navigate(`/tools?source=${encodeURIComponent(group.name)}`)} | ||
| 198 | className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent/40 transition-colors text-left group/row w-full" | ||
| 199 | > | ||
| 200 | <div className="h-7 w-7 rounded bg-muted flex items-center justify-center shrink-0"> | ||
| 201 | <SourceIcon className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 202 | </div> | ||
| 203 | <div className="flex-1 min-w-0"> | ||
| 204 | <div className="flex items-center gap-2"> | ||
| 205 | <span className="text-sm font-mono font-medium text-foreground"> | ||
| 206 | {group.name} | ||
| 207 | </span> | ||
| 208 | <span className="text-[10px] font-mono text-muted-foreground/70 uppercase tracking-wider"> | ||
| 209 | {group.type} | ||
| 210 | </span> | ||
| 211 | </div> | ||
| 212 | <span className="text-[11px] text-muted-foreground"> | ||
| 213 | {group.tools.length} tool{group.tools.length !== 1 ? "s" : ""} | ||
| 214 | {group.namespaces.size > 0 && ( | ||
| 215 | <> · {group.namespaces.size} namespace{group.namespaces.size !== 1 ? "s" : ""}</> | ||
| 216 | )} | ||
| 217 | {group.approvalCount > 0 && ( | ||
| 218 | <> · <span className="text-terminal-amber">{group.approvalCount} gated</span></> | ||
| 219 | )} | ||
| 220 | </span> | ||
| 221 | </div> | ||
| 222 | <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40 opacity-0 group-hover/row:opacity-100 transition-opacity shrink-0" /> | ||
| 223 | </button> | ||
| 224 | ); | ||
| 225 | })} | ||
| 226 | </div> | ||
| 227 | </CardContent> | ||
| 228 | </Card> | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | 22 | ||
| 232 | export function DashboardView() { | 23 | export 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 | <StatCard | 74 | <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 | <StatCard | 80 | <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 | <StatCard | 86 | <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 | <StatCard | 92 | <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 | } |
| @@ -2,6 +2,7 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { useMemo, useState } from "react"; | 3 | import { useMemo, useState } from "react"; |
| 4 | import { Check, Copy } from "lucide-react"; | 4 | import { Check, Copy } from "lucide-react"; |
| 5 | import Image from "next/image"; | ||
| 5 | import { Button } from "@/components/ui/button"; | 6 | import { Button } from "@/components/ui/button"; |
| 6 | import { Input } from "@/components/ui/input"; | 7 | import { Input } from "@/components/ui/input"; |
| 7 | import { Label } from "@/components/ui/label"; | 8 | import { 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> |
| @@ -0,0 +1,84 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useState } from "react"; | ||
| 4 | import { Link } from "react-router"; | ||
| 5 | import { LogIn } from "lucide-react"; | ||
| 6 | import { Button } from "@/components/ui/button"; | ||
| 7 | import { | ||
| 8 | Dialog, | ||
| 9 | DialogContent, | ||
| 10 | DialogDescription, | ||
| 11 | DialogHeader, | ||
| 12 | DialogTitle, | ||
| 13 | } from "@/components/ui/dialog"; | ||
| 14 | import { useSession } from "@/lib/session-context"; | ||
| 15 | import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities"; | ||
| 16 | |||
| 17 | export function NoOrganizationModal({ enabled }: { enabled: boolean }) { | ||
| 18 | const { | ||
| 19 | loading, | ||
| 20 | organizations, | ||
| 21 | organizationsLoading, | ||
| 22 | context, | ||
| 23 | isSignedInToWorkos, | ||
| 24 | createAnonymousOrganization, | ||
| 25 | creatingAnonymousOrganization, | ||
| 26 | } = useSession(); | ||
| 27 | const [error, setError] = useState<string | null>(null); | ||
| 28 | |||
| 29 | const shouldShow = enabled | ||
| 30 | && !loading | ||
| 31 | && !organizationsLoading | ||
| 32 | && !context | ||
| 33 | && !isSignedInToWorkos | ||
| 34 | && organizations.length === 0; | ||
| 35 | |||
| 36 | const handleCreateAnonymousOrganization = async () => { | ||
| 37 | setError(null); | ||
| 38 | try { | ||
| 39 | await createAnonymousOrganization(); | ||
| 40 | } catch (cause) { | ||
| 41 | const message = cause instanceof Error ? cause.message : "Failed to create anonymous organization"; | ||
| 42 | setError(message); | ||
| 43 | } | ||
| 44 | }; | ||
| 45 | |||
| 46 | return ( | ||
| 47 | <Dialog open={shouldShow}> | ||
| 48 | <DialogContent | ||
| 49 | showCloseButton={false} | ||
| 50 | onEscapeKeyDown={(event) => event.preventDefault()} | ||
| 51 | onInteractOutside={(event) => event.preventDefault()} | ||
| 52 | > | ||
| 53 | <DialogHeader> | ||
| 54 | <DialogTitle>Choose how to continue</DialogTitle> | ||
| 55 | <DialogDescription> | ||
| 56 | Sign in to access your organizations, or create an anonymous organization with a default workspace. | ||
| 57 | </DialogDescription> | ||
| 58 | </DialogHeader> | ||
| 59 | <div className="space-y-2"> | ||
| 60 | {workosEnabled ? ( | ||
| 61 | <Button asChild className="w-full" disabled={creatingAnonymousOrganization}> | ||
| 62 | <Link to="/sign-in" reloadDocument className="gap-2"> | ||
| 63 | <LogIn className="h-4 w-4" /> | ||
| 64 | Sign in | ||
| 65 | </Link> | ||
| 66 | </Button> | ||
| 67 | ) : null} | ||
| 68 | <Button | ||
| 69 | variant="outline" | ||
| 70 | className="w-full" | ||
| 71 | onClick={handleCreateAnonymousOrganization} | ||
| 72 | disabled={creatingAnonymousOrganization || !anonymousDemoEnabled} | ||
| 73 | > | ||
| 74 | {creatingAnonymousOrganization ? "Creating anonymous organization..." : "Create anonymous organization"} | ||
| 75 | </Button> | ||
| 76 | {!anonymousDemoEnabled ? ( | ||
| 77 | <p className="text-xs text-muted-foreground">Anonymous organization creation is disabled.</p> | ||
| 78 | ) : null} | ||
| 79 | {error ? <p className="text-xs text-destructive">{error}</p> : null} | ||
| 80 | </div> | ||
| 81 | </DialogContent> | ||
| 82 | </Dialog> | ||
| 83 | ); | ||
| 84 | } | ||
| @@ -0,0 +1,237 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useState, type FormEvent } from "react"; | ||
| 4 | import { Link, useSearchParams } from "react-router"; | ||
| 5 | import { useMutation } from "convex/react"; | ||
| 6 | import { ChevronsUpDown, LogIn } from "lucide-react"; | ||
| 7 | import Image from "next/image"; | ||
| 8 | import { Button } from "@/components/ui/button"; | ||
| 9 | import { | ||
| 10 | DropdownMenu, | ||
| 11 | DropdownMenuContent, | ||
| 12 | DropdownMenuItem, | ||
| 13 | DropdownMenuLabel, | ||
| 14 | DropdownMenuSeparator, | ||
| 15 | DropdownMenuTrigger, | ||
| 16 | } from "@/components/ui/dropdown-menu"; | ||
| 17 | import { | ||
| 18 | Dialog, | ||
| 19 | DialogContent, | ||
| 20 | DialogDescription, | ||
| 21 | DialogFooter, | ||
| 22 | DialogHeader, | ||
| 23 | DialogTitle, | ||
| 24 | } from "@/components/ui/dialog"; | ||
| 25 | import { Input } from "@/components/ui/input"; | ||
| 26 | import { useSession } from "@/lib/session-context"; | ||
| 27 | import { convexApi } from "@/lib/convex-api"; | ||
| 28 | import { workosEnabled } from "@/lib/auth-capabilities"; | ||
| 29 | |||
| 30 | export function SessionInfo() { | ||
| 31 | const { | ||
| 32 | loading, | ||
| 33 | isSignedInToWorkos, | ||
| 34 | workosProfile, | ||
| 35 | context, | ||
| 36 | workspaces, | ||
| 37 | resetWorkspace, | ||
| 38 | } = useSession(); | ||
| 39 | const [searchParams] = useSearchParams(); | ||
| 40 | const deleteCurrentAccountMutation = useMutation(convexApi.accounts.deleteCurrentAccount); | ||
| 41 | const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); | ||
| 42 | const [deleteConfirmText, setDeleteConfirmText] = useState(""); | ||
| 43 | const [deleteError, setDeleteError] = useState<string | null>(null); | ||
| 44 | const [deletingAccount, setDeletingAccount] = useState(false); | ||
| 45 | const avatarUrl = workosProfile?.avatarUrl ?? null; | ||
| 46 | const avatarLabel = workosProfile?.name || workosProfile?.email || "User"; | ||
| 47 | const avatarInitial = (avatarLabel[0] ?? "U").toUpperCase(); | ||
| 48 | const canDeleteAccount = deleteConfirmText === "DELETE"; | ||
| 49 | |||
| 50 | const activeWorkspace = context | ||
| 51 | ? workspaces.find((workspace) => workspace.id === context.workspaceId) | ||
| 52 | : null; | ||
| 53 | const inferredOrganizationId = activeWorkspace?.organizationId ?? undefined; | ||
| 54 | const hintedOrganizationId = | ||
| 55 | searchParams.get("organization_id") | ||
| 56 | ?? searchParams.get("organizationId") | ||
| 57 | ?? searchParams.get("org_id") | ||
| 58 | ?? searchParams.get("orgId") | ||
| 59 | ?? inferredOrganizationId | ||
| 60 | ?? undefined; | ||
| 61 | const hintedLogin = | ||
| 62 | searchParams.get("login_hint") | ||
| 63 | ?? searchParams.get("loginHint") | ||
| 64 | ?? searchParams.get("email") | ||
| 65 | ?? undefined; | ||
| 66 | const signInParams = new URLSearchParams(); | ||
| 67 | if (hintedOrganizationId) { | ||
| 68 | signInParams.set("organization_id", hintedOrganizationId); | ||
| 69 | } | ||
| 70 | if (hintedLogin) { | ||
| 71 | signInParams.set("login_hint", hintedLogin); | ||
| 72 | } | ||
| 73 | const signInHref = signInParams.size > 0 ? `/sign-in?${signInParams.toString()}` : "/sign-in"; | ||
| 74 | |||
| 75 | const handleDeleteAccount = async (event: FormEvent<HTMLFormElement>) => { | ||
| 76 | event.preventDefault(); | ||
| 77 | if (!canDeleteAccount || deletingAccount) { | ||
| 78 | return; | ||
| 79 | } | ||
| 80 | |||
| 81 | setDeletingAccount(true); | ||
| 82 | setDeleteError(null); | ||
| 83 | try { | ||
| 84 | await deleteCurrentAccountMutation({ | ||
| 85 | sessionId: context?.sessionId ?? undefined, | ||
| 86 | }); | ||
| 87 | await resetWorkspace(); | ||
| 88 | window.location.assign(workosEnabled ? "/sign-out" : "/"); | ||
| 89 | } catch (cause) { | ||
| 90 | const message = cause instanceof Error ? cause.message : "Failed to delete account"; | ||
| 91 | setDeleteError(message); | ||
| 92 | setDeletingAccount(false); | ||
| 93 | } | ||
| 94 | }; | ||
| 95 | |||
| 96 | if (loading) { | ||
| 97 | return ( | ||
| 98 | <div className="border-t border-border px-3 py-2"> | ||
| 99 | <span className="text-[11px] font-mono text-muted-foreground">Loading session...</span> | ||
| 100 | </div> | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | |||
| 104 | return ( | ||
| 105 | <div className="border-t border-border"> | ||
| 106 | {isSignedInToWorkos ? ( | ||
| 107 | <> | ||
| 108 | <DropdownMenu> | ||
| 109 | <DropdownMenuTrigger asChild> | ||
| 110 | <Button | ||
| 111 | variant="ghost" | ||
| 112 | className="h-14 w-full justify-between rounded-none border-0 bg-transparent px-3 py-0 text-left shadow-none hover:bg-accent/40" | ||
| 113 | > | ||
| 114 | <span className="flex items-center gap-2 min-w-0"> | ||
| 115 | {avatarUrl ? ( | ||
| 116 | <Image | ||
| 117 | src={avatarUrl} | ||
| 118 | alt={avatarLabel} | ||
| 119 | width={24} | ||
| 120 | height={24} | ||
| 121 | className="h-6 w-6 rounded-full border border-border object-cover" | ||
| 122 | unoptimized | ||
| 123 | /> | ||
| 124 | ) : ( | ||
| 125 | <span className="h-6 w-6 rounded-full border border-border bg-muted text-[10px] font-mono text-muted-foreground flex items-center justify-center"> | ||
| 126 | {avatarInitial} | ||
| 127 | </span> | ||
| 128 | )} | ||
| 129 | <span className="min-w-0"> | ||
| 130 | <span className="text-[11px] font-medium truncate block">{avatarLabel}</span> | ||
| 131 | {workosProfile?.email ? ( | ||
| 132 | <span className="text-[10px] text-muted-foreground truncate block">{workosProfile.email}</span> | ||
| 133 | ) : null} | ||
| 134 | </span> | ||
| 135 | </span> | ||
| 136 | <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 137 | </Button> | ||
| 138 | </DropdownMenuTrigger> | ||
| 139 | <DropdownMenuContent align="end" className="w-64"> | ||
| 140 | <DropdownMenuLabel className="text-xs"> | ||
| 141 | Account | ||
| 142 | </DropdownMenuLabel> | ||
| 143 | <DropdownMenuSeparator /> | ||
| 144 | <DropdownMenuItem | ||
| 145 | className="text-xs" | ||
| 146 | onSelect={(event) => { | ||
| 147 | event.preventDefault(); | ||
| 148 | setDeleteConfirmText(""); | ||
| 149 | setDeleteError(null); | ||
| 150 | setAccountSettingsOpen(true); | ||
| 151 | }} | ||
| 152 | > | ||
| 153 | Account Settings | ||
| 154 | </DropdownMenuItem> | ||
| 155 | <DropdownMenuItem asChild className="text-xs"> | ||
| 156 | <Link to="/sign-out" reloadDocument>Sign out</Link> | ||
| 157 | </DropdownMenuItem> | ||
| 158 | </DropdownMenuContent> | ||
| 159 | </DropdownMenu> | ||
| 160 | |||
| 161 | <Dialog | ||
| 162 | open={accountSettingsOpen} | ||
| 163 | onOpenChange={(open) => { | ||
| 164 | setAccountSettingsOpen(open); | ||
| 165 | if (!open) { | ||
| 166 | setDeleteConfirmText(""); | ||
| 167 | setDeleteError(null); | ||
| 168 | setDeletingAccount(false); | ||
| 169 | } | ||
| 170 | }} | ||
| 171 | > | ||
| 172 | <DialogContent className="sm:max-w-md"> | ||
| 173 | <form className="space-y-4" onSubmit={handleDeleteAccount}> | ||
| 174 | <DialogHeader> | ||
| 175 | <DialogTitle>Account Settings</DialogTitle> | ||
| 176 | <DialogDescription> | ||
| 177 | Deleting your account will remove organizations, workspaces, and data you created. | ||
| 178 | This cannot be undone. | ||
| 179 | </DialogDescription> | ||
| 180 | </DialogHeader> | ||
| 181 | <div className="space-y-2"> | ||
| 182 | <label htmlFor="delete-account-confirm" className="text-xs font-medium text-foreground"> | ||
| 183 | Type DELETE to confirm | ||
| 184 | </label> | ||
| 185 | <Input | ||
| 186 | id="delete-account-confirm" | ||
| 187 | value={deleteConfirmText} | ||
| 188 | onChange={(event) => setDeleteConfirmText(event.target.value)} | ||
| 189 | autoComplete="off" | ||
| 190 | spellCheck={false} | ||
| 191 | /> | ||
| 192 | {deleteError ? ( | ||
| 193 | <p className="text-xs text-destructive">{deleteError}</p> | ||
| 194 | ) : null} | ||
| 195 | </div> | ||
| 196 | <DialogFooter> | ||
| 197 | <Button | ||
| 198 | type="button" | ||
| 199 | variant="outline" | ||
| 200 | onClick={() => setAccountSettingsOpen(false)} | ||
| 201 | disabled={deletingAccount} | ||
| 202 | > | ||
| 203 | Cancel | ||
| 204 | </Button> | ||
| 205 | <Button | ||
| 206 | type="submit" | ||
| 207 | variant="destructive" | ||
| 208 | disabled={!canDeleteAccount || deletingAccount} | ||
| 209 | > | ||
| 210 | {deletingAccount ? "Deleting..." : "Delete account"} | ||
| 211 | </Button> | ||
| 212 | </DialogFooter> | ||
| 213 | </form> | ||
| 214 | </DialogContent> | ||
| 215 | </Dialog> | ||
| 216 | </> | ||
| 217 | ) : ( | ||
| 218 | <div className="px-3 py-3 space-y-2"> | ||
| 219 | {workosEnabled ? ( | ||
| 220 | <Link to={signInHref} reloadDocument className="block"> | ||
| 221 | <Button | ||
| 222 | variant="outline" | ||
| 223 | className="w-full h-9 justify-center gap-2 text-xs font-medium" | ||
| 224 | > | ||
| 225 | <LogIn className="h-3.5 w-3.5" /> | ||
| 226 | Sign in | ||
| 227 | </Button> | ||
| 228 | </Link> | ||
| 229 | ) : ( | ||
| 230 | <p className="text-[11px] text-muted-foreground text-center">Guest mode</p> | ||
| 231 | )} | ||
| 232 | </div> | ||
| 233 | )} | ||
| 234 | |||
| 235 | </div> | ||
| 236 | ); | ||
| 237 | } | ||
| @@ -0,0 +1,161 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useState } from "react"; | ||
| 4 | import { Play, Send } from "lucide-react"; | ||
| 5 | import { useMutation, useQuery } from "convex/react"; | ||
| 6 | import { toast } from "sonner"; | ||
| 7 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||
| 8 | import { Button } from "@/components/ui/button"; | ||
| 9 | import { Input } from "@/components/ui/input"; | ||
| 10 | import { Label } from "@/components/ui/label"; | ||
| 11 | import { | ||
| 12 | Select, | ||
| 13 | SelectContent, | ||
| 14 | SelectItem, | ||
| 15 | SelectTrigger, | ||
| 16 | SelectValue, | ||
| 17 | } from "@/components/ui/select"; | ||
| 18 | import { CodeEditor } from "@/components/code-editor"; | ||
| 19 | import { convexApi } from "@/lib/convex-api"; | ||
| 20 | import { useSession } from "@/lib/session-context"; | ||
| 21 | import type { RuntimeTargetDescriptor } from "@/lib/types"; | ||
| 22 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | ||
| 23 | |||
| 24 | const DEFAULT_CODE = `// Example: call some tools and return a compact result | ||
| 25 | const time = await tools.utils.get_time(); | ||
| 26 | const sum = await tools.math.add({ a: 7, b: 35 }); | ||
| 27 | |||
| 28 | // This will require approval: | ||
| 29 | await tools.admin.send_announcement({ | ||
| 30 | channel: "general", | ||
| 31 | message: "Hello from executor!" | ||
| 32 | }); | ||
| 33 | |||
| 34 | return { | ||
| 35 | isoTime: time.iso, | ||
| 36 | total: sum.result, | ||
| 37 | };`; | ||
| 38 | const DEFAULT_TIMEOUT_MS = 300_000; | ||
| 39 | |||
| 40 | export function TaskComposer() { | ||
| 41 | const { context } = useSession(); | ||
| 42 | const [code, setCode] = useState(DEFAULT_CODE); | ||
| 43 | const [runtimeId, setRuntimeId] = useState("local-bun"); | ||
| 44 | const [timeoutMs, setTimeoutMs] = useState(String(DEFAULT_TIMEOUT_MS)); | ||
| 45 | const [submitting, setSubmitting] = useState(false); | ||
| 46 | |||
| 47 | const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {}); | ||
| 48 | const createTask = useMutation(convexApi.executor.createTask); | ||
| 49 | const { tools, dtsUrls, loadingTools, loadingTypes } = useWorkspaceTools(context ?? null); | ||
| 50 | |||
| 51 | const handleSubmit = async () => { | ||
| 52 | if (!context || !code.trim()) return; | ||
| 53 | setSubmitting(true); | ||
| 54 | try { | ||
| 55 | const data = await createTask({ | ||
| 56 | code, | ||
| 57 | runtimeId, | ||
| 58 | timeoutMs: Number.parseInt(timeoutMs, 10) || DEFAULT_TIMEOUT_MS, | ||
| 59 | workspaceId: context.workspaceId, | ||
| 60 | sessionId: context.sessionId, | ||
| 61 | clientId: context.clientId, | ||
| 62 | }); | ||
| 63 | toast.success(`Task created: ${data.task.id}`); | ||
| 64 | } catch (err) { | ||
| 65 | toast.error( | ||
| 66 | err instanceof Error ? err.message : "Failed to create task", | ||
| 67 | ); | ||
| 68 | } finally { | ||
| 69 | setSubmitting(false); | ||
| 70 | } | ||
| 71 | }; | ||
| 72 | |||
| 73 | return ( | ||
| 74 | <Card className="bg-card border-border"> | ||
| 75 | <CardHeader className="pb-3"> | ||
| 76 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 77 | <Play className="h-4 w-4 text-terminal-green" /> | ||
| 78 | New Task | ||
| 79 | </CardTitle> | ||
| 80 | </CardHeader> | ||
| 81 | <CardContent className="space-y-4"> | ||
| 82 | <div className="grid grid-cols-2 gap-3"> | ||
| 83 | <div className="space-y-1.5"> | ||
| 84 | <Label className="text-xs text-muted-foreground">Runtime</Label> | ||
| 85 | <Select value={runtimeId} onValueChange={setRuntimeId}> | ||
| 86 | <SelectTrigger className="h-8 text-xs font-mono bg-background"> | ||
| 87 | <SelectValue /> | ||
| 88 | </SelectTrigger> | ||
| 89 | <SelectContent> | ||
| 90 | {(runtimes ?? []).map((r: RuntimeTargetDescriptor) => ( | ||
| 91 | <SelectItem key={r.id} value={r.id} className="text-xs"> | ||
| 92 | {r.label} | ||
| 93 | </SelectItem> | ||
| 94 | ))} | ||
| 95 | </SelectContent> | ||
| 96 | </Select> | ||
| 97 | </div> | ||
| 98 | <div className="space-y-1.5"> | ||
| 99 | <Label className="text-xs text-muted-foreground"> | ||
| 100 | Timeout (ms) | ||
| 101 | </Label> | ||
| 102 | <Input | ||
| 103 | type="number" | ||
| 104 | value={timeoutMs} | ||
| 105 | onChange={(e) => setTimeoutMs(e.target.value)} | ||
| 106 | className="h-8 text-xs font-mono bg-background" | ||
| 107 | /> | ||
| 108 | </div> | ||
| 109 | </div> | ||
| 110 | <div className="space-y-1.5"> | ||
| 111 | <div className="flex items-center justify-between gap-2"> | ||
| 112 | <Label className="text-xs text-muted-foreground">Code</Label> | ||
| 113 | <span className="text-[10px] font-mono text-muted-foreground"> | ||
| 114 | {loadingTools | ||
| 115 | ? "Loading tool inventory..." | ||
| 116 | : loadingTypes | ||
| 117 | ? `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded, type defs warming...` | ||
| 118 | : `${tools.length} tool${tools.length === 1 ? "" : "s"} ready`} | ||
| 119 | </span> | ||
| 120 | </div> | ||
| 121 | {!loadingTools && loadingTypes && tools.length > 0 && ( | ||
| 122 | <div className="flex flex-wrap gap-1.5 rounded-md border border-border/50 bg-muted/20 px-2 py-1.5"> | ||
| 123 | {tools.slice(0, 8).map((tool) => ( | ||
| 124 | <span | ||
| 125 | key={tool.path} | ||
| 126 | className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground" | ||
| 127 | > | ||
| 128 | {tool.path} | ||
| 129 | </span> | ||
| 130 | ))} | ||
| 131 | {tools.length > 8 && ( | ||
| 132 | <span className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground"> | ||
| 133 | +{tools.length - 8} more | ||
| 134 | </span> | ||
| 135 | )} | ||
| 136 | </div> | ||
| 137 | )} | ||
| 138 | <div className="rounded-md border border-border"> | ||
| 139 | <CodeEditor | ||
| 140 | value={code} | ||
| 141 | onChange={setCode} | ||
| 142 | tools={tools} | ||
| 143 | dtsUrls={dtsUrls} | ||
| 144 | typesLoading={loadingTypes} | ||
| 145 | height="400px" | ||
| 146 | /> | ||
| 147 | </div> | ||
| 148 | </div> | ||
| 149 | <Button | ||
| 150 | onClick={handleSubmit} | ||
| 151 | disabled={submitting || !code.trim()} | ||
| 152 | className="w-full bg-primary text-primary-foreground hover:bg-primary/90 h-9" | ||
| 153 | size="sm" | ||
| 154 | > | ||
| 155 | <Send className="h-3.5 w-3.5 mr-2" /> | ||
| 156 | {submitting ? "Creating..." : "Execute Task"} | ||
| 157 | </Button> | ||
| 158 | </CardContent> | ||
| 159 | </Card> | ||
| 160 | ); | ||
| 161 | } | ||
| @@ -0,0 +1,255 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useState } from "react"; | ||
| 4 | import { X, ShieldX, CheckCircle2 } from "lucide-react"; | ||
| 5 | import { useMutation, useQuery } from "convex/react"; | ||
| 6 | import type { Id } from "@executor/convex/_generated/dataModel"; | ||
| 7 | import { toast } from "sonner"; | ||
| 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||
| 9 | import { Button } from "@/components/ui/button"; | ||
| 10 | import { Separator } from "@/components/ui/separator"; | ||
| 11 | import { TaskStatusBadge } from "@/components/status-badge"; | ||
| 12 | import { FormattedCodeBlock } from "@/components/formatted-code-block"; | ||
| 13 | import { convexApi } from "@/lib/convex-api"; | ||
| 14 | import { formatApprovalInput } from "@/lib/approval-input-format"; | ||
| 15 | import { cn } from "@/lib/utils"; | ||
| 16 | import type { TaskRecord, PendingApprovalRecord } from "@/lib/types"; | ||
| 17 | |||
| 18 | function formatDate(ts: number) { | ||
| 19 | return new Date(ts).toLocaleDateString([], { | ||
| 20 | month: "short", | ||
| 21 | day: "numeric", | ||
| 22 | hour: "2-digit", | ||
| 23 | minute: "2-digit", | ||
| 24 | }); | ||
| 25 | } | ||
| 26 | |||
| 27 | function formatResult(result: unknown): string { | ||
| 28 | if (result === undefined) { | ||
| 29 | return ""; | ||
| 30 | } | ||
| 31 | |||
| 32 | try { | ||
| 33 | return JSON.stringify(result, null, 2); | ||
| 34 | } catch { | ||
| 35 | return String(result); | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | export function TaskDetail({ | ||
| 40 | task, | ||
| 41 | workspaceId, | ||
| 42 | sessionId, | ||
| 43 | pendingApprovals, | ||
| 44 | onClose, | ||
| 45 | }: { | ||
| 46 | task: TaskRecord; | ||
| 47 | workspaceId: Id<"workspaces">; | ||
| 48 | sessionId?: string; | ||
| 49 | pendingApprovals: PendingApprovalRecord[]; | ||
| 50 | onClose: () => void; | ||
| 51 | }) { | ||
| 52 | const resolveApproval = useMutation(convexApi.executor.resolveApproval); | ||
| 53 | const [resolvingApprovalId, setResolvingApprovalId] = useState<string | null>(null); | ||
| 54 | const liveTaskData = useQuery( | ||
| 55 | convexApi.workspace.getTaskInWorkspace, | ||
| 56 | workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip", | ||
| 57 | ); | ||
| 58 | |||
| 59 | const liveTask = liveTaskData ?? task; | ||
| 60 | const liveResult = formatResult(liveTask.result); | ||
| 61 | |||
| 62 | const duration = | ||
| 63 | liveTask.completedAt && liveTask.startedAt | ||
| 64 | ? `${((liveTask.completedAt - liveTask.startedAt) / 1000).toFixed(2)}s` | ||
| 65 | : liveTask.startedAt | ||
| 66 | ? "running..." | ||
| 67 | : "—"; | ||
| 68 | |||
| 69 | const handleResolveApproval = async ( | ||
| 70 | approvalId: string, | ||
| 71 | decision: "approved" | "denied", | ||
| 72 | toolPath: string, | ||
| 73 | ) => { | ||
| 74 | setResolvingApprovalId(approvalId); | ||
| 75 | try { | ||
| 76 | await resolveApproval({ | ||
| 77 | workspaceId, | ||
| 78 | sessionId, | ||
| 79 | approvalId, | ||
| 80 | decision, | ||
| 81 | }); | ||
| 82 | toast.success( | ||
| 83 | decision === "approved" | ||
| 84 | ? `Approved: ${toolPath}` | ||
| 85 | : `Denied: ${toolPath}`, | ||
| 86 | ); | ||
| 87 | } catch (error) { | ||
| 88 | toast.error(error instanceof Error ? error.message : "Failed to resolve approval"); | ||
| 89 | } finally { | ||
| 90 | setResolvingApprovalId(null); | ||
| 91 | } | ||
| 92 | }; | ||
| 93 | |||
| 94 | return ( | ||
| 95 | <Card className="bg-card border-border"> | ||
| 96 | <CardHeader className="pb-3"> | ||
| 97 | <div className="flex items-center justify-between"> | ||
| 98 | <CardTitle className="text-sm font-medium font-mono truncate pr-4"> | ||
| 99 | {liveTask.id} | ||
| 100 | </CardTitle> | ||
| 101 | <Button | ||
| 102 | variant="ghost" | ||
| 103 | size="icon" | ||
| 104 | className="h-7 w-7 shrink-0" | ||
| 105 | onClick={onClose} | ||
| 106 | > | ||
| 107 | <X className="h-4 w-4" /> | ||
| 108 | </Button> | ||
| 109 | </div> | ||
| 110 | </CardHeader> | ||
| 111 | <CardContent className="space-y-4"> | ||
| 112 | <div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> | ||
| 113 | {[ | ||
| 114 | { label: "Status", value: <TaskStatusBadge status={liveTask.status} /> }, | ||
| 115 | { label: "Runtime", value: <span className="font-mono text-xs">{liveTask.runtimeId}</span> }, | ||
| 116 | { label: "Duration", value: <span className="font-mono text-xs">{duration}</span> }, | ||
| 117 | { | ||
| 118 | label: "Exit Code", | ||
| 119 | value: ( | ||
| 120 | <span className={cn("font-mono text-xs", liveTask.exitCode === 0 ? "text-terminal-green" : liveTask.exitCode ? "text-terminal-red" : "text-muted-foreground")}> | ||
| 121 | {liveTask.exitCode ?? "—"} | ||
| 122 | </span> | ||
| 123 | ), | ||
| 124 | }, | ||
| 125 | ].map((item) => ( | ||
| 126 | <div key={item.label}> | ||
| 127 | <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1"> | ||
| 128 | {item.label} | ||
| 129 | </span> | ||
| 130 | {item.value} | ||
| 131 | </div> | ||
| 132 | ))} | ||
| 133 | </div> | ||
| 134 | |||
| 135 | {pendingApprovals.length > 0 ? ( | ||
| 136 | <> | ||
| 137 | <Separator /> | ||
| 138 | <div className="space-y-2.5"> | ||
| 139 | <div className="flex items-center justify-between"> | ||
| 140 | <span className="text-[10px] uppercase tracking-widest text-terminal-amber"> | ||
| 141 | Pending approvals | ||
| 142 | </span> | ||
| 143 | <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded"> | ||
| 144 | {pendingApprovals.length} | ||
| 145 | </span> | ||
| 146 | </div> | ||
| 147 | {pendingApprovals.map((approval) => { | ||
| 148 | const input = formatApprovalInput(approval.input); | ||
| 149 | const resolving = resolvingApprovalId === approval.id; | ||
| 150 | return ( | ||
| 151 | <div | ||
| 152 | key={approval.id} | ||
| 153 | className="rounded-md border border-terminal-amber/30 bg-terminal-amber/6 p-3 space-y-2" | ||
| 154 | > | ||
| 155 | <div className="flex items-center justify-between gap-3"> | ||
| 156 | <div className="min-w-0"> | ||
| 157 | <p className="text-xs font-mono text-foreground truncate"> | ||
| 158 | {approval.toolPath} | ||
| 159 | </p> | ||
| 160 | <p className="text-[10px] text-muted-foreground"> | ||
| 161 | Requested {formatDate(approval.createdAt)} | ||
| 162 | </p> | ||
| 163 | </div> | ||
| 164 | <div className="flex items-center gap-1.5 shrink-0"> | ||
| 165 | <Button | ||
| 166 | variant="outline" | ||
| 167 | size="sm" | ||
| 168 | className="h-7 text-[11px] border-terminal-green/30 text-terminal-green hover:bg-terminal-green/10" | ||
| 169 | disabled={resolvingApprovalId !== null} | ||
| 170 | onClick={() => | ||
| 171 | void handleResolveApproval( | ||
| 172 | approval.id, | ||
| 173 | "approved", | ||
| 174 | approval.toolPath, | ||
| 175 | ) | ||
| 176 | } | ||
| 177 | > | ||
| 178 | <CheckCircle2 className="h-3 w-3 mr-1" /> | ||
| 179 | {resolving ? "Approving..." : "Approve"} | ||
| 180 | </Button> | ||
| 181 | <Button | ||
| 182 | variant="outline" | ||
| 183 | size="sm" | ||
| 184 | className="h-7 text-[11px] border-terminal-red/30 text-terminal-red hover:bg-terminal-red/10" | ||
| 185 | disabled={resolvingApprovalId !== null} | ||
| 186 | onClick={() => | ||
| 187 | void handleResolveApproval( | ||
| 188 | approval.id, | ||
| 189 | "denied", | ||
| 190 | approval.toolPath, | ||
| 191 | ) | ||
| 192 | } | ||
| 193 | > | ||
| 194 | <ShieldX className="h-3 w-3 mr-1" /> | ||
| 195 | {resolving ? "Denying..." : "Deny"} | ||
| 196 | </Button> | ||
| 197 | </div> | ||
| 198 | </div> | ||
| 199 | {input ? ( | ||
| 200 | <FormattedCodeBlock | ||
| 201 | content={input.content} | ||
| 202 | language={input.language} | ||
| 203 | className="max-h-40 overflow-y-auto" | ||
| 204 | /> | ||
| 205 | ) : null} | ||
| 206 | </div> | ||
| 207 | ); | ||
| 208 | })} | ||
| 209 | </div> | ||
| 210 | </> | ||
| 211 | ) : null} | ||
| 212 | |||
| 213 | <Separator /> | ||
| 214 | |||
| 215 | <div> | ||
| 216 | <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2"> | ||
| 217 | Code | ||
| 218 | </span> | ||
| 219 | <FormattedCodeBlock | ||
| 220 | content={liveTask.code} | ||
| 221 | language="typescript" | ||
| 222 | className="max-h-48 overflow-y-auto" | ||
| 223 | /> | ||
| 224 | </div> | ||
| 225 | |||
| 226 | {liveResult && ( | ||
| 227 | <div> | ||
| 228 | <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2"> | ||
| 229 | Result | ||
| 230 | </span> | ||
| 231 | <FormattedCodeBlock | ||
| 232 | content={liveResult} | ||
| 233 | language="json" | ||
| 234 | className="max-h-48 overflow-y-auto" | ||
| 235 | /> | ||
| 236 | </div> | ||
| 237 | )} | ||
| 238 | |||
| 239 | {liveTask.error && ( | ||
| 240 | <div> | ||
| 241 | <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2"> | ||
| 242 | Error | ||
| 243 | </span> | ||
| 244 | <FormattedCodeBlock | ||
| 245 | content={liveTask.error} | ||
| 246 | language="text" | ||
| 247 | tone="red" | ||
| 248 | className="max-h-48 overflow-y-auto" | ||
| 249 | /> | ||
| 250 | </div> | ||
| 251 | )} | ||
| 252 | </CardContent> | ||
| 253 | </Card> | ||
| 254 | ); | ||
| 255 | } | ||
| @@ -0,0 +1,53 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { ChevronRight } from "lucide-react"; | ||
| 4 | import { TaskStatusBadge } from "@/components/status-badge"; | ||
| 5 | import type { TaskRecord } from "@/lib/types"; | ||
| 6 | import { cn } from "@/lib/utils"; | ||
| 7 | |||
| 8 | function formatDate(ts: number) { | ||
| 9 | return new Date(ts).toLocaleDateString([], { | ||
| 10 | month: "short", | ||
| 11 | day: "numeric", | ||
| 12 | hour: "2-digit", | ||
| 13 | minute: "2-digit", | ||
| 14 | }); | ||
| 15 | } | ||
| 16 | |||
| 17 | type TaskListItemProps = { | ||
| 18 | task: TaskRecord; | ||
| 19 | selected: boolean; | ||
| 20 | onClick: () => void; | ||
| 21 | }; | ||
| 22 | |||
| 23 | export function TaskListItem({ task, selected, onClick }: TaskListItemProps) { | ||
| 24 | return ( | ||
| 25 | <button | ||
| 26 | onClick={onClick} | ||
| 27 | className={cn( | ||
| 28 | "w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-left group", | ||
| 29 | selected | ||
| 30 | ? "bg-primary/10 border border-primary/20" | ||
| 31 | : "hover:bg-accent/50 border border-transparent", | ||
| 32 | )} | ||
| 33 | > | ||
| 34 | <div className="flex-1 min-w-0"> | ||
| 35 | <div className="flex items-center gap-2"> | ||
| 36 | <span className="text-xs font-mono text-foreground truncate"> | ||
| 37 | {task.id} | ||
| 38 | </span> | ||
| 39 | </div> | ||
| 40 | <div className="flex items-center gap-2 mt-0.5"> | ||
| 41 | <span className="text-[10px] font-mono text-muted-foreground"> | ||
| 42 | {task.runtimeId} | ||
| 43 | </span> | ||
| 44 | <span className="text-[10px] text-muted-foreground"> | ||
| 45 | {formatDate(task.createdAt)} | ||
| 46 | </span> | ||
| 47 | </div> | ||
| 48 | </div> | ||
| 49 | <TaskStatusBadge status={task.status} /> | ||
| 50 | <ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> | ||
| 51 | </button> | ||
| 52 | ); | ||
| 53 | } | ||
| @@ -2,521 +2,23 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { useState, useCallback } from "react"; | 3 | import { useState, useCallback } from "react"; |
| 4 | import { useNavigate, useSearchParams } from "react-router"; | 4 | import { useNavigate, useSearchParams } from "react-router"; |
| 5 | import { | 5 | import { Play, ShieldCheck } from "lucide-react"; |
| 6 | Play, | ||
| 7 | ChevronRight, | ||
| 8 | X, | ||
| 9 | Send, | ||
| 10 | ShieldCheck, | ||
| 11 | ShieldX, | ||
| 12 | CheckCircle2, | ||
| 13 | } from "lucide-react"; | ||
| 14 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 15 | import { Button } from "@/components/ui/button"; | 7 | import { Button } from "@/components/ui/button"; |
| 16 | import { Input } from "@/components/ui/input"; | ||
| 17 | import { Label } from "@/components/ui/label"; | ||
| 18 | import { CodeEditor } from "@/components/code-editor"; | ||
| 19 | import { | ||
| 20 | Select, | ||
| 21 | SelectContent, | ||
| 22 | SelectItem, | ||
| 23 | SelectTrigger, | ||
| 24 | SelectValue, | ||
| 25 | } from "@/components/ui/select"; | ||
| 26 | import { Skeleton } from "@/components/ui/skeleton"; | 8 | import { Skeleton } from "@/components/ui/skeleton"; |
| 27 | import { Separator } from "@/components/ui/separator"; | ||
| 28 | import { PageHeader } from "@/components/page-header"; | 9 | import { PageHeader } from "@/components/page-header"; |
| 29 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | 10 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| 30 | import { TaskStatusBadge } from "@/components/status-badge"; | 11 | import { TaskDetail } from "@/components/task-detail"; |
| 31 | import { FormattedCodeBlock } from "@/components/formatted-code-block"; | 12 | import { TaskComposer } from "@/components/task-composer"; |
| 13 | import { TaskListItem } from "@/components/task-list-item"; | ||
| 32 | import { useSession } from "@/lib/session-context"; | 14 | import { useSession } from "@/lib/session-context"; |
| 33 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | 15 | import { useQuery } from "convex/react"; |
| 34 | import { useMutation, useQuery } from "convex/react"; | ||
| 35 | import { convexApi } from "@/lib/convex-api"; | 16 | import { convexApi } from "@/lib/convex-api"; |
| 17 | import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | ||
| 36 | import type { | 18 | import type { |
| 37 | RuntimeTargetDescriptor, | ||
| 38 | TaskRecord, | 19 | TaskRecord, |
| 39 | PendingApprovalRecord, | 20 | PendingApprovalRecord, |
| 40 | } from "@/lib/types"; | 21 | } from "@/lib/types"; |
| 41 | import type { Id } from "@executor/convex/_generated/dataModel"; | ||
| 42 | import { toast } from "sonner"; | ||
| 43 | import { cn } from "@/lib/utils"; | ||
| 44 | |||
| 45 | const DEFAULT_CODE = `// Example: call some tools and return a compact result | ||
| 46 | const time = await tools.utils.get_time(); | ||
| 47 | const sum = await tools.math.add({ a: 7, b: 35 }); | ||
| 48 | |||
| 49 | // This will require approval: | ||
| 50 | await tools.admin.send_announcement({ | ||
| 51 | channel: "general", | ||
| 52 | message: "Hello from executor!" | ||
| 53 | }); | ||
| 54 | |||
| 55 | return { | ||
| 56 | isoTime: time.iso, | ||
| 57 | total: sum.result, | ||
| 58 | };`; | ||
| 59 | const DEFAULT_TIMEOUT_MS = 300_000; | ||
| 60 | |||
| 61 | function formatDate(ts: number) { | ||
| 62 | return new Date(ts).toLocaleDateString([], { | ||
| 63 | month: "short", | ||
| 64 | day: "numeric", | ||
| 65 | hour: "2-digit", | ||
| 66 | minute: "2-digit", | ||
| 67 | }); | ||
| 68 | } | ||
| 69 | |||
| 70 | function formatResult(result: unknown): string { | ||
| 71 | if (result === undefined) { | ||
| 72 | return ""; | ||
| 73 | } | ||
| 74 | |||
| 75 | try { | ||
| 76 | return JSON.stringify(result, null, 2); | ||
| 77 | } catch { | ||
| 78 | return String(result); | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | function formatApprovalInput( | ||
| 83 | input: unknown, | ||
| 84 | ): { | ||
| 85 | content: string; | ||
| 86 | language: "json" | "text"; | ||
| 87 | } | null { | ||
| 88 | if (input === null || input === undefined) { | ||
| 89 | return null; | ||
| 90 | } | ||
| 91 | |||
| 92 | if (typeof input === "string") { | ||
| 93 | const trimmed = input.trim(); | ||
| 94 | if (!trimmed) { | ||
| 95 | return null; | ||
| 96 | } | ||
| 97 | |||
| 98 | try { | ||
| 99 | return { | ||
| 100 | content: JSON.stringify(JSON.parse(trimmed), null, 2), | ||
| 101 | language: "json", | ||
| 102 | }; | ||
| 103 | } catch { | ||
| 104 | return { | ||
| 105 | content: trimmed, | ||
| 106 | language: "text", | ||
| 107 | }; | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | try { | ||
| 112 | return { | ||
| 113 | content: JSON.stringify(input, null, 2), | ||
| 114 | language: "json", | ||
| 115 | }; | ||
| 116 | } catch { | ||
| 117 | const fallback = String(input).trim(); | ||
| 118 | if (!fallback) { | ||
| 119 | return null; | ||
| 120 | } | ||
| 121 | |||
| 122 | return { | ||
| 123 | content: fallback, | ||
| 124 | language: "text", | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | // ── Task Composer ── | ||
| 130 | |||
| 131 | function TaskComposer() { | ||
| 132 | const { context } = useSession(); | ||
| 133 | const [code, setCode] = useState(DEFAULT_CODE); | ||
| 134 | const [runtimeId, setRuntimeId] = useState("local-bun"); | ||
| 135 | const [timeoutMs, setTimeoutMs] = useState(String(DEFAULT_TIMEOUT_MS)); | ||
| 136 | const [submitting, setSubmitting] = useState(false); | ||
| 137 | |||
| 138 | const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {}); | ||
| 139 | const createTask = useMutation(convexApi.executor.createTask); | ||
| 140 | const { tools, dtsUrls, loadingTools, loadingTypes } = useWorkspaceTools(context ?? null); | ||
| 141 | |||
| 142 | const handleSubmit = async () => { | ||
| 143 | if (!context || !code.trim()) return; | ||
| 144 | setSubmitting(true); | ||
| 145 | try { | ||
| 146 | const data = await createTask({ | ||
| 147 | code, | ||
| 148 | runtimeId, | ||
| 149 | timeoutMs: Number.parseInt(timeoutMs, 10) || DEFAULT_TIMEOUT_MS, | ||
| 150 | workspaceId: context.workspaceId, | ||
| 151 | sessionId: context.sessionId, | ||
| 152 | clientId: context.clientId, | ||
| 153 | }); | ||
| 154 | toast.success(`Task created: ${data.task.id}`); | ||
| 155 | } catch (err) { | ||
| 156 | toast.error( | ||
| 157 | err instanceof Error ? err.message : "Failed to create task", | ||
| 158 | ); | ||
| 159 | } finally { | ||
| 160 | setSubmitting(false); | ||
| 161 | } | ||
| 162 | }; | ||
| 163 | |||
| 164 | return ( | ||
| 165 | <Card className="bg-card border-border"> | ||
| 166 | <CardHeader className="pb-3"> | ||
| 167 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 168 | <Play className="h-4 w-4 text-terminal-green" /> | ||
| 169 | New Task | ||
| 170 | </CardTitle> | ||
| 171 | </CardHeader> | ||
| 172 | <CardContent className="space-y-4"> | ||
| 173 | <div className="grid grid-cols-2 gap-3"> | ||
| 174 | <div className="space-y-1.5"> | ||
| 175 | <Label className="text-xs text-muted-foreground">Runtime</Label> | ||
| 176 | <Select value={runtimeId} onValueChange={setRuntimeId}> | ||
| 177 | <SelectTrigger className="h-8 text-xs font-mono bg-background"> | ||
| 178 | <SelectValue /> | ||
| 179 | </SelectTrigger> | ||
| 180 | <SelectContent> | ||
| 181 | {(runtimes ?? []).map((r: RuntimeTargetDescriptor) => ( | ||
| 182 | <SelectItem key={r.id} value={r.id} className="text-xs"> | ||
| 183 | {r.label} | ||
| 184 | </SelectItem> | ||
| 185 | ))} | ||
| 186 | </SelectContent> | ||
| 187 | </Select> | ||
| 188 | </div> | ||
| 189 | <div className="space-y-1.5"> | ||
| 190 | <Label className="text-xs text-muted-foreground"> | ||
| 191 | Timeout (ms) | ||
| 192 | </Label> | ||
| 193 | <Input | ||
| 194 | type="number" | ||
| 195 | value={timeoutMs} | ||
| 196 | onChange={(e) => setTimeoutMs(e.target.value)} | ||
| 197 | className="h-8 text-xs font-mono bg-background" | ||
| 198 | /> | ||
| 199 | </div> | ||
| 200 | </div> | ||
| 201 | <div className="space-y-1.5"> | ||
| 202 | <div className="flex items-center justify-between gap-2"> | ||
| 203 | <Label className="text-xs text-muted-foreground">Code</Label> | ||
| 204 | <span className="text-[10px] font-mono text-muted-foreground"> | ||
| 205 | {loadingTools | ||
| 206 | ? "Loading tool inventory..." | ||
| 207 | : loadingTypes | ||
| 208 | ? `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded, type defs warming...` | ||
| 209 | : `${tools.length} tool${tools.length === 1 ? "" : "s"} ready`} | ||
| 210 | </span> | ||
| 211 | </div> | ||
| 212 | {!loadingTools && loadingTypes && tools.length > 0 && ( | ||
| 213 | <div className="flex flex-wrap gap-1.5 rounded-md border border-border/50 bg-muted/20 px-2 py-1.5"> | ||
| 214 | {tools.slice(0, 8).map((tool) => ( | ||
| 215 | <span | ||
| 216 | key={tool.path} | ||
| 217 | className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground" | ||
| 218 | > | ||
| 219 | {tool.path} | ||
| 220 | </span> | ||
| 221 | ))} | ||
| 222 | {tools.length > 8 && ( | ||
| 223 | <span className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground"> | ||
| 224 | +{tools.length - 8} more | ||
| 225 | </span> | ||
| 226 | )} | ||
| 227 | </div> | ||
| 228 | )} | ||
| 229 | <div className="rounded-md border border-border"> | ||
| 230 | <CodeEditor | ||
| 231 | value={code} | ||
| 232 | onChange={setCode} | ||
| 233 | tools={tools} | ||
| 234 | dtsUrls={dtsUrls} | ||
| 235 | typesLoading={loadingTypes} | ||
| 236 | height="400px" | ||
| 237 | /> | ||
| 238 | </div> | ||
| 239 | </div> | ||
| 240 | <Button | ||
| 241 | onClick={handleSubmit} | ||
| 242 | disabled={submitting || !code.trim()} | ||
| 243 | className="w-full bg-primary text-primary-foreground hover:bg-primary/90 h-9" | ||
| 244 | size="sm" | ||
| 245 | > | ||
| 246 | <Send className="h-3.5 w-3.5 mr-2" /> | ||
| 247 | {submitting ? "Creating..." : "Execute Task"} | ||
| 248 | </Button> | ||
| 249 | </CardContent> | ||
| 250 | </Card> | ||
| 251 | ); | ||
| 252 | } | ||
| 253 | |||
| 254 | // ── Task List ── | ||
| 255 | |||
| 256 | function TaskListItem({ | ||
| 257 | task, | ||
| 258 | selected, | ||
| 259 | onClick, | ||
| 260 | }: { | ||
| 261 | task: TaskRecord; | ||
| 262 | selected: boolean; | ||
| 263 | onClick: () => void; | ||
| 264 | }) { | ||
| 265 | return ( | ||
| 266 | <button | ||
| 267 | onClick={onClick} | ||
| 268 | className={cn( | ||
| 269 | "w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-left group", | ||
| 270 | selected | ||
| 271 | ? "bg-primary/10 border border-primary/20" | ||
| 272 | : "hover:bg-accent/50 border border-transparent", | ||
| 273 | )} | ||
| 274 | > | ||
| 275 | <div className="flex-1 min-w-0"> | ||
| 276 | <div className="flex items-center gap-2"> | ||
| 277 | <span className="text-xs font-mono text-foreground truncate"> | ||
| 278 | {task.id} | ||
| 279 | </span> | ||
| 280 | </div> | ||
| 281 | <div className="flex items-center gap-2 mt-0.5"> | ||
| 282 | <span className="text-[10px] font-mono text-muted-foreground"> | ||
| 283 | {task.runtimeId} | ||
| 284 | </span> | ||
| 285 | <span className="text-[10px] text-muted-foreground"> | ||
| 286 | {formatDate(task.createdAt)} | ||
| 287 | </span> | ||
| 288 | </div> | ||
| 289 | </div> | ||
| 290 | <TaskStatusBadge status={task.status} /> | ||
| 291 | <ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> | ||
| 292 | </button> | ||
| 293 | ); | ||
| 294 | } | ||
| 295 | |||
| 296 | // ── Task Detail ── | ||
| 297 | |||
| 298 | function TaskDetail({ | ||
| 299 | task, | ||
| 300 | workspaceId, | ||
| 301 | sessionId, | ||
| 302 | pendingApprovals, | ||
| 303 | onClose, | ||
| 304 | }: { | ||
| 305 | task: TaskRecord; | ||
| 306 | workspaceId: Id<"workspaces">; | ||
| 307 | sessionId?: string; | ||
| 308 | pendingApprovals: PendingApprovalRecord[]; | ||
| 309 | onClose: () => void; | ||
| 310 | }) { | ||
| 311 | const resolveApproval = useMutation(convexApi.executor.resolveApproval); | ||
| 312 | const [resolvingApprovalId, setResolvingApprovalId] = useState<string | null>(null); | ||
| 313 | const liveTaskData = useQuery( | ||
| 314 | convexApi.workspace.getTaskInWorkspace, | ||
| 315 | workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip", | ||
| 316 | ); | ||
| 317 | |||
| 318 | const liveTask = liveTaskData ?? task; | ||
| 319 | const liveResult = formatResult(liveTask.result); | ||
| 320 | |||
| 321 | const duration = | ||
| 322 | liveTask.completedAt && liveTask.startedAt | ||
| 323 | ? `${((liveTask.completedAt - liveTask.startedAt) / 1000).toFixed(2)}s` | ||
| 324 | : liveTask.startedAt | ||
| 325 | ? "running..." | ||
| 326 | : "—"; | ||
| 327 | |||
| 328 | const handleResolveApproval = async ( | ||
| 329 | approvalId: string, | ||
| 330 | decision: "approved" | "denied", | ||
| 331 | toolPath: string, | ||
| 332 | ) => { | ||
| 333 | setResolvingApprovalId(approvalId); | ||
| 334 | try { | ||
| 335 | await resolveApproval({ | ||
| 336 | workspaceId, | ||
| 337 | sessionId, | ||
| 338 | approvalId, | ||
| 339 | decision, | ||
| 340 | }); | ||
| 341 | toast.success( | ||
| 342 | decision === "approved" | ||
| 343 | ? `Approved: ${toolPath}` | ||
| 344 | : `Denied: ${toolPath}`, | ||
| 345 | ); | ||
| 346 | } catch (error) { | ||
| 347 | toast.error(error instanceof Error ? error.message : "Failed to resolve approval"); | ||
| 348 | } finally { | ||
| 349 | setResolvingApprovalId(null); | ||
| 350 | } | ||
| 351 | }; | ||
| 352 | |||
| 353 | return ( | ||
| 354 | <Card className="bg-card border-border"> | ||
| 355 | <CardHeader className="pb-3"> | ||
| 356 | <div className="flex items-center justify-between"> | ||
| 357 | <CardTitle className="text-sm font-medium font-mono truncate pr-4"> | ||
| 358 | {liveTask.id} | ||
| 359 | </CardTitle> | ||
| 360 | <Button | ||
| 361 | variant="ghost" | ||
| 362 | size="icon" | ||
| 363 | className="h-7 w-7 shrink-0" | ||
| 364 | onClick={onClose} | ||
| 365 | > | ||
| 366 | <X className="h-4 w-4" /> | ||
| 367 | </Button> | ||
| 368 | </div> | ||
| 369 | </CardHeader> | ||
| 370 | <CardContent className="space-y-4"> | ||
| 371 | {/* Metadata grid */} | ||
| 372 | <div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> | ||
| 373 | {[ | ||
| 374 | { label: "Status", value: <TaskStatusBadge status={liveTask.status} /> }, | ||
| 375 | { label: "Runtime", value: <span className="font-mono text-xs">{liveTask.runtimeId}</span> }, | ||
| 376 | { label: "Duration", value: <span className="font-mono text-xs">{duration}</span> }, | ||
| 377 | { | ||
| 378 | label: "Exit Code", | ||
| 379 | value: ( | ||
| 380 | <span className={cn("font-mono text-xs", liveTask.exitCode === 0 ? "text-terminal-green" : liveTask.exitCode ? "text-terminal-red" : "text-muted-foreground")}> | ||
| 381 | {liveTask.exitCode ?? "—"} | ||
| 382 | </span> | ||
| 383 | ), | ||
| 384 | }, | ||
| 385 | ].map((item) => ( | ||
| 386 | <div key={item.label}> | ||
| 387 | <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1"> | ||
| 388 | {item.label} | ||
| 389 | </span> | ||
| 390 | {item.value} | ||
| 391 | </div> | ||
| 392 | ))} | ||
| 393 | </div> | ||
| 394 | |||
| 395 | {pendingApprovals.length > 0 ? ( | ||
| 396 | <> | ||
| 397 | <Separator /> | ||
| 398 | <div className="space-y-2.5"> | ||
| 399 | <div className="flex items-center justify-between"> | ||
| 400 | <span className="text-[10px] uppercase tracking-widest text-terminal-amber"> | ||
| 401 | Pending approvals | ||
| 402 | </span> | ||
| 403 | <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded"> | ||
| 404 | {pendingApprovals.length} | ||
| 405 | </span> | ||
| 406 | </div> | ||
| 407 | {pendingApprovals.map((approval) => { | ||
| 408 | const input = formatApprovalInput(approval.input); | ||
| 409 | const resolving = resolvingApprovalId === approval.id; | ||
| 410 | return ( | ||
| 411 | <div | ||
| 412 | key={approval.id} | ||
| 413 | className="rounded-md border border-terminal-amber/30 bg-terminal-amber/6 p-3 space-y-2" | ||
| 414 | > | ||
| 415 | <div className="flex items-center justify-between gap-3"> | ||
| 416 | <div className="min-w-0"> | ||
| 417 | <p className="text-xs font-mono text-foreground truncate"> | ||
| 418 | {approval.toolPath} | ||
| 419 | </p> | ||
| 420 | <p className="text-[10px] text-muted-foreground"> | ||
| 421 | Requested {formatDate(approval.createdAt)} | ||
| 422 | </p> | ||
| 423 | </div> | ||
| 424 | <div className="flex items-center gap-1.5 shrink-0"> | ||
| 425 | <Button | ||
| 426 | variant="outline" | ||
| 427 | size="sm" | ||
| 428 | className="h-7 text-[11px] border-terminal-green/30 text-terminal-green hover:bg-terminal-green/10" | ||
| 429 | disabled={resolvingApprovalId !== null} | ||
| 430 | onClick={() => | ||
| 431 | void handleResolveApproval( | ||
| 432 | approval.id, | ||
| 433 | "approved", | ||
| 434 | approval.toolPath, | ||
| 435 | ) | ||
| 436 | } | ||
| 437 | > | ||
| 438 | <CheckCircle2 className="h-3 w-3 mr-1" /> | ||
| 439 | {resolving ? "Approving..." : "Approve"} | ||
| 440 | </Button> | ||
| 441 | <Button | ||
| 442 | variant="outline" | ||
| 443 | size="sm" | ||
| 444 | className="h-7 text-[11px] border-terminal-red/30 text-terminal-red hover:bg-terminal-red/10" | ||
| 445 | disabled={resolvingApprovalId !== null} | ||
| 446 | onClick={() => | ||
| 447 | void handleResolveApproval( | ||
| 448 | approval.id, | ||
| 449 | "denied", | ||
| 450 | approval.toolPath, | ||
| 451 | ) | ||
| 452 | } | ||
| 453 | > | ||
| 454 | <ShieldX className="h-3 w-3 mr-1" /> | ||
| 455 | {resolving ? "Denying..." : "Deny"} | ||
| 456 | </Button> | ||
| 457 | </div> | ||
| 458 | </div> | ||
| 459 | {input ? ( | ||
| 460 | <FormattedCodeBlock | ||
| 461 | content={input.content} | ||
| 462 | language={input.language} | ||
| 463 | className="max-h-40 overflow-y-auto" | ||
| 464 | /> | ||
| 465 | ) : null} | ||
| 466 | </div> | ||
| 467 | ); | ||
| 468 | })} | ||
| 469 | </div> | ||
| 470 | </> | ||
| 471 | ) : null} | ||
| 472 | |||
| 473 | <Separator /> | ||
| 474 | |||
| 475 | {/* Code */} | ||
| 476 | <div> | ||
| 477 | <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2"> | ||
| 478 | Code | ||
| 479 | </span> | ||
| 480 | <FormattedCodeBlock | ||
| 481 | content={liveTask.code} | ||
| 482 | language="typescript" | ||
| 483 | className="max-h-48 overflow-y-auto" | ||
| 484 | /> | ||
| 485 | </div> | ||
| 486 | |||
| 487 | {/* Result */} | ||
| 488 | {liveResult && ( | ||
| 489 | <div> | ||
| 490 | <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2"> | ||
| 491 | Result | ||
| 492 | </span> | ||
| 493 | <FormattedCodeBlock | ||
| 494 | content={liveResult} | ||
| 495 | language="json" | ||
| 496 | className="max-h-48 overflow-y-auto" | ||
| 497 | /> | ||
| 498 | </div> | ||
| 499 | )} | ||
| 500 | |||
| 501 | {/* Error */} | ||
| 502 | {liveTask.error && ( | ||
| 503 | <div> | ||
| 504 | <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2"> | ||
| 505 | Error | ||
| 506 | </span> | ||
| 507 | <FormattedCodeBlock | ||
| 508 | content={liveTask.error} | ||
| 509 | language="text" | ||
| 510 | tone="red" | ||
| 511 | className="max-h-48 overflow-y-auto" | ||
| 512 | /> | ||
| 513 | </div> | ||
| 514 | )} | ||
| 515 | </CardContent> | ||
| 516 | </Card> | ||
| 517 | ); | ||
| 518 | } | ||
| 519 | |||
| 520 | // ── Tasks View ── | 22 | // ── Tasks View ── |
| 521 | 23 | ||
| 522 | export function TasksView() { | 24 | export 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 | <TaskDetail | 137 | <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)} |
| @@ -0,0 +1,55 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useCallback, useState } from "react"; | ||
| 4 | import { Check, Copy } from "lucide-react"; | ||
| 5 | import { | ||
| 6 | Tooltip, | ||
| 7 | TooltipContent, | ||
| 8 | TooltipProvider, | ||
| 9 | TooltipTrigger, | ||
| 10 | } from "@/components/ui/tooltip"; | ||
| 11 | import { cn } from "@/lib/utils"; | ||
| 12 | |||
| 13 | interface CopyButtonProps { | ||
| 14 | text: string; | ||
| 15 | className?: string; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function CopyButton({ text, className }: CopyButtonProps) { | ||
| 19 | const [copied, setCopied] = useState(false); | ||
| 20 | |||
| 21 | const handleCopy = useCallback( | ||
| 22 | (event: React.MouseEvent) => { | ||
| 23 | event.stopPropagation(); | ||
| 24 | navigator.clipboard.writeText(text); | ||
| 25 | setCopied(true); | ||
| 26 | setTimeout(() => setCopied(false), 1500); | ||
| 27 | }, | ||
| 28 | [text], | ||
| 29 | ); | ||
| 30 | |||
| 31 | return ( | ||
| 32 | <TooltipProvider delayDuration={200}> | ||
| 33 | <Tooltip> | ||
| 34 | <TooltipTrigger asChild> | ||
| 35 | <button | ||
| 36 | onClick={handleCopy} | ||
| 37 | className={cn( | ||
| 38 | "h-5 w-5 rounded flex items-center justify-center text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted transition-colors shrink-0", | ||
| 39 | className, | ||
| 40 | )} | ||
| 41 | > | ||
| 42 | {copied ? ( | ||
| 43 | <Check className="h-3 w-3 text-terminal-green" /> | ||
| 44 | ) : ( | ||
| 45 | <Copy className="h-3 w-3" /> | ||
| 46 | )} | ||
| 47 | </button> | ||
| 48 | </TooltipTrigger> | ||
| 49 | <TooltipContent side="left" className="text-[11px]"> | ||
| 50 | Copy path | ||
| 51 | </TooltipContent> | ||
| 52 | </Tooltip> | ||
| 53 | </TooltipProvider> | ||
| 54 | ); | ||
| 55 | } | ||
| @@ -0,0 +1,124 @@ | |||
| 1 | import { | ||
| 2 | buildApprovalTree, | ||
| 3 | buildNamespaceTree, | ||
| 4 | buildSourceTree, | ||
| 5 | toolNamespace, | ||
| 6 | type ToolGroup, | ||
| 7 | } from "@/lib/tool-explorer-grouping"; | ||
| 8 | import { sourceLabel } from "@/lib/tool-source-utils"; | ||
| 9 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 10 | import type { GroupBy, ViewMode } from "./tool-explorer-toolbar"; | ||
| 11 | |||
| 12 | export type FilterApproval = "all" | "required" | "auto"; | ||
| 13 | |||
| 14 | export function expandedKeysForSource(source: string | null): Set<string> { | ||
| 15 | return source ? new Set([`source:${source}`]) : new Set(); | ||
| 16 | } | ||
| 17 | |||
| 18 | export function filterToolsBySourceAndApproval( | ||
| 19 | tools: ToolDescriptor[], | ||
| 20 | activeSource: string | null, | ||
| 21 | filterApproval: FilterApproval, | ||
| 22 | ): ToolDescriptor[] { | ||
| 23 | let result = tools; | ||
| 24 | |||
| 25 | if (activeSource) { | ||
| 26 | result = result.filter((tool) => sourceLabel(tool.source) === activeSource); | ||
| 27 | } | ||
| 28 | |||
| 29 | if (filterApproval === "required") { | ||
| 30 | result = result.filter((tool) => tool.approval === "required"); | ||
| 31 | } else if (filterApproval === "auto") { | ||
| 32 | result = result.filter((tool) => tool.approval !== "required"); | ||
| 33 | } | ||
| 34 | |||
| 35 | return result; | ||
| 36 | } | ||
| 37 | |||
| 38 | export function filterToolsBySearch( | ||
| 39 | tools: ToolDescriptor[], | ||
| 40 | search: string, | ||
| 41 | ): ToolDescriptor[] { | ||
| 42 | if (!search) { | ||
| 43 | return tools; | ||
| 44 | } | ||
| 45 | |||
| 46 | const lowerSearch = search.toLowerCase(); | ||
| 47 | return tools.filter( | ||
| 48 | (tool) => | ||
| 49 | tool.path.toLowerCase().includes(lowerSearch) || | ||
| 50 | tool.description.toLowerCase().includes(lowerSearch), | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | |||
| 54 | export function treeGroupsForView( | ||
| 55 | tools: ToolDescriptor[], | ||
| 56 | viewMode: ViewMode, | ||
| 57 | groupBy: GroupBy, | ||
| 58 | ): ToolGroup[] { | ||
| 59 | if (viewMode === "flat") { | ||
| 60 | return []; | ||
| 61 | } | ||
| 62 | |||
| 63 | if (groupBy === "source") { | ||
| 64 | return buildSourceTree(tools); | ||
| 65 | } | ||
| 66 | |||
| 67 | if (groupBy === "namespace") { | ||
| 68 | return buildNamespaceTree(tools); | ||
| 69 | } | ||
| 70 | |||
| 71 | return buildApprovalTree(tools); | ||
| 72 | } | ||
| 73 | |||
| 74 | export function flatToolsForView( | ||
| 75 | tools: ToolDescriptor[], | ||
| 76 | viewMode: ViewMode, | ||
| 77 | ): ToolDescriptor[] { | ||
| 78 | if (viewMode !== "flat") { | ||
| 79 | return []; | ||
| 80 | } | ||
| 81 | |||
| 82 | return [...tools].sort((a, b) => a.path.localeCompare(b.path)); | ||
| 83 | } | ||
| 84 | |||
| 85 | export function autoExpandedKeysForSearch( | ||
| 86 | search: string, | ||
| 87 | filteredTools: ToolDescriptor[], | ||
| 88 | viewMode: ViewMode, | ||
| 89 | ): Set<string> | null { | ||
| 90 | if (search.length < 2 || viewMode !== "tree") { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | |||
| 94 | const allGroupKeys = new Set<string>(); | ||
| 95 | const lowerSearch = search.toLowerCase(); | ||
| 96 | const matching = filteredTools.filter( | ||
| 97 | (tool) => | ||
| 98 | tool.path.toLowerCase().includes(lowerSearch) || | ||
| 99 | tool.description.toLowerCase().includes(lowerSearch), | ||
| 100 | ); | ||
| 101 | |||
| 102 | for (const tool of matching) { | ||
| 103 | const source = sourceLabel(tool.source); | ||
| 104 | const namespace = toolNamespace(tool.path); | ||
| 105 | allGroupKeys.add(`source:${source}`); | ||
| 106 | allGroupKeys.add(`source:${source}:ns:${namespace}`); | ||
| 107 | allGroupKeys.add(`ns:${namespace}`); | ||
| 108 | } | ||
| 109 | |||
| 110 | return allGroupKeys; | ||
| 111 | } | ||
| 112 | |||
| 113 | export function countSelectedTools( | ||
| 114 | selectedKeys: Set<string>, | ||
| 115 | filteredTools: ToolDescriptor[], | ||
| 116 | ): number { | ||
| 117 | return Array.from(selectedKeys).filter((key) => | ||
| 118 | filteredTools.some((tool) => tool.path === key), | ||
| 119 | ).length; | ||
| 120 | } | ||
| 121 | |||
| 122 | export function sourceOptionsFromTools(tools: ToolDescriptor[]): string[] { | ||
| 123 | return Array.from(new Set(tools.map((tool) => sourceLabel(tool.source)))); | ||
| 124 | } | ||
| @@ -0,0 +1,238 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useMemo } from "react"; | ||
| 4 | import { | ||
| 5 | Check, | ||
| 6 | ChevronDown, | ||
| 7 | ChevronRight, | ||
| 8 | Globe, | ||
| 9 | Layers, | ||
| 10 | Server, | ||
| 11 | ShieldCheck, | ||
| 12 | } from "lucide-react"; | ||
| 13 | import { | ||
| 14 | Collapsible, | ||
| 15 | CollapsibleContent, | ||
| 16 | CollapsibleTrigger, | ||
| 17 | } from "@/components/ui/collapsible"; | ||
| 18 | import { cn } from "@/lib/utils"; | ||
| 19 | import { sourceLabel, sourceType } from "@/lib/tool-source-utils"; | ||
| 20 | import { toolOperation, type ToolGroup } from "@/lib/tool-explorer-grouping"; | ||
| 21 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 22 | import { SelectableToolRow } from "./tool-explorer-rows"; | ||
| 23 | |||
| 24 | export function GroupNode({ | ||
| 25 | group, | ||
| 26 | depth, | ||
| 27 | expandedKeys, | ||
| 28 | onToggle, | ||
| 29 | selectedKeys, | ||
| 30 | onSelectGroup, | ||
| 31 | onSelectTool, | ||
| 32 | search, | ||
| 33 | }: { | ||
| 34 | group: ToolGroup; | ||
| 35 | depth: number; | ||
| 36 | expandedKeys: Set<string>; | ||
| 37 | onToggle: (key: string) => void; | ||
| 38 | selectedKeys: Set<string>; | ||
| 39 | onSelectGroup: (key: string, e: React.MouseEvent) => void; | ||
| 40 | onSelectTool: (path: string, e: React.MouseEvent) => void; | ||
| 41 | search: string; | ||
| 42 | }) { | ||
| 43 | const isExpanded = expandedKeys.has(group.key); | ||
| 44 | const isSource = group.type === "source"; | ||
| 45 | const isGroupSelected = selectedKeys.has(group.key); | ||
| 46 | const SourceIcon = | ||
| 47 | group.sourceType === "mcp" | ||
| 48 | ? Server | ||
| 49 | : group.sourceType === "graphql" | ||
| 50 | ? Layers | ||
| 51 | : Globe; | ||
| 52 | |||
| 53 | const hasNestedGroups = | ||
| 54 | group.children.length > 0 && "key" in group.children[0]; | ||
| 55 | |||
| 56 | return ( | ||
| 57 | <Collapsible open={isExpanded} onOpenChange={() => onToggle(group.key)}> | ||
| 58 | <CollapsibleTrigger asChild> | ||
| 59 | <div | ||
| 60 | className={cn( | ||
| 61 | "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/row select-none", | ||
| 62 | "sticky bg-background/95 backdrop-blur-sm", | ||
| 63 | isExpanded && "border-b border-border/30", | ||
| 64 | isGroupSelected | ||
| 65 | ? "bg-primary/10 ring-1 ring-primary/20" | ||
| 66 | : "hover:bg-accent/30", | ||
| 67 | )} | ||
| 68 | style={{ | ||
| 69 | paddingLeft: `${depth * 20 + 8}px`, | ||
| 70 | top: `${depth * 32}px`, | ||
| 71 | zIndex: 20 - depth, | ||
| 72 | }} | ||
| 73 | > | ||
| 74 | <button | ||
| 75 | onClick={(e) => onSelectGroup(group.key, e)} | ||
| 76 | className={cn( | ||
| 77 | "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors", | ||
| 78 | isGroupSelected | ||
| 79 | ? "bg-primary border-primary text-primary-foreground" | ||
| 80 | : "border-border hover:border-muted-foreground/50", | ||
| 81 | )} | ||
| 82 | > | ||
| 83 | {isGroupSelected && <Check className="h-2.5 w-2.5" />} | ||
| 84 | </button> | ||
| 85 | |||
| 86 | <div className="h-4 w-4 flex items-center justify-center shrink-0"> | ||
| 87 | {isExpanded ? ( | ||
| 88 | <ChevronDown className="h-3 w-3 text-muted-foreground" /> | ||
| 89 | ) : ( | ||
| 90 | <ChevronRight className="h-3 w-3 text-muted-foreground" /> | ||
| 91 | )} | ||
| 92 | </div> | ||
| 93 | |||
| 94 | {isSource && ( | ||
| 95 | <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0"> | ||
| 96 | <SourceIcon className="h-3 w-3 text-muted-foreground" /> | ||
| 97 | </div> | ||
| 98 | )} | ||
| 99 | |||
| 100 | <span | ||
| 101 | className={cn( | ||
| 102 | "font-mono text-[13px] truncate", | ||
| 103 | isSource | ||
| 104 | ? "font-semibold text-foreground" | ||
| 105 | : "font-medium text-foreground/90", | ||
| 106 | )} | ||
| 107 | > | ||
| 108 | {group.label} | ||
| 109 | </span> | ||
| 110 | |||
| 111 | <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto flex items-center gap-2 shrink-0"> | ||
| 112 | {isSource && group.sourceType && ( | ||
| 113 | <span className="uppercase tracking-wider opacity-70"> | ||
| 114 | {group.sourceType} | ||
| 115 | </span> | ||
| 116 | )} | ||
| 117 | {group.approvalCount > 0 && ( | ||
| 118 | <span className="inline-flex items-center gap-0.5 text-terminal-amber"> | ||
| 119 | <ShieldCheck className="h-2.5 w-2.5" /> | ||
| 120 | {group.approvalCount} | ||
| 121 | </span> | ||
| 122 | )} | ||
| 123 | <span className="tabular-nums">{group.childCount}</span> | ||
| 124 | </span> | ||
| 125 | </div> | ||
| 126 | </CollapsibleTrigger> | ||
| 127 | |||
| 128 | <CollapsibleContent> | ||
| 129 | {hasNestedGroups | ||
| 130 | ? (group.children as ToolGroup[]).map((child) => ( | ||
| 131 | <GroupNode | ||
| 132 | key={child.key} | ||
| 133 | group={child} | ||
| 134 | depth={depth + 1} | ||
| 135 | expandedKeys={expandedKeys} | ||
| 136 | onToggle={onToggle} | ||
| 137 | selectedKeys={selectedKeys} | ||
| 138 | onSelectGroup={onSelectGroup} | ||
| 139 | onSelectTool={onSelectTool} | ||
| 140 | search={search} | ||
| 141 | /> | ||
| 142 | )) | ||
| 143 | : (group.children as ToolDescriptor[]).map((tool) => ( | ||
| 144 | <SelectableToolRow | ||
| 145 | key={tool.path} | ||
| 146 | tool={tool} | ||
| 147 | label={search ? tool.path : toolOperation(tool.path)} | ||
| 148 | depth={depth + 1} | ||
| 149 | selectedKeys={selectedKeys} | ||
| 150 | onSelectTool={onSelectTool} | ||
| 151 | /> | ||
| 152 | ))} | ||
| 153 | </CollapsibleContent> | ||
| 154 | </Collapsible> | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 158 | export function SourceSidebar({ | ||
| 159 | tools, | ||
| 160 | activeSource, | ||
| 161 | onSelectSource, | ||
| 162 | }: { | ||
| 163 | tools: ToolDescriptor[]; | ||
| 164 | activeSource: string | null; | ||
| 165 | onSelectSource: (source: string | null) => void; | ||
| 166 | }) { | ||
| 167 | const groups = useMemo(() => { | ||
| 168 | const map = new Map< | ||
| 169 | string, | ||
| 170 | { name: string; type: string; count: number; approvalCount: number } | ||
| 171 | >(); | ||
| 172 | |||
| 173 | for (const tool of tools) { | ||
| 174 | const name = sourceLabel(tool.source); | ||
| 175 | const type = sourceType(tool.source); | ||
| 176 | let group = map.get(name); | ||
| 177 | if (!group) { | ||
| 178 | group = { name, type, count: 0, approvalCount: 0 }; | ||
| 179 | map.set(name, group); | ||
| 180 | } | ||
| 181 | group.count++; | ||
| 182 | if (tool.approval === "required") { | ||
| 183 | group.approvalCount++; | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | return Array.from(map.values()).sort((a, b) => b.count - a.count); | ||
| 188 | }, [tools]); | ||
| 189 | |||
| 190 | return ( | ||
| 191 | <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block"> | ||
| 192 | <div className="px-3 pb-2 pt-1"> | ||
| 193 | <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50"> | ||
| 194 | Sources | ||
| 195 | </p> | ||
| 196 | </div> | ||
| 197 | <div className="space-y-0.5 px-1"> | ||
| 198 | <button | ||
| 199 | onClick={() => onSelectSource(null)} | ||
| 200 | className={cn( | ||
| 201 | "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]", | ||
| 202 | activeSource === null | ||
| 203 | ? "bg-accent/40 text-foreground" | ||
| 204 | : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | ||
| 205 | )} | ||
| 206 | > | ||
| 207 | <Layers className="h-3 w-3 shrink-0" /> | ||
| 208 | <span className="font-medium truncate">All sources</span> | ||
| 209 | <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60"> | ||
| 210 | {tools.length} | ||
| 211 | </span> | ||
| 212 | </button> | ||
| 213 | |||
| 214 | {groups.map((g) => { | ||
| 215 | const Icon = g.type === "mcp" ? Server : Globe; | ||
| 216 | return ( | ||
| 217 | <button | ||
| 218 | key={g.name} | ||
| 219 | onClick={() => onSelectSource(g.name)} | ||
| 220 | className={cn( | ||
| 221 | "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]", | ||
| 222 | activeSource === g.name | ||
| 223 | ? "bg-accent/40 text-foreground" | ||
| 224 | : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | ||
| 225 | )} | ||
| 226 | > | ||
| 227 | <Icon className="h-3 w-3 shrink-0" /> | ||
| 228 | <span className="font-mono font-medium truncate">{g.name}</span> | ||
| 229 | <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60"> | ||
| 230 | {g.count} | ||
| 231 | </span> | ||
| 232 | </button> | ||
| 233 | ); | ||
| 234 | })} | ||
| 235 | </div> | ||
| 236 | </div> | ||
| 237 | ); | ||
| 238 | } | ||
| @@ -0,0 +1,34 @@ | |||
| 1 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 2 | import type { ToolGroup } from "@/lib/tool-explorer-grouping"; | ||
| 3 | |||
| 4 | export function findToolsInGroupByKey( | ||
| 5 | groups: ToolGroup[], | ||
| 6 | key: string, | ||
| 7 | ): ToolDescriptor[] { | ||
| 8 | for (const group of groups) { | ||
| 9 | if (group.key === key) { | ||
| 10 | return collectToolsFromGroup(group); | ||
| 11 | } | ||
| 12 | |||
| 13 | if (group.children.length > 0 && "key" in group.children[0]) { | ||
| 14 | const found = findToolsInGroupByKey(group.children as ToolGroup[], key); | ||
| 15 | if (found.length > 0) { | ||
| 16 | return found; | ||
| 17 | } | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | return []; | ||
| 22 | } | ||
| 23 | |||
| 24 | function collectToolsFromGroup(group: ToolGroup): ToolDescriptor[] { | ||
| 25 | if (group.children.length === 0) { | ||
| 26 | return []; | ||
| 27 | } | ||
| 28 | |||
| 29 | if ("key" in group.children[0]) { | ||
| 30 | return (group.children as ToolGroup[]).flatMap(collectToolsFromGroup); | ||
| 31 | } | ||
| 32 | |||
| 33 | return group.children as ToolDescriptor[]; | ||
| 34 | } | ||
| @@ -0,0 +1,35 @@ | |||
| 1 | import { Loader2 } from "lucide-react"; | ||
| 2 | import { displaySourceName } from "@/lib/tool-source-utils"; | ||
| 3 | import type { ToolSourceRecord } from "@/lib/types"; | ||
| 4 | |||
| 5 | export function ToolExplorerLoading({ sources }: { sources: ToolSourceRecord[] }) { | ||
| 6 | const enabledSources = sources.filter((source) => source.enabled); | ||
| 7 | |||
| 8 | return ( | ||
| 9 | <div className="space-y-3 rounded-md border border-border/40 bg-muted/20 p-3"> | ||
| 10 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> | ||
| 11 | <Loader2 className="h-4 w-4 animate-spin" /> | ||
| 12 | Loading tool inventory... | ||
| 13 | </div> | ||
| 14 | |||
| 15 | {enabledSources.length > 0 && ( | ||
| 16 | <div className="space-y-1"> | ||
| 17 | {enabledSources.map((source) => ( | ||
| 18 | <div | ||
| 19 | key={source.id} | ||
| 20 | className="flex items-center gap-2 rounded-md border border-border bg-background/70 px-3 py-2 text-xs" | ||
| 21 | > | ||
| 22 | <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> | ||
| 23 | <span className="truncate font-medium"> | ||
| 24 | {displaySourceName(source.name)} | ||
| 25 | </span> | ||
| 26 | <span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"> | ||
| 27 | {source.type} | ||
| 28 | </span> | ||
| 29 | </div> | ||
| 30 | ))} | ||
| 31 | </div> | ||
| 32 | )} | ||
| 33 | </div> | ||
| 34 | ); | ||
| 35 | } | ||
| @@ -0,0 +1,175 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useCallback, useState, memo } from "react"; | ||
| 4 | import { | ||
| 5 | Check, | ||
| 6 | ChevronDown, | ||
| 7 | ChevronRight, | ||
| 8 | Search, | ||
| 9 | ShieldCheck, | ||
| 10 | Zap, | ||
| 11 | } from "lucide-react"; | ||
| 12 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; | ||
| 13 | import { cn } from "@/lib/utils"; | ||
| 14 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 15 | import { CopyButton } from "./tool-explorer-copy-button"; | ||
| 16 | import { ToolDetail } from "./tool-explorer-tool-detail"; | ||
| 17 | |||
| 18 | const ToolRow = memo(function ToolRow({ | ||
| 19 | tool, | ||
| 20 | label, | ||
| 21 | depth, | ||
| 22 | selected, | ||
| 23 | onSelect, | ||
| 24 | }: { | ||
| 25 | tool: ToolDescriptor; | ||
| 26 | label: string; | ||
| 27 | depth: number; | ||
| 28 | selected: boolean; | ||
| 29 | onSelect: (e: React.MouseEvent) => void; | ||
| 30 | }) { | ||
| 31 | const [expanded, setExpanded] = useState(false); | ||
| 32 | |||
| 33 | return ( | ||
| 34 | <Collapsible open={expanded} onOpenChange={setExpanded}> | ||
| 35 | <CollapsibleTrigger asChild> | ||
| 36 | <div | ||
| 37 | className={cn( | ||
| 38 | "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/tool select-none", | ||
| 39 | expanded | ||
| 40 | ? "sticky bg-background/95 backdrop-blur-sm bg-accent/30" | ||
| 41 | : selected | ||
| 42 | ? "bg-primary/5 ring-1 ring-primary/10" | ||
| 43 | : "hover:bg-accent/20", | ||
| 44 | )} | ||
| 45 | style={{ | ||
| 46 | paddingLeft: `${depth * 20 + 8}px`, | ||
| 47 | ...(expanded ? { top: `${depth * 32}px`, zIndex: 20 - depth } : {}), | ||
| 48 | }} | ||
| 49 | > | ||
| 50 | <button | ||
| 51 | onClick={onSelect} | ||
| 52 | className={cn( | ||
| 53 | "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors", | ||
| 54 | selected | ||
| 55 | ? "bg-primary border-primary text-primary-foreground" | ||
| 56 | : "border-border hover:border-muted-foreground/50", | ||
| 57 | )} | ||
| 58 | > | ||
| 59 | {selected && <Check className="h-2.5 w-2.5" />} | ||
| 60 | </button> | ||
| 61 | |||
| 62 | <div className="h-4 w-4 flex items-center justify-center shrink-0"> | ||
| 63 | {expanded ? ( | ||
| 64 | <ChevronDown className="h-3 w-3 text-muted-foreground/50" /> | ||
| 65 | ) : ( | ||
| 66 | <ChevronRight className="h-3 w-3 text-muted-foreground/30" /> | ||
| 67 | )} | ||
| 68 | </div> | ||
| 69 | |||
| 70 | <Zap className="h-3 w-3 text-primary/60 shrink-0" /> | ||
| 71 | |||
| 72 | <span className="text-[13px] font-mono text-foreground/90 truncate"> | ||
| 73 | {label} | ||
| 74 | </span> | ||
| 75 | |||
| 76 | {tool.approval === "required" && ( | ||
| 77 | <span className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/8 px-1.5 py-0.5 rounded border border-terminal-amber/15 shrink-0"> | ||
| 78 | <ShieldCheck className="h-2.5 w-2.5" /> | ||
| 79 | gated | ||
| 80 | </span> | ||
| 81 | )} | ||
| 82 | |||
| 83 | <div className="ml-auto flex items-center gap-1 shrink-0 opacity-0 group-hover/tool:opacity-100 transition-opacity"> | ||
| 84 | <CopyButton text={tool.path} /> | ||
| 85 | </div> | ||
| 86 | </div> | ||
| 87 | </CollapsibleTrigger> | ||
| 88 | |||
| 89 | <CollapsibleContent> | ||
| 90 | <ToolDetail tool={tool} depth={depth} /> | ||
| 91 | </CollapsibleContent> | ||
| 92 | </Collapsible> | ||
| 93 | ); | ||
| 94 | }); | ||
| 95 | |||
| 96 | export const SelectableToolRow = memo(function SelectableToolRow({ | ||
| 97 | tool, | ||
| 98 | label, | ||
| 99 | depth, | ||
| 100 | selectedKeys, | ||
| 101 | onSelectTool, | ||
| 102 | }: { | ||
| 103 | tool: ToolDescriptor; | ||
| 104 | label: string; | ||
| 105 | depth: number; | ||
| 106 | selectedKeys: Set<string>; | ||
| 107 | onSelectTool: (path: string, e: React.MouseEvent) => void; | ||
| 108 | }) { | ||
| 109 | const selected = selectedKeys.has(tool.path); | ||
| 110 | const handleSelect = useCallback( | ||
| 111 | (e: React.MouseEvent) => onSelectTool(tool.path, e), | ||
| 112 | [onSelectTool, tool.path], | ||
| 113 | ); | ||
| 114 | |||
| 115 | return ( | ||
| 116 | <ToolRow | ||
| 117 | tool={tool} | ||
| 118 | label={label} | ||
| 119 | depth={depth} | ||
| 120 | selected={selected} | ||
| 121 | onSelect={handleSelect} | ||
| 122 | /> | ||
| 123 | ); | ||
| 124 | }, | ||
| 125 | (prev, next) => | ||
| 126 | prev.tool === next.tool && | ||
| 127 | prev.label === next.label && | ||
| 128 | prev.depth === next.depth && | ||
| 129 | prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path), | ||
| 130 | ); | ||
| 131 | |||
| 132 | export function VirtualFlatList({ | ||
| 133 | tools, | ||
| 134 | selectedKeys, | ||
| 135 | onSelectTool, | ||
| 136 | scrollContainerRef, | ||
| 137 | }: { | ||
| 138 | tools: ToolDescriptor[]; | ||
| 139 | selectedKeys: Set<string>; | ||
| 140 | onSelectTool: (path: string, e: React.MouseEvent) => void; | ||
| 141 | scrollContainerRef: React.RefObject<HTMLDivElement | null>; | ||
| 142 | }) { | ||
| 143 | return ( | ||
| 144 | <div | ||
| 145 | ref={scrollContainerRef} | ||
| 146 | className="max-h-[calc(100vh-320px)] overflow-y-auto rounded-md border border-border/30 bg-background/30" | ||
| 147 | > | ||
| 148 | <div className="p-1"> | ||
| 149 | {tools.map((tool) => ( | ||
| 150 | <SelectableToolRow | ||
| 151 | key={tool.path} | ||
| 152 | tool={tool} | ||
| 153 | label={tool.path} | ||
| 154 | depth={0} | ||
| 155 | selectedKeys={selectedKeys} | ||
| 156 | onSelectTool={onSelectTool} | ||
| 157 | /> | ||
| 158 | ))} | ||
| 159 | </div> | ||
| 160 | </div> | ||
| 161 | ); | ||
| 162 | } | ||
| 163 | |||
| 164 | export function EmptyState({ hasSearch }: { hasSearch: boolean }) { | ||
| 165 | return ( | ||
| 166 | <div className="flex flex-col items-center justify-center h-full py-12 gap-2"> | ||
| 167 | <div className="h-10 w-10 rounded-full bg-muted/50 flex items-center justify-center"> | ||
| 168 | <Search className="h-5 w-5 text-muted-foreground/30" /> | ||
| 169 | </div> | ||
| 170 | <p className="text-sm text-muted-foreground/60"> | ||
| 171 | {hasSearch ? "No tools match your search" : "No tools available"} | ||
| 172 | </p> | ||
| 173 | </div> | ||
| 174 | ); | ||
| 175 | } | ||
| @@ -0,0 +1,43 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { Streamdown } from "streamdown"; | ||
| 4 | import { createCodePlugin } from "@streamdown/code"; | ||
| 5 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 6 | import { TypeSignature } from "./tool-explorer-type-signature"; | ||
| 7 | |||
| 8 | const codePlugin = createCodePlugin({ | ||
| 9 | themes: ["github-light", "github-dark"], | ||
| 10 | }); | ||
| 11 | |||
| 12 | export function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) { | ||
| 13 | const insetLeft = depth * 20 + 8 + 16 + 8; | ||
| 14 | |||
| 15 | return ( | ||
| 16 | <div className="space-y-2.5 pb-3 pt-1 pr-2" style={{ paddingLeft: insetLeft }}> | ||
| 17 | {tool.description && ( | ||
| 18 | <div className="tool-description text-[12px] leading-relaxed text-muted-foreground"> | ||
| 19 | <Streamdown plugins={{ code: codePlugin }}>{tool.description}</Streamdown> | ||
| 20 | </div> | ||
| 21 | )} | ||
| 22 | |||
| 23 | <div className="flex flex-wrap gap-1.5"> | ||
| 24 | <span className="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground/70"> | ||
| 25 | {tool.path} | ||
| 26 | </span> | ||
| 27 | {tool.source && ( | ||
| 28 | <span className="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground/70"> | ||
| 29 | source: {tool.source} | ||
| 30 | </span> | ||
| 31 | )} | ||
| 32 | {tool.operationId && ( | ||
| 33 | <span className="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground/70"> | ||
| 34 | op: {tool.operationId} | ||
| 35 | </span> | ||
| 36 | )} | ||
| 37 | </div> | ||
| 38 | |||
| 39 | {tool.argsType && <TypeSignature raw={tool.argsType} label="Arguments" />} | ||
| 40 | {tool.returnsType && <TypeSignature raw={tool.returnsType} label="Returns" />} | ||
| 41 | </div> | ||
| 42 | ); | ||
| 43 | } | ||
| @@ -0,0 +1,319 @@ | |||
| 1 | import { | ||
| 2 | Search, | ||
| 3 | ShieldCheck, | ||
| 4 | Server, | ||
| 5 | Filter, | ||
| 6 | X, | ||
| 7 | FolderTree, | ||
| 8 | List, | ||
| 9 | } from "lucide-react"; | ||
| 10 | import { Input } from "@/components/ui/input"; | ||
| 11 | import { Button } from "@/components/ui/button"; | ||
| 12 | import { | ||
| 13 | DropdownMenu, | ||
| 14 | DropdownMenuContent, | ||
| 15 | DropdownMenuCheckboxItem, | ||
| 16 | DropdownMenuLabel, | ||
| 17 | DropdownMenuSeparator, | ||
| 18 | DropdownMenuTrigger, | ||
| 19 | } from "@/components/ui/dropdown-menu"; | ||
| 20 | import { cn } from "@/lib/utils"; | ||
| 21 | |||
| 22 | export type GroupBy = "source" | "namespace" | "approval"; | ||
| 23 | export type ViewMode = "tree" | "flat"; | ||
| 24 | type FilterApproval = "all" | "required" | "auto"; | ||
| 25 | |||
| 26 | interface ToolExplorerToolbarProps { | ||
| 27 | search: string; | ||
| 28 | filteredToolCount: number; | ||
| 29 | hasSearch: boolean; | ||
| 30 | resultCount: number; | ||
| 31 | viewMode: ViewMode; | ||
| 32 | groupBy: GroupBy; | ||
| 33 | filterApproval: FilterApproval; | ||
| 34 | showSourceSidebar: boolean; | ||
| 35 | activeSource: string | null; | ||
| 36 | sourceOptions: string[]; | ||
| 37 | selectedToolCount: number; | ||
| 38 | warningsCount: number; | ||
| 39 | onSearchChange: (value: string) => void; | ||
| 40 | onClearSearch: () => void; | ||
| 41 | onViewModeChange: (mode: ViewMode) => void; | ||
| 42 | onGroupByChange: (mode: GroupBy) => void; | ||
| 43 | onFilterApprovalChange: (filter: FilterApproval) => void; | ||
| 44 | onSourceSelect: (source: string | null) => void; | ||
| 45 | onSelectAll: () => void; | ||
| 46 | onClearSelection: () => void; | ||
| 47 | onExpandAll: () => void; | ||
| 48 | onCollapseAll: () => void; | ||
| 49 | } | ||
| 50 | |||
| 51 | export function ToolExplorerToolbar({ | ||
| 52 | search, | ||
| 53 | filteredToolCount, | ||
| 54 | hasSearch, | ||
| 55 | resultCount, | ||
| 56 | viewMode, | ||
| 57 | groupBy, | ||
| 58 | filterApproval, | ||
| 59 | showSourceSidebar, | ||
| 60 | activeSource, | ||
| 61 | sourceOptions, | ||
| 62 | selectedToolCount, | ||
| 63 | warningsCount, | ||
| 64 | onSearchChange, | ||
| 65 | onClearSearch, | ||
| 66 | onViewModeChange, | ||
| 67 | onGroupByChange, | ||
| 68 | onFilterApprovalChange, | ||
| 69 | onSourceSelect, | ||
| 70 | onSelectAll, | ||
| 71 | onClearSelection, | ||
| 72 | onExpandAll, | ||
| 73 | onCollapseAll, | ||
| 74 | }: ToolExplorerToolbarProps) { | ||
| 75 | return ( | ||
| 76 | <div className="shrink-0"> | ||
| 77 | <div className="flex items-center gap-2 pb-3 flex-wrap"> | ||
| 78 | <div className="relative flex-1 min-w-[200px]"> | ||
| 79 | <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" /> | ||
| 80 | <Input | ||
| 81 | value={search} | ||
| 82 | onChange={(e) => onSearchChange(e.target.value)} | ||
| 83 | placeholder={`Search ${filteredToolCount} tools...`} | ||
| 84 | className="h-8 text-xs pl-8 bg-background/50 border-border/50 focus:border-primary/30" | ||
| 85 | /> | ||
| 86 | {search && ( | ||
| 87 | <button | ||
| 88 | onClick={onClearSearch} | ||
| 89 | className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 rounded flex items-center justify-center text-muted-foreground hover:text-foreground" | ||
| 90 | > | ||
| 91 | <X className="h-3 w-3" /> | ||
| 92 | </button> | ||
| 93 | )} | ||
| 94 | </div> | ||
| 95 | |||
| 96 | <div className="flex h-8 rounded-md border border-border/50 overflow-hidden"> | ||
| 97 | <button | ||
| 98 | onClick={() => onViewModeChange("tree")} | ||
| 99 | className={cn( | ||
| 100 | "px-2 flex items-center gap-1 text-[11px] transition-colors", | ||
| 101 | viewMode === "tree" | ||
| 102 | ? "bg-accent/40 text-foreground" | ||
| 103 | : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | ||
| 104 | )} | ||
| 105 | > | ||
| 106 | <FolderTree className="h-3 w-3" /> | ||
| 107 | <span className="hidden sm:inline">Tree</span> | ||
| 108 | </button> | ||
| 109 | <button | ||
| 110 | onClick={() => onViewModeChange("flat")} | ||
| 111 | className={cn( | ||
| 112 | "px-2 flex items-center gap-1 text-[11px] transition-colors border-l border-border/50", | ||
| 113 | viewMode === "flat" | ||
| 114 | ? "bg-accent/40 text-foreground" | ||
| 115 | : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | ||
| 116 | )} | ||
| 117 | > | ||
| 118 | <List className="h-3 w-3" /> | ||
| 119 | <span className="hidden sm:inline">Flat</span> | ||
| 120 | </button> | ||
| 121 | </div> | ||
| 122 | |||
| 123 | {viewMode === "tree" && ( | ||
| 124 | <DropdownMenu> | ||
| 125 | <DropdownMenuTrigger asChild> | ||
| 126 | <Button | ||
| 127 | variant="outline" | ||
| 128 | size="sm" | ||
| 129 | className="h-8 text-[11px] border-border/50" | ||
| 130 | > | ||
| 131 | <FolderTree className="h-3 w-3 mr-1" /> | ||
| 132 | {groupBy === "source" | ||
| 133 | ? "By source" | ||
| 134 | : groupBy === "namespace" | ||
| 135 | ? "By namespace" | ||
| 136 | : "By approval"} | ||
| 137 | </Button> | ||
| 138 | </DropdownMenuTrigger> | ||
| 139 | <DropdownMenuContent align="end"> | ||
| 140 | <DropdownMenuLabel className="text-[11px]">Group by</DropdownMenuLabel> | ||
| 141 | <DropdownMenuSeparator /> | ||
| 142 | <DropdownMenuCheckboxItem | ||
| 143 | checked={groupBy === "source"} | ||
| 144 | onCheckedChange={() => onGroupByChange("source")} | ||
| 145 | className="text-xs" | ||
| 146 | > | ||
| 147 | Source → Namespace | ||
| 148 | </DropdownMenuCheckboxItem> | ||
| 149 | <DropdownMenuCheckboxItem | ||
| 150 | checked={groupBy === "namespace"} | ||
| 151 | onCheckedChange={() => onGroupByChange("namespace")} | ||
| 152 | className="text-xs" | ||
| 153 | > | ||
| 154 | Namespace | ||
| 155 | </DropdownMenuCheckboxItem> | ||
| 156 | <DropdownMenuCheckboxItem | ||
| 157 | checked={groupBy === "approval"} | ||
| 158 | onCheckedChange={() => onGroupByChange("approval")} | ||
| 159 | className="text-xs" | ||
| 160 | > | ||
| 161 | Approval mode | ||
| 162 | </DropdownMenuCheckboxItem> | ||
| 163 | </DropdownMenuContent> | ||
| 164 | </DropdownMenu> | ||
| 165 | )} | ||
| 166 | |||
| 167 | <DropdownMenu> | ||
| 168 | <DropdownMenuTrigger asChild> | ||
| 169 | <Button | ||
| 170 | variant="outline" | ||
| 171 | size="sm" | ||
| 172 | className={cn( | ||
| 173 | "h-8 text-[11px] border-border/50", | ||
| 174 | filterApproval !== "all" && "border-primary/30 text-primary", | ||
| 175 | )} | ||
| 176 | > | ||
| 177 | <Filter className="h-3 w-3 mr-1" /> | ||
| 178 | {filterApproval === "all" | ||
| 179 | ? "Filter" | ||
| 180 | : filterApproval === "required" | ||
| 181 | ? "Gated only" | ||
| 182 | : "Auto only"} | ||
| 183 | </Button> | ||
| 184 | </DropdownMenuTrigger> | ||
| 185 | <DropdownMenuContent align="end"> | ||
| 186 | <DropdownMenuLabel className="text-[11px]">Approval filter</DropdownMenuLabel> | ||
| 187 | <DropdownMenuSeparator /> | ||
| 188 | <DropdownMenuCheckboxItem | ||
| 189 | checked={filterApproval === "all"} | ||
| 190 | onCheckedChange={() => onFilterApprovalChange("all")} | ||
| 191 | className="text-xs" | ||
| 192 | > | ||
| 193 | All tools | ||
| 194 | </DropdownMenuCheckboxItem> | ||
| 195 | <DropdownMenuCheckboxItem | ||
| 196 | checked={filterApproval === "required"} | ||
| 197 | onCheckedChange={() => onFilterApprovalChange("required")} | ||
| 198 | className="text-xs" | ||
| 199 | > | ||
| 200 | Approval required | ||
| 201 | </DropdownMenuCheckboxItem> | ||
| 202 | <DropdownMenuCheckboxItem | ||
| 203 | checked={filterApproval === "auto"} | ||
| 204 | onCheckedChange={() => onFilterApprovalChange("auto")} | ||
| 205 | className="text-xs" | ||
| 206 | > | ||
| 207 | Auto-approved | ||
| 208 | </DropdownMenuCheckboxItem> | ||
| 209 | </DropdownMenuContent> | ||
| 210 | </DropdownMenu> | ||
| 211 | |||
| 212 | <DropdownMenu> | ||
| 213 | <DropdownMenuTrigger asChild> | ||
| 214 | <Button | ||
| 215 | variant="outline" | ||
| 216 | size="sm" | ||
| 217 | className={cn( | ||
| 218 | "h-8 text-[11px] border-border/50", | ||
| 219 | showSourceSidebar && "lg:hidden", | ||
| 220 | activeSource && "border-primary/30 text-primary", | ||
| 221 | )} | ||
| 222 | > | ||
| 223 | <Server className="h-3 w-3 mr-1" /> | ||
| 224 | {activeSource ?? "All"} | ||
| 225 | </Button> | ||
| 226 | </DropdownMenuTrigger> | ||
| 227 | <DropdownMenuContent align="end"> | ||
| 228 | <DropdownMenuLabel className="text-[11px]">Source</DropdownMenuLabel> | ||
| 229 | <DropdownMenuSeparator /> | ||
| 230 | <DropdownMenuCheckboxItem | ||
| 231 | checked={activeSource === null} | ||
| 232 | onCheckedChange={() => onSourceSelect(null)} | ||
| 233 | className="text-xs" | ||
| 234 | > | ||
| 235 | All sources | ||
| 236 | </DropdownMenuCheckboxItem> | ||
| 237 | {sourceOptions.map((src) => ( | ||
| 238 | <DropdownMenuCheckboxItem | ||
| 239 | key={src} | ||
| 240 | checked={activeSource === src} | ||
| 241 | onCheckedChange={() => onSourceSelect(activeSource === src ? null : src)} | ||
| 242 | className="text-xs font-mono" | ||
| 243 | > | ||
| 244 | {src} | ||
| 245 | </DropdownMenuCheckboxItem> | ||
| 246 | ))} | ||
| 247 | </DropdownMenuContent> | ||
| 248 | </DropdownMenu> | ||
| 249 | </div> | ||
| 250 | |||
| 251 | {selectedToolCount > 0 && ( | ||
| 252 | <div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-md bg-primary/5 border border-primary/10"> | ||
| 253 | <span className="text-[12px] font-mono text-primary"> | ||
| 254 | {selectedToolCount} tool{selectedToolCount !== 1 ? "s" : ""} selected | ||
| 255 | </span> | ||
| 256 | <div className="flex-1" /> | ||
| 257 | <Button | ||
| 258 | variant="ghost" | ||
| 259 | size="sm" | ||
| 260 | className="h-6 text-[11px] text-muted-foreground" | ||
| 261 | onClick={onSelectAll} | ||
| 262 | > | ||
| 263 | Select all ({filteredToolCount}) | ||
| 264 | </Button> | ||
| 265 | <Button | ||
| 266 | variant="ghost" | ||
| 267 | size="sm" | ||
| 268 | className="h-6 text-[11px] text-muted-foreground" | ||
| 269 | onClick={onClearSelection} | ||
| 270 | > | ||
| 271 | Clear | ||
| 272 | </Button> | ||
| 273 | <div className="h-4 w-px bg-border/50" /> | ||
| 274 | <Button | ||
| 275 | variant="outline" | ||
| 276 | size="sm" | ||
| 277 | className="h-6 text-[11px] border-terminal-amber/30 text-terminal-amber hover:bg-terminal-amber/10" | ||
| 278 | > | ||
| 279 | <ShieldCheck className="h-3 w-3 mr-1" /> | ||
| 280 | Set approval | ||
| 281 | </Button> | ||
| 282 | </div> | ||
| 283 | )} | ||
| 284 | |||
| 285 | {warningsCount > 0 && ( | ||
| 286 | <div className="mb-2 rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2"> | ||
| 287 | <p className="text-[11px] text-terminal-amber/80"> | ||
| 288 | {warningsCount} source warning{warningsCount !== 1 ? "s" : ""} — inventory may | ||
| 289 | be incomplete | ||
| 290 | </p> | ||
| 291 | </div> | ||
| 292 | )} | ||
| 293 | |||
| 294 | <div className="flex items-center justify-between pb-1.5"> | ||
| 295 | <span className="text-[10px] font-mono text-muted-foreground/50"> | ||
| 296 | {hasSearch ? `${resultCount} results` : `${filteredToolCount} tools`} | ||
| 297 | {activeSource && ` in ${activeSource}`} | ||
| 298 | </span> | ||
| 299 | {viewMode === "tree" && ( | ||
| 300 | <div className="flex gap-1"> | ||
| 301 | <button | ||
| 302 | onClick={onExpandAll} | ||
| 303 | className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors" | ||
| 304 | > | ||
| 305 | Expand all | ||
| 306 | </button> | ||
| 307 | <span className="text-[10px] text-muted-foreground/30">·</span> | ||
| 308 | <button | ||
| 309 | onClick={onCollapseAll} | ||
| 310 | className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors" | ||
| 311 | > | ||
| 312 | Collapse all | ||
| 313 | </button> | ||
| 314 | </div> | ||
| 315 | )} | ||
| 316 | </div> | ||
| 317 | </div> | ||
| 318 | ); | ||
| 319 | } | ||
| @@ -0,0 +1,89 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useEffect, useMemo, useState } from "react"; | ||
| 4 | |||
| 5 | function prettyType(raw: string): string { | ||
| 6 | const trimmed = raw.trim(); | ||
| 7 | if (trimmed.includes("\n") || trimmed.length < 50) { | ||
| 8 | return trimmed; | ||
| 9 | } | ||
| 10 | |||
| 11 | let output = ""; | ||
| 12 | let indent = 0; | ||
| 13 | const tab = " "; | ||
| 14 | let index = 0; | ||
| 15 | |||
| 16 | while (index < trimmed.length) { | ||
| 17 | const char = trimmed[index]; | ||
| 18 | |||
| 19 | if (char === "[" && trimmed[index + 1] === "]") { | ||
| 20 | output += "[]"; | ||
| 21 | index += 2; | ||
| 22 | } else if (char === "{") { | ||
| 23 | indent++; | ||
| 24 | output += `${char}\n${tab.repeat(indent)}`; | ||
| 25 | index++; | ||
| 26 | while (index < trimmed.length && trimmed[index] === " ") { | ||
| 27 | index++; | ||
| 28 | } | ||
| 29 | } else if (char === "}") { | ||
| 30 | indent = Math.max(0, indent - 1); | ||
| 31 | output = output.replace(/\s+$/, ""); | ||
| 32 | output += `\n${tab.repeat(indent)}${char}`; | ||
| 33 | index++; | ||
| 34 | } else if (char === ";" && trimmed[index + 1] === " ") { | ||
| 35 | output += `;\n${tab.repeat(indent)}`; | ||
| 36 | index += 2; | ||
| 37 | } else if (char === "," && indent > 0 && trimmed[index + 1] === " ") { | ||
| 38 | output += `,\n${tab.repeat(indent)}`; | ||
| 39 | index += 2; | ||
| 40 | } else { | ||
| 41 | output += char; | ||
| 42 | index++; | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | return output; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function TypeSignature({ raw, label }: { raw: string; label: string }) { | ||
| 50 | const formatted = useMemo(() => prettyType(raw), [raw]); | ||
| 51 | const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null); | ||
| 52 | |||
| 53 | useEffect(() => { | ||
| 54 | let cancelled = false; | ||
| 55 | |||
| 56 | import("shiki").then(({ codeToHtml }) => | ||
| 57 | codeToHtml(formatted, { | ||
| 58 | lang: "typescript", | ||
| 59 | themes: { light: "github-light", dark: "github-dark" }, | ||
| 60 | }).then((html) => { | ||
| 61 | if (!cancelled) { | ||
| 62 | setHighlightedHtml(html); | ||
| 63 | } | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | |||
| 67 | return () => { | ||
| 68 | cancelled = true; | ||
| 69 | }; | ||
| 70 | }, [formatted]); | ||
| 71 | |||
| 72 | return ( | ||
| 73 | <div> | ||
| 74 | <p className="mb-1 text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50"> | ||
| 75 | {label} | ||
| 76 | </p> | ||
| 77 | {highlightedHtml ? ( | ||
| 78 | <div | ||
| 79 | className="type-signature text-[11px] leading-relaxed bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_code]:!text-[11px] [&_code]:!leading-relaxed" | ||
| 80 | dangerouslySetInnerHTML={{ __html: highlightedHtml }} | ||
| 81 | /> | ||
| 82 | ) : ( | ||
| 83 | <pre className="text-[11px] font-mono leading-relaxed text-foreground/80 bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto whitespace-pre"> | ||
| 84 | {formatted} | ||
| 85 | </pre> | ||
| 86 | )} | ||
| 87 | </div> | ||
| 88 | ); | ||
| 89 | } | ||
| @@ -1,789 +1,34 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useState, useMemo, useCallback, useRef } from "react"; | ||
| 4 | import { cn } from "@/lib/utils"; | ||
| 3 | import { | 5 | import { |
| 4 | useState, | 6 | collectGroupKeys, |
| 5 | useMemo, | 7 | } from "@/lib/tool-explorer-grouping"; |
| 6 | useCallback, | 8 | import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types"; |
| 7 | useEffect, | 9 | import { findToolsInGroupByKey } from "./tool-explorer-helpers"; |
| 8 | useRef, | ||
| 9 | memo, | ||
| 10 | } from "react"; | ||
| 11 | import { | ||
| 12 | Search, | ||
| 13 | ChevronRight, | ||
| 14 | ChevronDown, | ||
| 15 | ShieldCheck, | ||
| 16 | Zap, | ||
| 17 | Server, | ||
| 18 | Globe, | ||
| 19 | Filter, | ||
| 20 | X, | ||
| 21 | FolderTree, | ||
| 22 | List, | ||
| 23 | Check, | ||
| 24 | Copy, | ||
| 25 | Layers, | ||
| 26 | Loader2, | ||
| 27 | } from "lucide-react"; | ||
| 28 | import { Input } from "@/components/ui/input"; | ||
| 29 | import { Button } from "@/components/ui/button"; | ||
| 30 | import { | 10 | import { |
| 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"; | ||
| 21 | import { ToolExplorerLoading } from "./tool-explorer-loading"; | ||
| 38 | import { | 22 | import { |
| 39 | Tooltip, | 23 | EmptyState, |
| 40 | TooltipContent, | 24 | VirtualFlatList, |
| 41 | TooltipProvider, | 25 | } from "./tool-explorer-rows"; |
| 42 | TooltipTrigger, | 26 | import { GroupNode, SourceSidebar } from "./tool-explorer-groups"; |
| 43 | } from "@/components/ui/tooltip"; | ||
| 44 | import { | 27 | import { |
| 45 | Collapsible, | 28 | ToolExplorerToolbar, |
| 46 | CollapsibleContent, | 29 | type GroupBy, |
| 47 | CollapsibleTrigger, | 30 | type ViewMode, |
| 48 | } from "@/components/ui/collapsible"; | 31 | } from "./tool-explorer-toolbar"; |
| 49 | import { Streamdown } from "streamdown"; | ||
| 50 | import { createCodePlugin } from "@streamdown/code"; | ||
| 51 | import { cn } from "@/lib/utils"; | ||
| 52 | import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types"; | ||
| 53 | |||
| 54 | // ── Streamdown code plugin (light + dark dual-theme) ── | ||
| 55 | const codePlugin = createCodePlugin({ | ||
| 56 | themes: ["github-light", "github-dark"], | ||
| 57 | }); | ||
| 58 | |||
| 59 | // ── Helpers ── | ||
| 60 | |||
| 61 | function sourceLabel(source?: string): string { | ||
| 62 | if (!source) return "built-in"; | ||
| 63 | const idx = source.indexOf(":"); | ||
| 64 | return idx >= 0 ? source.slice(idx + 1) : source; | ||
| 65 | } | ||
| 66 | |||
| 67 | function sourceType(source?: string): string { | ||
| 68 | if (!source) return "local"; | ||
| 69 | const idx = source.indexOf(":"); | ||
| 70 | return idx >= 0 ? source.slice(0, idx) : "local"; | ||
| 71 | } | ||
| 72 | |||
| 73 | function toolNamespace(path: string): string { | ||
| 74 | const parts = path.split("."); | ||
| 75 | if (parts.length >= 2) return parts.slice(0, -1).join("."); | ||
| 76 | return parts[0]; | ||
| 77 | } | ||
| 78 | |||
| 79 | function toolOperation(path: string): string { | ||
| 80 | const parts = path.split("."); | ||
| 81 | return parts[parts.length - 1]; | ||
| 82 | } | ||
| 83 | |||
| 84 | function trimLeadingNamespace(path: string, prefix: string): string { | ||
| 85 | const dottedPrefix = `${prefix}.`; | ||
| 86 | if (path.startsWith(dottedPrefix)) { | ||
| 87 | return path.slice(dottedPrefix.length); | ||
| 88 | } | ||
| 89 | return path; | ||
| 90 | } | ||
| 91 | |||
| 92 | function displaySourceName(name: string): string { | ||
| 93 | const parts = name.split(/[-_.]+/).filter(Boolean); | ||
| 94 | if (parts.length === 0) return name; | ||
| 95 | |||
| 96 | const deduped = parts.filter((part, index, all) => { | ||
| 97 | if (index === 0) return true; | ||
| 98 | return part.toLowerCase() !== all[index - 1]?.toLowerCase(); | ||
| 99 | }); | ||
| 100 | |||
| 101 | const tokenMap: Record<string, string> = { | ||
| 102 | api: "API", | ||
| 103 | oauth: "OAuth", | ||
| 104 | graphql: "GraphQL", | ||
| 105 | mcp: "MCP", | ||
| 106 | github: "GitHub", | ||
| 107 | }; | ||
| 108 | |||
| 109 | return deduped | ||
| 110 | .map((token) => { | ||
| 111 | const lower = token.toLowerCase(); | ||
| 112 | if (tokenMap[lower]) return tokenMap[lower]; | ||
| 113 | return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`; | ||
| 114 | }) | ||
| 115 | .join(" "); | ||
| 116 | } | ||
| 117 | |||
| 118 | // ── Types for grouped tree data ── | ||
| 119 | |||
| 120 | interface ToolGroup { | ||
| 121 | key: string; | ||
| 122 | label: string; | ||
| 123 | type: "source" | "namespace"; | ||
| 124 | sourceType?: string; | ||
| 125 | childCount: number; | ||
| 126 | approvalCount: number; | ||
| 127 | children: ToolGroup[] | ToolDescriptor[]; | ||
| 128 | } | ||
| 129 | |||
| 130 | type GroupBy = "source" | "namespace" | "approval"; | ||
| 131 | type ViewMode = "tree" | "flat"; | ||
| 132 | |||
| 133 | // ── Build grouped data structures ── | ||
| 134 | |||
| 135 | function buildSourceTree(tools: ToolDescriptor[]): ToolGroup[] { | ||
| 136 | const bySource = new Map<string, ToolDescriptor[]>(); | ||
| 137 | for (const tool of tools) { | ||
| 138 | const src = sourceLabel(tool.source); | ||
| 139 | let list = bySource.get(src); | ||
| 140 | if (!list) { | ||
| 141 | list = []; | ||
| 142 | bySource.set(src, list); | ||
| 143 | } | ||
| 144 | list.push(tool); | ||
| 145 | } | ||
| 146 | |||
| 147 | return Array.from(bySource.entries()) | ||
| 148 | .sort((a, b) => b[1].length - a[1].length) | ||
| 149 | .map(([src, srcTools]) => { | ||
| 150 | const sType = srcTools[0] ? sourceType(srcTools[0].source) : "local"; | ||
| 151 | |||
| 152 | const byNs = new Map<string, ToolDescriptor[]>(); | ||
| 153 | for (const tool of srcTools) { | ||
| 154 | const ns = toolNamespace(tool.path); | ||
| 155 | let list = byNs.get(ns); | ||
| 156 | if (!list) { | ||
| 157 | list = []; | ||
| 158 | byNs.set(ns, list); | ||
| 159 | } | ||
| 160 | list.push(tool); | ||
| 161 | } | ||
| 162 | |||
| 163 | const nsGroups: ToolGroup[] = Array.from(byNs.entries()) | ||
| 164 | .sort((a, b) => a[0].localeCompare(b[0])) | ||
| 165 | .map(([ns, nsTools]) => ({ | ||
| 166 | key: `source:${src}:ns:${ns}`, | ||
| 167 | label: trimLeadingNamespace(ns, src), | ||
| 168 | type: "namespace" as const, | ||
| 169 | childCount: nsTools.length, | ||
| 170 | approvalCount: nsTools.filter((t) => t.approval === "required") | ||
| 171 | .length, | ||
| 172 | children: [...nsTools].sort((a, b) => | ||
| 173 | a.path.localeCompare(b.path), | ||
| 174 | ), | ||
| 175 | })); | ||
| 176 | |||
| 177 | return { | ||
| 178 | key: `source:${src}`, | ||
| 179 | label: src, | ||
| 180 | type: "source" as const, | ||
| 181 | sourceType: sType, | ||
| 182 | childCount: srcTools.length, | ||
| 183 | approvalCount: srcTools.filter((t) => t.approval === "required") | ||
| 184 | .length, | ||
| 185 | children: nsGroups, | ||
| 186 | }; | ||
| 187 | }); | ||
| 188 | } | ||
| 189 | |||
| 190 | function buildNamespaceTree(tools: ToolDescriptor[]): ToolGroup[] { | ||
| 191 | const byNs = new Map<string, ToolDescriptor[]>(); | ||
| 192 | for (const tool of tools) { | ||
| 193 | const ns = toolNamespace(tool.path); | ||
| 194 | let list = byNs.get(ns); | ||
| 195 | if (!list) { | ||
| 196 | list = []; | ||
| 197 | byNs.set(ns, list); | ||
| 198 | } | ||
| 199 | list.push(tool); | ||
| 200 | } | ||
| 201 | |||
| 202 | return Array.from(byNs.entries()) | ||
| 203 | .sort((a, b) => a[0].localeCompare(b[0])) | ||
| 204 | .map(([ns, nsTools]) => ({ | ||
| 205 | key: `ns:${ns}`, | ||
| 206 | label: ns, | ||
| 207 | type: "namespace" as const, | ||
| 208 | childCount: nsTools.length, | ||
| 209 | approvalCount: nsTools.filter((t) => t.approval === "required").length, | ||
| 210 | children: [...nsTools].sort((a, b) => a.path.localeCompare(b.path)), | ||
| 211 | })); | ||
| 212 | } | ||
| 213 | |||
| 214 | function buildApprovalTree(tools: ToolDescriptor[]): ToolGroup[] { | ||
| 215 | const gated = tools.filter((t) => t.approval === "required"); | ||
| 216 | const auto = tools.filter((t) => t.approval !== "required"); | ||
| 217 | const groups: ToolGroup[] = []; | ||
| 218 | |||
| 219 | if (gated.length > 0) { | ||
| 220 | groups.push({ | ||
| 221 | key: "approval:required", | ||
| 222 | label: "Approval Required", | ||
| 223 | type: "namespace", | ||
| 224 | childCount: gated.length, | ||
| 225 | approvalCount: gated.length, | ||
| 226 | children: [...gated].sort((a, b) => a.path.localeCompare(b.path)), | ||
| 227 | }); | ||
| 228 | } | ||
| 229 | if (auto.length > 0) { | ||
| 230 | groups.push({ | ||
| 231 | key: "approval:auto", | ||
| 232 | label: "Auto-approved", | ||
| 233 | type: "namespace", | ||
| 234 | childCount: auto.length, | ||
| 235 | approvalCount: 0, | ||
| 236 | children: [...auto].sort((a, b) => a.path.localeCompare(b.path)), | ||
| 237 | }); | ||
| 238 | } | ||
| 239 | |||
| 240 | return groups; | ||
| 241 | } | ||
| 242 | |||
| 243 | function collectGroupKeys(groups: ToolGroup[]): Set<string> { | ||
| 244 | const keys = new Set<string>(); | ||
| 245 | const stack = [...groups]; | ||
| 246 | |||
| 247 | while (stack.length > 0) { | ||
| 248 | const group = stack.pop(); | ||
| 249 | if (!group) continue; | ||
| 250 | |||
| 251 | keys.add(group.key); | ||
| 252 | if (group.children.length > 0 && "key" in group.children[0]) { | ||
| 253 | stack.push(...(group.children as ToolGroup[])); | ||
| 254 | } | ||
| 255 | } | ||
| 256 | |||
| 257 | return keys; | ||
| 258 | } | ||
| 259 | |||
| 260 | // ── Copy button ── | ||
| 261 | |||
| 262 | function CopyButton({ | ||
| 263 | text, | ||
| 264 | className, | ||
| 265 | }: { | ||
| 266 | text: string; | ||
| 267 | className?: string; | ||
| 268 | }) { | ||
| 269 | const [copied, setCopied] = useState(false); | ||
| 270 | |||
| 271 | const handleCopy = useCallback( | ||
| 272 | (e: React.MouseEvent) => { | ||
| 273 | e.stopPropagation(); | ||
| 274 | navigator.clipboard.writeText(text); | ||
| 275 | setCopied(true); | ||
| 276 | setTimeout(() => setCopied(false), 1500); | ||
| 277 | }, | ||
| 278 | [text], | ||
| 279 | ); | ||
| 280 | |||
| 281 | return ( | ||
| 282 | <TooltipProvider delayDuration={200}> | ||
| 283 | <Tooltip> | ||
| 284 | <TooltipTrigger asChild> | ||
| 285 | <button | ||
| 286 | onClick={handleCopy} | ||
| 287 | className={cn( | ||
| 288 | "h-5 w-5 rounded flex items-center justify-center text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted transition-colors shrink-0", | ||
| 289 | className, | ||
| 290 | )} | ||
| 291 | > | ||
| 292 | {copied ? ( | ||
| 293 | <Check className="h-3 w-3 text-terminal-green" /> | ||
| 294 | ) : ( | ||
| 295 | <Copy className="h-3 w-3" /> | ||
| 296 | )} | ||
| 297 | </button> | ||
| 298 | </TooltipTrigger> | ||
| 299 | <TooltipContent side="left" className="text-[11px]"> | ||
| 300 | Copy path | ||
| 301 | </TooltipContent> | ||
| 302 | </Tooltip> | ||
| 303 | </TooltipProvider> | ||
| 304 | ); | ||
| 305 | } | ||
| 306 | |||
| 307 | // ── Pretty-print a TS type literal for display ── | ||
| 308 | // Turns `{ owner: string; repo: string; ref?: string }` into a multi-line | ||
| 309 | // indented format. Handles nested objects/arrays. Pure string transform. | ||
| 310 | |||
| 311 | function prettyType(raw: string): string { | ||
| 312 | const trimmed = raw.trim(); | ||
| 313 | // If it's already multi-line or short enough, leave it | ||
| 314 | if (trimmed.includes("\n") || trimmed.length < 50) return trimmed; | ||
| 315 | |||
| 316 | let out = ""; | ||
| 317 | let indent = 0; | ||
| 318 | const TAB = " "; | ||
| 319 | let i = 0; | ||
| 320 | |||
| 321 | while (i < trimmed.length) { | ||
| 322 | const ch = trimmed[i]; | ||
| 323 | |||
| 324 | // Array type bracket — e.g. `string[]` or `}[]` — keep inline | ||
| 325 | if (ch === "[" && trimmed[i + 1] === "]") { | ||
| 326 | out += "[]"; | ||
| 327 | i += 2; | ||
| 328 | } else if (ch === "{") { | ||
| 329 | indent++; | ||
| 330 | out += ch + "\n" + TAB.repeat(indent); | ||
| 331 | i++; | ||
| 332 | // skip whitespace after brace | ||
| 333 | while (i < trimmed.length && trimmed[i] === " ") i++; | ||
| 334 | } else if (ch === "}") { | ||
| 335 | indent = Math.max(0, indent - 1); | ||
| 336 | // trim trailing whitespace on current line | ||
| 337 | out = out.replace(/\s+$/, ""); | ||
| 338 | out += "\n" + TAB.repeat(indent) + ch; | ||
| 339 | i++; | ||
| 340 | } else if (ch === ";" && trimmed[i + 1] === " ") { | ||
| 341 | // property separator — newline after semicolon | ||
| 342 | out += ";\n" + TAB.repeat(indent); | ||
| 343 | i += 2; // skip the semicolon + space | ||
| 344 | } else if (ch === "," && indent > 0 && trimmed[i + 1] === " ") { | ||
| 345 | out += ",\n" + TAB.repeat(indent); | ||
| 346 | i += 2; | ||
| 347 | } else { | ||
| 348 | out += ch; | ||
| 349 | i++; | ||
| 350 | } | ||
| 351 | } | ||
| 352 | |||
| 353 | return out; | ||
| 354 | } | ||
| 355 | |||
| 356 | // ── Formatted + highlighted type signature display ── | ||
| 357 | |||
| 358 | function TypeSignature({ raw, label }: { raw: string; label: string }) { | ||
| 359 | const formatted = useMemo(() => prettyType(raw), [raw]); | ||
| 360 | const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null); | ||
| 361 | |||
| 362 | useEffect(() => { | ||
| 363 | let cancelled = false; | ||
| 364 | import("shiki").then(({ codeToHtml }) => | ||
| 365 | codeToHtml(formatted, { | ||
| 366 | lang: "typescript", | ||
| 367 | themes: { light: "github-light", dark: "github-dark" }, | ||
| 368 | }).then((html) => { | ||
| 369 | if (!cancelled) setHighlightedHtml(html); | ||
| 370 | }), | ||
| 371 | ); | ||
| 372 | return () => { cancelled = true; }; | ||
| 373 | }, [formatted]); | ||
| 374 | |||
| 375 | return ( | ||
| 376 | <div> | ||
| 377 | <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50 mb-1"> | ||
| 378 | {label} | ||
| 379 | </p> | ||
| 380 | {highlightedHtml ? ( | ||
| 381 | <div | ||
| 382 | className="type-signature text-[11px] leading-relaxed bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_code]:!text-[11px] [&_code]:!leading-relaxed" | ||
| 383 | dangerouslySetInnerHTML={{ __html: highlightedHtml }} | ||
| 384 | /> | ||
| 385 | ) : ( | ||
| 386 | <pre className="text-[11px] font-mono leading-relaxed text-foreground/80 bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto whitespace-pre"> | ||
| 387 | {formatted} | ||
| 388 | </pre> | ||
| 389 | )} | ||
| 390 | </div> | ||
| 391 | ); | ||
| 392 | } | ||
| 393 | |||
| 394 | // ── Inline tool detail (shown when tool row is expanded) ── | ||
| 395 | |||
| 396 | function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) { | ||
| 397 | // Align details under the expand chevron. | ||
| 398 | const insetLeft = depth * 20 + 8 + 16 + 8; | ||
| 399 | return ( | ||
| 400 | <div className="pr-2 pb-3 pt-1 space-y-2.5" style={{ paddingLeft: insetLeft }}> | ||
| 401 | {/* Description rendered as markdown/HTML via Streamdown */} | ||
| 402 | {tool.description && ( | ||
| 403 | <div className="tool-description text-[12px] text-muted-foreground leading-relaxed"> | ||
| 404 | <Streamdown plugins={{ code: codePlugin }}> | ||
| 405 | {tool.description} | ||
| 406 | </Streamdown> | ||
| 407 | </div> | ||
| 408 | )} | ||
| 409 | |||
| 410 | {/* Metadata pills */} | ||
| 411 | <div className="flex flex-wrap gap-1.5"> | ||
| 412 | <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded"> | ||
| 413 | {tool.path} | ||
| 414 | </span> | ||
| 415 | {tool.source && ( | ||
| 416 | <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded"> | ||
| 417 | source: {tool.source} | ||
| 418 | </span> | ||
| 419 | )} | ||
| 420 | {tool.operationId && ( | ||
| 421 | <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded"> | ||
| 422 | op: {tool.operationId} | ||
| 423 | </span> | ||
| 424 | )} | ||
| 425 | </div> | ||
| 426 | |||
| 427 | {/* Args / Returns — pretty-printed type signatures */} | ||
| 428 | {tool.argsType && ( | ||
| 429 | <TypeSignature raw={tool.argsType} label="Arguments" /> | ||
| 430 | )} | ||
| 431 | {tool.returnsType && ( | ||
| 432 | <TypeSignature raw={tool.returnsType} label="Returns" /> | ||
| 433 | )} | ||
| 434 | </div> | ||
| 435 | ); | ||
| 436 | } | ||
| 437 | |||
| 438 | // ── Tool row — self-contained expand state, memoized ── | ||
| 439 | // Expand/collapse is local state so toggling one row never re-renders siblings. | ||
| 440 | |||
| 441 | const ToolRow = memo(function ToolRow({ | ||
| 442 | tool, | ||
| 443 | label, | ||
| 444 | depth, | ||
| 445 | selected, | ||
| 446 | onSelect, | ||
| 447 | }: { | ||
| 448 | tool: ToolDescriptor; | ||
| 449 | label: string; | ||
| 450 | depth: number; | ||
| 451 | selected: boolean; | ||
| 452 | onSelect: (e: React.MouseEvent) => void; | ||
| 453 | }) { | ||
| 454 | const [expanded, setExpanded] = useState(false); | ||
| 455 | |||
| 456 | return ( | ||
| 457 | <Collapsible open={expanded} onOpenChange={setExpanded}> | ||
| 458 | <CollapsibleTrigger asChild> | ||
| 459 | <div | ||
| 460 | className={cn( | ||
| 461 | "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/tool select-none", | ||
| 462 | expanded | ||
| 463 | ? "sticky bg-background/95 backdrop-blur-sm bg-accent/30" | ||
| 464 | : selected | ||
| 465 | ? "bg-primary/5 ring-1 ring-primary/10" | ||
| 466 | : "hover:bg-accent/20", | ||
| 467 | )} | ||
| 468 | style={{ | ||
| 469 | paddingLeft: `${depth * 20 + 8}px`, | ||
| 470 | ...(expanded ? { top: `${depth * 32}px`, zIndex: 20 - depth } : {}), | ||
| 471 | }} | ||
| 472 | > | ||
| 473 | {/* Selection checkbox */} | ||
| 474 | <button | ||
| 475 | onClick={onSelect} | ||
| 476 | className={cn( | ||
| 477 | "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors", | ||
| 478 | selected | ||
| 479 | ? "bg-primary border-primary text-primary-foreground" | ||
| 480 | : "border-border hover:border-muted-foreground/50", | ||
| 481 | )} | ||
| 482 | > | ||
| 483 | {selected && <Check className="h-2.5 w-2.5" />} | ||
| 484 | </button> | ||
| 485 | |||
| 486 | {/* Expand chevron */} | ||
| 487 | <div className="h-4 w-4 flex items-center justify-center shrink-0"> | ||
| 488 | {expanded ? ( | ||
| 489 | <ChevronDown className="h-3 w-3 text-muted-foreground/50" /> | ||
| 490 | ) : ( | ||
| 491 | <ChevronRight className="h-3 w-3 text-muted-foreground/30" /> | ||
| 492 | )} | ||
| 493 | </div> | ||
| 494 | |||
| 495 | {/* Tool icon */} | ||
| 496 | <Zap className="h-3 w-3 text-primary/60 shrink-0" /> | ||
| 497 | |||
| 498 | {/* Tool name */} | ||
| 499 | <span className="text-[13px] font-mono text-foreground/90 truncate"> | ||
| 500 | {label} | ||
| 501 | </span> | ||
| 502 | |||
| 503 | {/* Badges */} | ||
| 504 | {tool.approval === "required" && ( | ||
| 505 | <span className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/8 px-1.5 py-0.5 rounded border border-terminal-amber/15 shrink-0"> | ||
| 506 | <ShieldCheck className="h-2.5 w-2.5" /> | ||
| 507 | gated | ||
| 508 | </span> | ||
| 509 | )} | ||
| 510 | |||
| 511 | {/* Right side actions */} | ||
| 512 | <div className="ml-auto flex items-center gap-1 shrink-0 opacity-0 group-hover/tool:opacity-100 transition-opacity"> | ||
| 513 | <CopyButton text={tool.path} /> | ||
| 514 | </div> | ||
| 515 | </div> | ||
| 516 | </CollapsibleTrigger> | ||
| 517 | |||
| 518 | <CollapsibleContent> | ||
| 519 | <ToolDetail tool={tool} depth={depth} /> | ||
| 520 | </CollapsibleContent> | ||
| 521 | </Collapsible> | ||
| 522 | ); | ||
| 523 | }); | ||
| 524 | |||
| 525 | // ── Group row with Collapsible children ── | ||
| 526 | |||
| 527 | function GroupNode({ | ||
| 528 | group, | ||
| 529 | depth, | ||
| 530 | expandedKeys, | ||
| 531 | onToggle, | ||
| 532 | selectedKeys, | ||
| 533 | onSelectGroup, | ||
| 534 | onSelectTool, | ||
| 535 | search, | ||
| 536 | }: { | ||
| 537 | group: ToolGroup; | ||
| 538 | depth: number; | ||
| 539 | expandedKeys: Set<string>; | ||
| 540 | onToggle: (key: string) => void; | ||
| 541 | selectedKeys: Set<string>; | ||
| 542 | onSelectGroup: (key: string, e: React.MouseEvent) => void; | ||
| 543 | onSelectTool: (path: string, e: React.MouseEvent) => void; | ||
| 544 | search: string; | ||
| 545 | }) { | ||
| 546 | const isExpanded = expandedKeys.has(group.key); | ||
| 547 | const isSource = group.type === "source"; | ||
| 548 | const isGroupSelected = selectedKeys.has(group.key); | ||
| 549 | const SourceIcon = | ||
| 550 | group.sourceType === "mcp" | ||
| 551 | ? Server | ||
| 552 | : group.sourceType === "graphql" | ||
| 553 | ? Layers | ||
| 554 | : Globe; | ||
| 555 | |||
| 556 | const hasNestedGroups = | ||
| 557 | group.children.length > 0 && "key" in group.children[0]; | ||
| 558 | |||
| 559 | return ( | ||
| 560 | <Collapsible open={isExpanded} onOpenChange={() => onToggle(group.key)}> | ||
| 561 | <CollapsibleTrigger asChild> | ||
| 562 | <div | ||
| 563 | className={cn( | ||
| 564 | "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/row select-none", | ||
| 565 | "sticky bg-background/95 backdrop-blur-sm", | ||
| 566 | isExpanded && "border-b border-border/30", | ||
| 567 | isGroupSelected | ||
| 568 | ? "bg-primary/10 ring-1 ring-primary/20" | ||
| 569 | : "hover:bg-accent/30", | ||
| 570 | )} | ||
| 571 | style={{ | ||
| 572 | paddingLeft: `${depth * 20 + 8}px`, | ||
| 573 | top: `${depth * 32}px`, | ||
| 574 | zIndex: 20 - depth, | ||
| 575 | }} | ||
| 576 | > | ||
| 577 | {/* Selection checkbox */} | ||
| 578 | <button | ||
| 579 | onClick={(e) => onSelectGroup(group.key, e)} | ||
| 580 | className={cn( | ||
| 581 | "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors", | ||
| 582 | isGroupSelected | ||
| 583 | ? "bg-primary border-primary text-primary-foreground" | ||
| 584 | : "border-border hover:border-muted-foreground/50", | ||
| 585 | )} | ||
| 586 | > | ||
| 587 | {isGroupSelected && <Check className="h-2.5 w-2.5" />} | ||
| 588 | </button> | ||
| 589 | |||
| 590 | {/* Expand/collapse chevron */} | ||
| 591 | <div className="h-4 w-4 flex items-center justify-center shrink-0"> | ||
| 592 | {isExpanded ? ( | ||
| 593 | <ChevronDown className="h-3 w-3 text-muted-foreground" /> | ||
| 594 | ) : ( | ||
| 595 | <ChevronRight className="h-3 w-3 text-muted-foreground" /> | ||
| 596 | )} | ||
| 597 | </div> | ||
| 598 | |||
| 599 | {/* Icon */} | ||
| 600 | {isSource && ( | ||
| 601 | <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0"> | ||
| 602 | <SourceIcon className="h-3 w-3 text-muted-foreground" /> | ||
| 603 | </div> | ||
| 604 | )} | ||
| 605 | |||
| 606 | {/* Label */} | ||
| 607 | <span | ||
| 608 | className={cn( | ||
| 609 | "font-mono text-[13px] truncate", | ||
| 610 | isSource | ||
| 611 | ? "font-semibold text-foreground" | ||
| 612 | : "font-medium text-foreground/90", | ||
| 613 | )} | ||
| 614 | > | ||
| 615 | {group.label} | ||
| 616 | </span> | ||
| 617 | |||
| 618 | {/* Metadata */} | ||
| 619 | <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto flex items-center gap-2 shrink-0"> | ||
| 620 | {isSource && group.sourceType && ( | ||
| 621 | <span className="uppercase tracking-wider opacity-70"> | ||
| 622 | {group.sourceType} | ||
| 623 | </span> | ||
| 624 | )} | ||
| 625 | {group.approvalCount > 0 && ( | ||
| 626 | <span className="inline-flex items-center gap-0.5 text-terminal-amber"> | ||
| 627 | <ShieldCheck className="h-2.5 w-2.5" /> | ||
| 628 | {group.approvalCount} | ||
| 629 | </span> | ||
| 630 | )} | ||
| 631 | <span className="tabular-nums">{group.childCount}</span> | ||
| 632 | </span> | ||
| 633 | </div> | ||
| 634 | </CollapsibleTrigger> | ||
| 635 | |||
| 636 | <CollapsibleContent> | ||
| 637 | {hasNestedGroups | ||
| 638 | ? (group.children as ToolGroup[]).map((child) => ( | ||
| 639 | <GroupNode | ||
| 640 | key={child.key} | ||
| 641 | group={child} | ||
| 642 | depth={depth + 1} | ||
| 643 | expandedKeys={expandedKeys} | ||
| 644 | onToggle={onToggle} | ||
| 645 | selectedKeys={selectedKeys} | ||
| 646 | onSelectGroup={onSelectGroup} | ||
| 647 | onSelectTool={onSelectTool} | ||
| 648 | search={search} | ||
| 649 | /> | ||
| 650 | )) | ||
| 651 | : (group.children as ToolDescriptor[]).map((tool) => ( | ||
| 652 | <SelectableToolRow | ||
| 653 | key={tool.path} | ||
| 654 | tool={tool} | ||
| 655 | label={search ? tool.path : toolOperation(tool.path)} | ||
| 656 | depth={depth + 1} | ||
| 657 | selectedKeys={selectedKeys} | ||
| 658 | onSelectTool={onSelectTool} | ||
| 659 | /> | ||
| 660 | ))} | ||
| 661 | </CollapsibleContent> | ||
| 662 | </Collapsible> | ||
| 663 | ); | ||
| 664 | } | ||
| 665 | |||
| 666 | /** | ||
| 667 | * Thin adapter: extracts a boolean from the Set so ToolRow's memo sees | ||
| 668 | * a primitive `selected` and skips re-render when a *different* tool changes. | ||
| 669 | */ | ||
| 670 | const SelectableToolRow = memo(function SelectableToolRow({ | ||
| 671 | tool, | ||
| 672 | label, | ||
| 673 | depth, | ||
| 674 | selectedKeys, | ||
| 675 | onSelectTool, | ||
| 676 | }: { | ||
| 677 | tool: ToolDescriptor; | ||
| 678 | label: string; | ||
| 679 | depth: number; | ||
| 680 | selectedKeys: Set<string>; | ||
| 681 | onSelectTool: (path: string, e: React.MouseEvent) => void; | ||
| 682 | }) { | ||
| 683 | const selected = selectedKeys.has(tool.path); | ||
| 684 | const handleSelect = useCallback( | ||
| 685 | (e: React.MouseEvent) => onSelectTool(tool.path, e), | ||
| 686 | [onSelectTool, tool.path], | ||
| 687 | ); | ||
| 688 | |||
| 689 | return ( | ||
| 690 | <ToolRow | ||
| 691 | tool={tool} | ||
| 692 | label={label} | ||
| 693 | depth={depth} | ||
| 694 | selected={selected} | ||
| 695 | onSelect={handleSelect} | ||
| 696 | /> | ||
| 697 | ); | ||
| 698 | }, | ||
| 699 | (prev, next) => | ||
| 700 | prev.tool === next.tool && | ||
| 701 | prev.label === next.label && | ||
| 702 | prev.depth === next.depth && | ||
| 703 | prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path), | ||
| 704 | ); | ||
| 705 | |||
| 706 | // ── Sidebar (Source list) ── | ||
| 707 | |||
| 708 | function SourceSidebar({ | ||
| 709 | tools, | ||
| 710 | activeSource, | ||
| 711 | onSelectSource, | ||
| 712 | }: { | ||
| 713 | tools: ToolDescriptor[]; | ||
| 714 | activeSource: string | null; | ||
| 715 | onSelectSource: (source: string | null) => void; | ||
| 716 | }) { | ||
| 717 | const groups = useMemo(() => { | ||
| 718 | const map = new Map< | ||
| 719 | string, | ||
| 720 | { name: string; type: string; count: number; approvalCount: number } | ||
| 721 | >(); | ||
| 722 | |||
| 723 | for (const tool of tools) { | ||
| 724 | const name = sourceLabel(tool.source); | ||
| 725 | const type = sourceType(tool.source); | ||
| 726 | let group = map.get(name); | ||
| 727 | if (!group) { | ||
| 728 | group = { name, type, count: 0, approvalCount: 0 }; | ||
| 729 | map.set(name, group); | ||
| 730 | } | ||
| 731 | group.count++; | ||
| 732 | if (tool.approval === "required") group.approvalCount++; | ||
| 733 | } | ||
| 734 | |||
| 735 | return Array.from(map.values()).sort((a, b) => b.count - a.count); | ||
| 736 | }, [tools]); | ||
| 737 | |||
| 738 | return ( | ||
| 739 | <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block"> | ||
| 740 | <div className="px-3 pb-2 pt-1"> | ||
| 741 | <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50"> | ||
| 742 | Sources | ||
| 743 | </p> | ||
| 744 | </div> | ||
| 745 | <div className="space-y-0.5 px-1"> | ||
| 746 | <button | ||
| 747 | onClick={() => onSelectSource(null)} | ||
| 748 | className={cn( | ||
| 749 | "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]", | ||
| 750 | activeSource === null | ||
| 751 | ? "bg-accent/40 text-foreground" | ||
| 752 | : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | ||
| 753 | )} | ||
| 754 | > | ||
| 755 | <Layers className="h-3 w-3 shrink-0" /> | ||
| 756 | <span className="font-medium truncate">All sources</span> | ||
| 757 | <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60"> | ||
| 758 | {tools.length} | ||
| 759 | </span> | ||
| 760 | </button> | ||
| 761 | |||
| 762 | {groups.map((g) => { | ||
| 763 | const Icon = g.type === "mcp" ? Server : Globe; | ||
| 764 | return ( | ||
| 765 | <button | ||
| 766 | key={g.name} | ||
| 767 | onClick={() => onSelectSource(g.name)} | ||
| 768 | className={cn( | ||
| 769 | "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]", | ||
| 770 | activeSource === g.name | ||
| 771 | ? "bg-accent/40 text-foreground" | ||
| 772 | : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | ||
| 773 | )} | ||
| 774 | > | ||
| 775 | <Icon className="h-3 w-3 shrink-0" /> | ||
| 776 | <span className="font-mono font-medium truncate">{g.name}</span> | ||
| 777 | <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60"> | ||
| 778 | {g.count} | ||
| 779 | </span> | ||
| 780 | </button> | ||
| 781 | ); | ||
| 782 | })} | ||
| 783 | </div> | ||
| 784 | </div> | ||
| 785 | ); | ||
| 786 | } | ||
| 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 | <Input | 220 | 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 | <button | 227 | 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 | <button | 238 | 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 | <div | 245 | <div |
| @@ -1390,52 +286,3 @@ export function ToolExplorer({ · 40 unmodified lines | |||
| 1390 | </div> | 286 | </div> |
| 1391 | ); | 287 | ); |
| 1392 | } | 288 | } |
| 1393 | |||
| 1394 | // ── Flat list ── | ||
| 1395 | |||
| 1396 | function VirtualFlatList({ | ||
| 1397 | tools, | ||
| 1398 | selectedKeys, | ||
| 1399 | onSelectTool, | ||
| 1400 | scrollContainerRef, | ||
| 1401 | }: { | ||
| 1402 | tools: ToolDescriptor[]; | ||
| 1403 | selectedKeys: Set<string>; | ||
| 1404 | onSelectTool: (path: string, e: React.MouseEvent) => void; | ||
| 1405 | scrollContainerRef: React.RefObject<HTMLDivElement | null>; | ||
| 1406 | }) { | ||
| 1407 | return ( | ||
| 1408 | <div | ||
| 1409 | ref={scrollContainerRef} | ||
| 1410 | className="max-h-[calc(100vh-320px)] overflow-y-auto rounded-md border border-border/30 bg-background/30" | ||
| 1411 | > | ||
| 1412 | <div className="p-1"> | ||
| 1413 | {tools.map((tool) => ( | ||
| 1414 | <SelectableToolRow | ||
| 1415 | key={tool.path} | ||
| 1416 | tool={tool} | ||
| 1417 | label={tool.path} | ||
| 1418 | depth={0} | ||
| 1419 | selectedKeys={selectedKeys} | ||
| 1420 | onSelectTool={onSelectTool} | ||
| 1421 | /> | ||
| 1422 | ))} | ||
| 1423 | </div> | ||
| 1424 | </div> | ||
| 1425 | ); | ||
| 1426 | } | ||
| 1427 | |||
| 1428 | // ── Empty state ── | ||
| 1429 | |||
| 1430 | function EmptyState({ hasSearch }: { hasSearch: boolean }) { | ||
| 1431 | return ( | ||
| 1432 | <div className="flex flex-col items-center justify-center h-full py-12 gap-2"> | ||
| 1433 | <div className="h-10 w-10 rounded-full bg-muted/50 flex items-center justify-center"> | ||
| 1434 | <Search className="h-5 w-5 text-muted-foreground/30" /> | ||
| 1435 | </div> | ||
| 1436 | <p className="text-sm text-muted-foreground/60"> | ||
| 1437 | {hasSearch ? "No tools match your search" : "No tools available"} | ||
| 1438 | </p> | ||
| 1439 | </div> | ||
| 1440 | ); | ||
| 1441 | } | ||
| @@ -0,0 +1,92 @@ | |||
| 1 | import { | ||
| 2 | type CatalogCollectionItem, | ||
| 3 | HARD_CODED_CATALOG_ITEMS, | ||
| 4 | } from "@/lib/catalog-collections"; | ||
| 5 | |||
| 6 | export type SourceCatalogSort = "popular" | "recent"; | ||
| 7 | export type SourceType = "mcp" | "openapi" | "graphql"; | ||
| 8 | |||
| 9 | export const DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY = "userId"; | ||
| 10 | |||
| 11 | export function getVisibleCatalogItems( | ||
| 12 | query: string, | ||
| 13 | sort: SourceCatalogSort, | ||
| 14 | ): CatalogCollectionItem[] { | ||
| 15 | const normalizedQuery = query.trim().toLowerCase(); | ||
| 16 | const filtered = HARD_CODED_CATALOG_ITEMS.filter((item) => { | ||
| 17 | if (!normalizedQuery) { | ||
| 18 | return true; | ||
| 19 | } | ||
| 20 | |||
| 21 | return [ | ||
| 22 | item.name, | ||
| 23 | item.providerName, | ||
| 24 | item.summary, | ||
| 25 | item.categories ?? "", | ||
| 26 | ].some((value) => value.toLowerCase().includes(normalizedQuery)); | ||
| 27 | }); | ||
| 28 | |||
| 29 | return [...filtered].sort((a, b) => { | ||
| 30 | if (sort === "recent") { | ||
| 31 | return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime(); | ||
| 32 | } | ||
| 33 | |||
| 34 | return a.rank - b.rank; | ||
| 35 | }); | ||
| 36 | } | ||
| 37 | |||
| 38 | export function endpointLabelForType(type: SourceType): string { | ||
| 39 | if (type === "mcp") { | ||
| 40 | return "Endpoint URL"; | ||
| 41 | } | ||
| 42 | |||
| 43 | if (type === "graphql") { | ||
| 44 | return "GraphQL Endpoint"; | ||
| 45 | } | ||
| 46 | |||
| 47 | return "Spec URL"; | ||
| 48 | } | ||
| 49 | |||
| 50 | export function endpointPlaceholderForType(type: SourceType): string { | ||
| 51 | if (type === "mcp") { | ||
| 52 | return "https://mcp-server.example.com/sse"; | ||
| 53 | } | ||
| 54 | |||
| 55 | if (type === "graphql") { | ||
| 56 | return "https://api.example.com/graphql"; | ||
| 57 | } | ||
| 58 | |||
| 59 | return "https://api.example.com/openapi.json"; | ||
| 60 | } | ||
| 61 | |||
| 62 | export function createCustomSourceConfig({ | ||
| 63 | type, | ||
| 64 | endpoint, | ||
| 65 | baseUrl, | ||
| 66 | mcpTransport, | ||
| 67 | mcpActorQueryParamKey, | ||
| 68 | actorId, | ||
| 69 | }: { | ||
| 70 | type: SourceType; | ||
| 71 | endpoint: string; | ||
| 72 | baseUrl: string; | ||
| 73 | mcpTransport: "auto" | "streamable-http" | "sse"; | ||
| 74 | mcpActorQueryParamKey: string; | ||
| 75 | actorId?: string; | ||
| 76 | }): Record<string, unknown> { | ||
| 77 | if (type === "mcp") { | ||
| 78 | return { | ||
| 79 | url: endpoint, | ||
| 80 | ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}), | ||
| 81 | ...(mcpActorQueryParamKey.trim() && actorId | ||
| 82 | ? { queryParams: { [mcpActorQueryParamKey.trim()]: actorId } } | ||
| 83 | : {}), | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | |||
| 87 | if (type === "graphql") { | ||
| 88 | return { endpoint }; | ||
| 89 | } | ||
| 90 | |||
| 91 | return { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; | ||
| 92 | } | ||
| @@ -0,0 +1,264 @@ | |||
| 1 | import { ChevronRight, Plus } from "lucide-react"; | ||
| 2 | import Image from "next/image"; | ||
| 3 | import { Streamdown } from "streamdown"; | ||
| 4 | import { Button } from "@/components/ui/button"; | ||
| 5 | import { Input } from "@/components/ui/input"; | ||
| 6 | import { Label } from "@/components/ui/label"; | ||
| 7 | import { | ||
| 8 | Select, | ||
| 9 | SelectContent, | ||
| 10 | SelectItem, | ||
| 11 | SelectTrigger, | ||
| 12 | SelectValue, | ||
| 13 | } from "@/components/ui/select"; | ||
| 14 | import { Separator } from "@/components/ui/separator"; | ||
| 15 | import type { CatalogCollectionItem } from "@/lib/catalog-collections"; | ||
| 16 | import { | ||
| 17 | endpointLabelForType, | ||
| 18 | endpointPlaceholderForType, | ||
| 19 | type SourceCatalogSort, | ||
| 20 | type SourceType, | ||
| 21 | } from "./tools-view-add-source-dialog-helpers"; | ||
| 22 | |||
| 23 | export function CatalogViewSection({ | ||
| 24 | catalogQuery, | ||
| 25 | onCatalogQueryChange, | ||
| 26 | catalogSort, | ||
| 27 | onCatalogSortChange, | ||
| 28 | visibleCatalogItems, | ||
| 29 | addingCatalogId, | ||
| 30 | onSwitchToCustom, | ||
| 31 | onAddCatalog, | ||
| 32 | }: { | ||
| 33 | catalogQuery: string; | ||
| 34 | onCatalogQueryChange: (value: string) => void; | ||
| 35 | catalogSort: SourceCatalogSort; | ||
| 36 | onCatalogSortChange: (value: SourceCatalogSort) => void; | ||
| 37 | visibleCatalogItems: CatalogCollectionItem[]; | ||
| 38 | addingCatalogId: string | null; | ||
| 39 | onSwitchToCustom: () => void; | ||
| 40 | onAddCatalog: (item: CatalogCollectionItem) => void; | ||
| 41 | }) { | ||
| 42 | return ( | ||
| 43 | <div className="space-y-3"> | ||
| 44 | <div className="flex items-center gap-2 flex-wrap"> | ||
| 45 | <Input | ||
| 46 | value={catalogQuery} | ||
| 47 | onChange={(event) => onCatalogQueryChange(event.target.value)} | ||
| 48 | placeholder="Search APIs" | ||
| 49 | className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]" | ||
| 50 | /> | ||
| 51 | <Select value={catalogSort} onValueChange={(value) => onCatalogSortChange(value as SourceCatalogSort)}> | ||
| 52 | <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0"> | ||
| 53 | <SelectValue /> | ||
| 54 | </SelectTrigger> | ||
| 55 | <SelectContent> | ||
| 56 | <SelectItem value="popular" className="text-xs">Popular</SelectItem> | ||
| 57 | <SelectItem value="recent" className="text-xs">Recent</SelectItem> | ||
| 58 | </SelectContent> | ||
| 59 | </Select> | ||
| 60 | </div> | ||
| 61 | |||
| 62 | <p className="text-[11px] text-muted-foreground"> | ||
| 63 | Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}. | ||
| 64 | </p> | ||
| 65 | |||
| 66 | <Separator /> | ||
| 67 | |||
| 68 | <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1"> | ||
| 69 | <button | ||
| 70 | type="button" | ||
| 71 | onClick={onSwitchToCustom} | ||
| 72 | className="w-full max-w-full overflow-hidden text-left px-3 py-2 rounded-md border border-border/70 bg-muted/40 hover:bg-accent/30 transition-colors" | ||
| 73 | > | ||
| 74 | <div className="flex items-center gap-2"> | ||
| 75 | <span className="inline-flex h-6 w-6 items-center justify-center rounded bg-muted"> | ||
| 76 | <Plus className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 77 | </span> | ||
| 78 | <div className="min-w-0 w-0 flex-1 overflow-hidden"> | ||
| 79 | <p className="text-xs font-medium">Add custom source</p> | ||
| 80 | <p className="text-[10px] text-muted-foreground">MCP, OpenAPI, or GraphQL endpoint</p> | ||
| 81 | </div> | ||
| 82 | </div> | ||
| 83 | </button> | ||
| 84 | |||
| 85 | {visibleCatalogItems.map((item) => ( | ||
| 86 | <div | ||
| 87 | key={item.id} | ||
| 88 | className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50" | ||
| 89 | > | ||
| 90 | {item.logoUrl && ( | ||
| 91 | <Image | ||
| 92 | src={item.logoUrl} | ||
| 93 | alt="" | ||
| 94 | width={20} | ||
| 95 | height={20} | ||
| 96 | className="w-5 h-5 rounded shrink-0 mt-0.5 object-contain" | ||
| 97 | loading="lazy" | ||
| 98 | unoptimized | ||
| 99 | /> | ||
| 100 | )} | ||
| 101 | <div className="flex-1 min-w-0 w-0 overflow-hidden"> | ||
| 102 | <p className="text-xs font-medium truncate">{item.name}</p> | ||
| 103 | <p className="text-[10px] text-muted-foreground truncate"> | ||
| 104 | {item.providerName} | ||
| 105 | {item.version ? ` · v${item.version}` : ""} | ||
| 106 | </p> | ||
| 107 | {item.summary && ( | ||
| 108 | <div | ||
| 109 | className="mt-0.5 w-full min-w-0 max-w-full overflow-hidden text-[10px] text-muted-foreground/90 leading-relaxed break-words [overflow-wrap:anywhere] [&_*]:max-w-full [&_*]:min-w-0 [&_*]:break-words [&_a]:break-all [&_a]:whitespace-normal [&_code]:break-all [&_code]:whitespace-pre-wrap [&_p]:m-0 [&_pre]:max-w-full [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:break-words line-clamp-2" | ||
| 110 | style={{ overflowWrap: "anywhere", wordBreak: "break-word" }} | ||
| 111 | > | ||
| 112 | <Streamdown controls={false}>{item.summary}</Streamdown> | ||
| 113 | </div> | ||
| 114 | )} | ||
| 115 | </div> | ||
| 116 | <div className="flex items-center gap-1 shrink-0"> | ||
| 117 | <Button | ||
| 118 | type="button" | ||
| 119 | size="sm" | ||
| 120 | className="h-7 px-2 text-[11px]" | ||
| 121 | onClick={() => onAddCatalog(item)} | ||
| 122 | disabled={Boolean(addingCatalogId)} | ||
| 123 | > | ||
| 124 | {addingCatalogId === item.id ? "Adding..." : "Add"} | ||
| 125 | </Button> | ||
| 126 | </div> | ||
| 127 | </div> | ||
| 128 | ))} | ||
| 129 | |||
| 130 | {visibleCatalogItems.length === 0 && ( | ||
| 131 | <p className="text-[11px] text-muted-foreground px-1 py-1"> | ||
| 132 | No collections found for this query. | ||
| 133 | </p> | ||
| 134 | )} | ||
| 135 | </div> | ||
| 136 | </div> | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | |||
| 140 | export function CustomViewSection({ | ||
| 141 | type, | ||
| 142 | onTypeChange, | ||
| 143 | endpoint, | ||
| 144 | onEndpointChange, | ||
| 145 | name, | ||
| 146 | onNameChange, | ||
| 147 | baseUrl, | ||
| 148 | onBaseUrlChange, | ||
| 149 | mcpTransport, | ||
| 150 | onMcpTransportChange, | ||
| 151 | mcpActorQueryParamKey, | ||
| 152 | onMcpActorQueryParamKeyChange, | ||
| 153 | submitting, | ||
| 154 | submitDisabled, | ||
| 155 | onBackToCatalog, | ||
| 156 | onSubmit, | ||
| 157 | }: { | ||
| 158 | type: SourceType; | ||
| 159 | onTypeChange: (value: SourceType) => void; | ||
| 160 | endpoint: string; | ||
| 161 | onEndpointChange: (value: string) => void; | ||
| 162 | name: string; | ||
| 163 | onNameChange: (value: string) => void; | ||
| 164 | baseUrl: string; | ||
| 165 | onBaseUrlChange: (value: string) => void; | ||
| 166 | mcpTransport: "auto" | "streamable-http" | "sse"; | ||
| 167 | onMcpTransportChange: (value: "auto" | "streamable-http" | "sse") => void; | ||
| 168 | mcpActorQueryParamKey: string; | ||
| 169 | onMcpActorQueryParamKeyChange: (value: string) => void; | ||
| 170 | submitting: boolean; | ||
| 171 | submitDisabled: boolean; | ||
| 172 | onBackToCatalog: () => void; | ||
| 173 | onSubmit: () => void; | ||
| 174 | }) { | ||
| 175 | return ( | ||
| 176 | <div className="space-y-3"> | ||
| 177 | <Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onBackToCatalog}> | ||
| 178 | <ChevronRight className="h-3.5 w-3.5 mr-1 rotate-180" /> | ||
| 179 | Back to API list | ||
| 180 | </Button> | ||
| 181 | |||
| 182 | <div className="space-y-1.5"> | ||
| 183 | <Label className="text-xs text-muted-foreground">Type</Label> | ||
| 184 | <Select value={type} onValueChange={(value) => onTypeChange(value as SourceType)}> | ||
| 185 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 186 | <SelectValue /> | ||
| 187 | </SelectTrigger> | ||
| 188 | <SelectContent> | ||
| 189 | <SelectItem value="mcp" className="text-xs">MCP Server</SelectItem> | ||
| 190 | <SelectItem value="openapi" className="text-xs">OpenAPI Spec</SelectItem> | ||
| 191 | <SelectItem value="graphql" className="text-xs">GraphQL</SelectItem> | ||
| 192 | </SelectContent> | ||
| 193 | </Select> | ||
| 194 | </div> | ||
| 195 | |||
| 196 | <div className="space-y-1.5"> | ||
| 197 | <Label className="text-xs text-muted-foreground">{endpointLabelForType(type)}</Label> | ||
| 198 | <Input | ||
| 199 | value={endpoint} | ||
| 200 | onChange={(event) => onEndpointChange(event.target.value)} | ||
| 201 | placeholder={endpointPlaceholderForType(type)} | ||
| 202 | className="h-8 text-xs font-mono bg-background" | ||
| 203 | /> | ||
| 204 | </div> | ||
| 205 | |||
| 206 | <div className="space-y-1.5"> | ||
| 207 | <Label className="text-xs text-muted-foreground">Name</Label> | ||
| 208 | <Input | ||
| 209 | value={name} | ||
| 210 | onChange={(event) => onNameChange(event.target.value)} | ||
| 211 | placeholder="e.g. my-service" | ||
| 212 | className="h-8 text-xs font-mono bg-background" | ||
| 213 | /> | ||
| 214 | </div> | ||
| 215 | |||
| 216 | {type === "openapi" && ( | ||
| 217 | <div className="space-y-1.5"> | ||
| 218 | <Label className="text-xs text-muted-foreground">Base URL (optional)</Label> | ||
| 219 | <Input | ||
| 220 | value={baseUrl} | ||
| 221 | onChange={(event) => onBaseUrlChange(event.target.value)} | ||
| 222 | placeholder="https://api.example.com" | ||
| 223 | className="h-8 text-xs font-mono bg-background" | ||
| 224 | /> | ||
| 225 | </div> | ||
| 226 | )} | ||
| 227 | |||
| 228 | {type === "mcp" && ( | ||
| 229 | <> | ||
| 230 | <div className="space-y-1.5"> | ||
| 231 | <Label className="text-xs text-muted-foreground">Transport</Label> | ||
| 232 | <Select | ||
| 233 | value={mcpTransport} | ||
| 234 | onValueChange={(value) => onMcpTransportChange(value as "auto" | "streamable-http" | "sse")} | ||
| 235 | > | ||
| 236 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 237 | <SelectValue /> | ||
| 238 | </SelectTrigger> | ||
| 239 | <SelectContent> | ||
| 240 | <SelectItem value="auto" className="text-xs">Auto (streamable, then SSE)</SelectItem> | ||
| 241 | <SelectItem value="streamable-http" className="text-xs">Streamable HTTP</SelectItem> | ||
| 242 | <SelectItem value="sse" className="text-xs">SSE</SelectItem> | ||
| 243 | </SelectContent> | ||
| 244 | </Select> | ||
| 245 | </div> | ||
| 246 | |||
| 247 | <div className="space-y-1.5"> | ||
| 248 | <Label className="text-xs text-muted-foreground">Anon actor query key (optional)</Label> | ||
| 249 | <Input | ||
| 250 | value={mcpActorQueryParamKey} | ||
| 251 | onChange={(event) => onMcpActorQueryParamKeyChange(event.target.value)} | ||
| 252 | placeholder="userId" | ||
| 253 | className="h-8 text-xs font-mono bg-background" | ||
| 254 | /> | ||
| 255 | </div> | ||
| 256 | </> | ||
| 257 | )} | ||
| 258 | |||
| 259 | <Button onClick={onSubmit} disabled={submitDisabled} className="w-full h-9" size="sm"> | ||
| 260 | {submitting ? "Adding..." : "Add Source"} | ||
| 261 | </Button> | ||
| 262 | </div> | ||
| 263 | ); | ||
| 264 | } | ||
| @@ -0,0 +1,242 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useMemo, useState } from "react"; | ||
| 4 | import { Plus } from "lucide-react"; | ||
| 5 | import { useMutation } from "convex/react"; | ||
| 6 | import { toast } from "sonner"; | ||
| 7 | import { Button } from "@/components/ui/button"; | ||
| 8 | import { | ||
| 9 | Dialog, | ||
| 10 | DialogContent, | ||
| 11 | DialogHeader, | ||
| 12 | DialogTitle, | ||
| 13 | DialogTrigger, | ||
| 14 | } from "@/components/ui/dialog"; | ||
| 15 | import { useSession } from "@/lib/session-context"; | ||
| 16 | import { convexApi } from "@/lib/convex-api"; | ||
| 17 | import type { ToolSourceRecord } from "@/lib/types"; | ||
| 18 | import { | ||
| 19 | type CatalogCollectionItem, | ||
| 20 | } from "@/lib/catalog-collections"; | ||
| 21 | import { | ||
| 22 | catalogSourceName, | ||
| 23 | inferNameFromUrl, | ||
| 24 | withUniqueSourceName, | ||
| 25 | } from "@/lib/tools-source-helpers"; | ||
| 26 | import { | ||
| 27 | createCustomSourceConfig, | ||
| 28 | DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY, | ||
| 29 | getVisibleCatalogItems, | ||
| 30 | type SourceCatalogSort, | ||
| 31 | type SourceType, | ||
| 32 | } from "./tools-view-add-source-dialog-helpers"; | ||
| 33 | import { | ||
| 34 | CatalogViewSection, | ||
| 35 | CustomViewSection, | ||
| 36 | } from "./tools-view-add-source-dialog-sections"; | ||
| 37 | |||
| 38 | export function AddSourceDialog({ | ||
| 39 | existingSourceNames, | ||
| 40 | onSourceAdded, | ||
| 41 | }: { | ||
| 42 | existingSourceNames: Set<string>; | ||
| 43 | onSourceAdded?: (source: ToolSourceRecord) => void; | ||
| 44 | }) { | ||
| 45 | const { context } = useSession(); | ||
| 46 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | ||
| 47 | const [open, setOpen] = useState(false); | ||
| 48 | const [view, setView] = useState<"catalog" | "custom">("catalog"); | ||
| 49 | const [type, setType] = useState<SourceType>("mcp"); | ||
| 50 | const [name, setName] = useState(""); | ||
| 51 | const [nameManuallyEdited, setNameManuallyEdited] = useState(false); | ||
| 52 | const [endpoint, setEndpoint] = useState(""); | ||
| 53 | const [baseUrl, setBaseUrl] = useState(""); | ||
| 54 | const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto"); | ||
| 55 | const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState( | ||
| 56 | DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY, | ||
| 57 | ); | ||
| 58 | const [submitting, setSubmitting] = useState(false); | ||
| 59 | const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]); | ||
| 60 | const [catalogQuery, setCatalogQuery] = useState(""); | ||
| 61 | const [catalogSort, setCatalogSort] = useState<SourceCatalogSort>("popular"); | ||
| 62 | const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null); | ||
| 63 | |||
| 64 | const visibleCatalogItems = useMemo( | ||
| 65 | () => getVisibleCatalogItems(catalogQuery, catalogSort), | ||
| 66 | [catalogQuery, catalogSort], | ||
| 67 | ); | ||
| 68 | |||
| 69 | const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]); | ||
| 70 | |||
| 71 | const reserveSourceName = (sourceName: string) => { | ||
| 72 | setLocallyReservedNames((current) => | ||
| 73 | current.includes(sourceName) | ||
| 74 | ? current | ||
| 75 | : [...current, sourceName], | ||
| 76 | ); | ||
| 77 | }; | ||
| 78 | |||
| 79 | const getUniqueAutoSourceName = (candidate: string) => { | ||
| 80 | return withUniqueSourceName(candidate, getTakenSourceNames()); | ||
| 81 | }; | ||
| 82 | |||
| 83 | const handleEndpointChange = (value: string) => { | ||
| 84 | setEndpoint(value); | ||
| 85 | if (!nameManuallyEdited) { | ||
| 86 | const inferred = inferNameFromUrl(value); | ||
| 87 | if (inferred) { | ||
| 88 | setName(inferred); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | }; | ||
| 92 | |||
| 93 | const handleNameChange = (value: string) => { | ||
| 94 | setName(value); | ||
| 95 | setNameManuallyEdited(true); | ||
| 96 | }; | ||
| 97 | |||
| 98 | const resetDialogState = () => { | ||
| 99 | setView("catalog"); | ||
| 100 | setType("mcp"); | ||
| 101 | setName(""); | ||
| 102 | setEndpoint(""); | ||
| 103 | setBaseUrl(""); | ||
| 104 | setMcpTransport("auto"); | ||
| 105 | setMcpActorQueryParamKey(DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY); | ||
| 106 | setNameManuallyEdited(false); | ||
| 107 | setLocallyReservedNames([]); | ||
| 108 | setCatalogQuery(""); | ||
| 109 | setCatalogSort("popular"); | ||
| 110 | setAddingCatalogId(null); | ||
| 111 | }; | ||
| 112 | |||
| 113 | const handleOpenChange = (isOpen: boolean) => { | ||
| 114 | setOpen(isOpen); | ||
| 115 | if (!isOpen) { | ||
| 116 | resetDialogState(); | ||
| 117 | } | ||
| 118 | }; | ||
| 119 | |||
| 120 | const addSource = async ( | ||
| 121 | sourceName: string, | ||
| 122 | sourceType: "mcp" | "openapi" | "graphql", | ||
| 123 | config: Record<string, unknown>, | ||
| 124 | ) => { | ||
| 125 | if (!context) { | ||
| 126 | return; | ||
| 127 | } | ||
| 128 | const created = await upsertToolSource({ | ||
| 129 | workspaceId: context.workspaceId, | ||
| 130 | sessionId: context.sessionId, | ||
| 131 | name: sourceName, | ||
| 132 | type: sourceType, | ||
| 133 | config, | ||
| 134 | }); | ||
| 135 | onSourceAdded?.(created as ToolSourceRecord); | ||
| 136 | toast.success(`Source "${sourceName}" added — loading tools…`); | ||
| 137 | }; | ||
| 138 | |||
| 139 | const handleCatalogAdd = async (item: CatalogCollectionItem) => { | ||
| 140 | if (!item.specUrl.trim()) { | ||
| 141 | toast.error("Missing OpenAPI spec URL for this API source"); | ||
| 142 | return; | ||
| 143 | } | ||
| 144 | |||
| 145 | setAddingCatalogId(item.id); | ||
| 146 | try { | ||
| 147 | const sourceName = getUniqueAutoSourceName(catalogSourceName(item)); | ||
| 148 | await addSource(sourceName, "openapi", { | ||
| 149 | spec: item.specUrl, | ||
| 150 | }); | ||
| 151 | reserveSourceName(sourceName); | ||
| 152 | } catch (error) { | ||
| 153 | toast.error(error instanceof Error ? error.message : "Failed to add API source"); | ||
| 154 | } finally { | ||
| 155 | setAddingCatalogId(null); | ||
| 156 | } | ||
| 157 | }; | ||
| 158 | |||
| 159 | const handleCustomSubmit = async () => { | ||
| 160 | if (!context || !name.trim() || !endpoint.trim()) { | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | |||
| 164 | const takenNames = [...getTakenSourceNames()].map((entry) => entry.toLowerCase()); | ||
| 165 | if (takenNames.includes(name.trim().toLowerCase())) { | ||
| 166 | toast.error(`Source name "${name.trim()}" already exists`); | ||
| 167 | return; | ||
| 168 | } | ||
| 169 | |||
| 170 | setSubmitting(true); | ||
| 171 | try { | ||
| 172 | const config = createCustomSourceConfig({ | ||
| 173 | type, | ||
| 174 | endpoint, | ||
| 175 | baseUrl, | ||
| 176 | mcpTransport, | ||
| 177 | mcpActorQueryParamKey, | ||
| 178 | actorId: context.actorId, | ||
| 179 | }); | ||
| 180 | await addSource(name.trim(), type, config); | ||
| 181 | reserveSourceName(name.trim()); | ||
| 182 | resetDialogState(); | ||
| 183 | setOpen(false); | ||
| 184 | } catch (err) { | ||
| 185 | toast.error(err instanceof Error ? err.message : "Failed to add source"); | ||
| 186 | } finally { | ||
| 187 | setSubmitting(false); | ||
| 188 | } | ||
| 189 | }; | ||
| 190 | |||
| 191 | return ( | ||
| 192 | <Dialog open={open} onOpenChange={handleOpenChange}> | ||
| 193 | <DialogTrigger asChild> | ||
| 194 | <Button size="sm" className="h-8 text-xs"> | ||
| 195 | <Plus className="h-3.5 w-3.5 mr-1.5" /> | ||
| 196 | Add Source | ||
| 197 | </Button> | ||
| 198 | </DialogTrigger> | ||
| 199 | <DialogContent className="bg-card border-border sm:max-w-md p-0 gap-0"> | ||
| 200 | <DialogHeader className="px-5 pt-5 pb-0"> | ||
| 201 | <DialogTitle className="text-sm font-medium"> | ||
| 202 | Add Tool Source | ||
| 203 | </DialogTitle> | ||
| 204 | </DialogHeader> | ||
| 205 | |||
| 206 | <div className="p-5 space-y-4"> | ||
| 207 | {view === "catalog" ? ( | ||
| 208 | <CatalogViewSection | ||
| 209 | catalogQuery={catalogQuery} | ||
| 210 | onCatalogQueryChange={setCatalogQuery} | ||
| 211 | catalogSort={catalogSort} | ||
| 212 | onCatalogSortChange={setCatalogSort} | ||
| 213 | visibleCatalogItems={visibleCatalogItems} | ||
| 214 | addingCatalogId={addingCatalogId} | ||
| 215 | onSwitchToCustom={() => setView("custom")} | ||
| 216 | onAddCatalog={(item) => void handleCatalogAdd(item)} | ||
| 217 | /> | ||
| 218 | ) : ( | ||
| 219 | <CustomViewSection | ||
| 220 | type={type} | ||
| 221 | onTypeChange={setType} | ||
| 222 | endpoint={endpoint} | ||
| 223 | onEndpointChange={handleEndpointChange} | ||
| 224 | name={name} | ||
| 225 | onNameChange={handleNameChange} | ||
| 226 | baseUrl={baseUrl} | ||
| 227 | onBaseUrlChange={setBaseUrl} | ||
| 228 | mcpTransport={mcpTransport} | ||
| 229 | onMcpTransportChange={setMcpTransport} | ||
| 230 | mcpActorQueryParamKey={mcpActorQueryParamKey} | ||
| 231 | onMcpActorQueryParamKeyChange={setMcpActorQueryParamKey} | ||
| 232 | submitting={submitting} | ||
| 233 | submitDisabled={submitting || !name.trim() || !endpoint.trim()} | ||
| 234 | onBackToCatalog={() => setView("catalog")} | ||
| 235 | onSubmit={handleCustomSubmit} | ||
| 236 | /> | ||
| 237 | )} | ||
| 238 | </div> | ||
| 239 | </DialogContent> | ||
| 240 | </Dialog> | ||
| 241 | ); | ||
| 242 | } | ||
| @@ -0,0 +1,101 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { Input } from "@/components/ui/input"; | ||
| 4 | import { Label } from "@/components/ui/label"; | ||
| 5 | import type { SourceAuthType } from "@/lib/types"; | ||
| 6 | import type { ConnectionMode } from "./tools-view-connection-form-dialog-state"; | ||
| 7 | |||
| 8 | type SelectedAuth = { | ||
| 9 | type: SourceAuthType; | ||
| 10 | }; | ||
| 11 | |||
| 12 | type ConnectionAuthFieldsProps = { | ||
| 13 | editing: boolean; | ||
| 14 | connectionMode: ConnectionMode; | ||
| 15 | selectedAuth: SelectedAuth; | ||
| 16 | tokenValue: string; | ||
| 17 | apiKeyValue: string; | ||
| 18 | basicUsername: string; | ||
| 19 | basicPassword: string; | ||
| 20 | onTokenValueChange: (value: string) => void; | ||
| 21 | onApiKeyValueChange: (value: string) => void; | ||
| 22 | onBasicUsernameChange: (value: string) => void; | ||
| 23 | onBasicPasswordChange: (value: string) => void; | ||
| 24 | }; | ||
| 25 | |||
| 26 | export function ConnectionAuthFields({ | ||
| 27 | editing, | ||
| 28 | connectionMode, | ||
| 29 | selectedAuth, | ||
| 30 | tokenValue, | ||
| 31 | apiKeyValue, | ||
| 32 | basicUsername, | ||
| 33 | basicPassword, | ||
| 34 | onTokenValueChange, | ||
| 35 | onApiKeyValueChange, | ||
| 36 | onBasicUsernameChange, | ||
| 37 | onBasicPasswordChange, | ||
| 38 | }: ConnectionAuthFieldsProps) { | ||
| 39 | if (!editing && connectionMode !== "new") { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | |||
| 43 | return ( | ||
| 44 | <> | ||
| 45 | {editing && ( | ||
| 46 | <p className="text-[11px] text-muted-foreground"> | ||
| 47 | Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it. | ||
| 48 | </p> | ||
| 49 | )} | ||
| 50 | |||
| 51 | {selectedAuth.type === "none" ? ( | ||
| 52 | <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p> | ||
| 53 | ) : selectedAuth.type === "mixed" ? ( | ||
| 54 | <p className="text-[11px] text-terminal-amber"> | ||
| 55 | This source has mixed auth requirements. Link an existing connection for now. | ||
| 56 | </p> | ||
| 57 | ) : selectedAuth.type === "apiKey" ? ( | ||
| 58 | <div className="space-y-1.5"> | ||
| 59 | <Label className="text-xs text-muted-foreground">API Key Value</Label> | ||
| 60 | <Input | ||
| 61 | value={apiKeyValue} | ||
| 62 | onChange={(e) => onApiKeyValueChange(e.target.value)} | ||
| 63 | placeholder="sk_live_..." | ||
| 64 | className="h-8 text-xs font-mono bg-background" | ||
| 65 | /> | ||
| 66 | </div> | ||
| 67 | ) : selectedAuth.type === "basic" ? ( | ||
| 68 | <div className="grid grid-cols-2 gap-3"> | ||
| 69 | <div className="space-y-1.5"> | ||
| 70 | <Label className="text-xs text-muted-foreground">Username</Label> | ||
| 71 | <Input | ||
| 72 | value={basicUsername} | ||
| 73 | onChange={(e) => onBasicUsernameChange(e.target.value)} | ||
| 74 | className="h-8 text-xs font-mono bg-background" | ||
| 75 | /> | ||
| 76 | </div> | ||
| 77 | <div className="space-y-1.5"> | ||
| 78 | <Label className="text-xs text-muted-foreground">Password</Label> | ||
| 79 | <Input | ||
| 80 | type="password" | ||
| 81 | value={basicPassword} | ||
| 82 | onChange={(e) => onBasicPasswordChange(e.target.value)} | ||
| 83 | className="h-8 text-xs font-mono bg-background" | ||
| 84 | /> | ||
| 85 | </div> | ||
| 86 | </div> | ||
| 87 | ) : ( | ||
| 88 | <div className="space-y-1.5"> | ||
| 89 | <Label className="text-xs text-muted-foreground">Bearer Token</Label> | ||
| 90 | <Input | ||
| 91 | type="password" | ||
| 92 | value={tokenValue} | ||
| 93 | onChange={(e) => onTokenValueChange(e.target.value)} | ||
| 94 | placeholder="ghp_..." | ||
| 95 | className="h-8 text-xs font-mono bg-background" | ||
| 96 | /> | ||
| 97 | </div> | ||
| 98 | )} | ||
| 99 | </> | ||
| 100 | ); | ||
| 101 | } | ||
| @@ -0,0 +1,257 @@ | |||
| 1 | import { useEffect, useMemo, useReducer } from "react"; | ||
| 2 | import type { | ||
| 3 | CredentialRecord, | ||
| 4 | CredentialScope, | ||
| 5 | SourceAuthProfile, | ||
| 6 | ToolSourceRecord, | ||
| 7 | } from "@/lib/types"; | ||
| 8 | import { | ||
| 9 | formatHeaderOverrides, | ||
| 10 | sourceAuthForKey, | ||
| 11 | } from "@/lib/credentials-source-helpers"; | ||
| 12 | import { | ||
| 13 | buildConnectionOptions, | ||
| 14 | buildSourceOptions, | ||
| 15 | compatibleConnections, | ||
| 16 | selectedAuthBadge, | ||
| 17 | type ConnectionMode, | ||
| 18 | } from "./tools-view-connection-form-dialog-state"; | ||
| 19 | |||
| 20 | type UseConnectionFormDialogFormParams = { | ||
| 21 | open: boolean; | ||
| 22 | editing: CredentialRecord | null; | ||
| 23 | initialSourceKey?: string | null; | ||
| 24 | sources: ToolSourceRecord[]; | ||
| 25 | credentials: CredentialRecord[]; | ||
| 26 | sourceAuthProfiles: Record<string, SourceAuthProfile>; | ||
| 27 | actorIdFallback?: string; | ||
| 28 | }; | ||
| 29 | |||
| 30 | type FormState = { | ||
| 31 | sourceKey: string; | ||
| 32 | scope: CredentialScope; | ||
| 33 | actorId: string; | ||
| 34 | connectionMode: ConnectionMode; | ||
| 35 | existingConnectionId: string; | ||
| 36 | tokenValue: string; | ||
| 37 | apiKeyValue: string; | ||
| 38 | basicUsername: string; | ||
| 39 | basicPassword: string; | ||
| 40 | customHeadersText: string; | ||
| 41 | }; | ||
| 42 | |||
| 43 | type FormAction = | ||
| 44 | | { type: "patch"; patch: Partial<FormState> } | ||
| 45 | | { type: "reset"; next: FormState }; | ||
| 46 | |||
| 47 | function formReducer(state: FormState, action: FormAction): FormState { | ||
| 48 | if (action.type === "reset") { | ||
| 49 | return action.next; | ||
| 50 | } | ||
| 51 | return { ...state, ...action.patch }; | ||
| 52 | } | ||
| 53 | |||
| 54 | function initialFormState({ | ||
| 55 | editing, | ||
| 56 | initialSourceKey, | ||
| 57 | sourceOptions, | ||
| 58 | sourceAuthProfiles, | ||
| 59 | actorIdFallback, | ||
| 60 | }: { | ||
| 61 | editing: CredentialRecord | null; | ||
| 62 | initialSourceKey?: string | null; | ||
| 63 | sourceOptions: ReturnType<typeof buildSourceOptions>; | ||
| 64 | sourceAuthProfiles: Record<string, SourceAuthProfile>; | ||
| 65 | actorIdFallback?: string; | ||
| 66 | }): FormState { | ||
| 67 | if (editing) { | ||
| 68 | return { | ||
| 69 | sourceKey: editing.sourceKey, | ||
| 70 | scope: editing.scope, | ||
| 71 | actorId: editing.actorId ?? actorIdFallback ?? "", | ||
| 72 | connectionMode: "new", | ||
| 73 | existingConnectionId: editing.id, | ||
| 74 | tokenValue: "", | ||
| 75 | apiKeyValue: "", | ||
| 76 | basicUsername: "", | ||
| 77 | basicPassword: "", | ||
| 78 | customHeadersText: formatHeaderOverrides(editing.overridesJson), | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | const resolvedSourceKey = initialSourceKey ?? sourceOptions[0]?.key ?? ""; | ||
| 83 | const auth = sourceAuthForKey(sourceOptions, resolvedSourceKey, sourceAuthProfiles); | ||
| 84 | return { | ||
| 85 | sourceKey: resolvedSourceKey, | ||
| 86 | scope: auth.mode ?? "workspace", | ||
| 87 | actorId: actorIdFallback ?? "", | ||
| 88 | connectionMode: "new", | ||
| 89 | existingConnectionId: "", | ||
| 90 | tokenValue: "", | ||
| 91 | apiKeyValue: "", | ||
| 92 | basicUsername: "", | ||
| 93 | basicPassword: "", | ||
| 94 | customHeadersText: "", | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | export function useConnectionFormDialogForm({ | ||
| 99 | open, | ||
| 100 | editing, | ||
| 101 | initialSourceKey, | ||
| 102 | sources, | ||
| 103 | credentials, | ||
| 104 | sourceAuthProfiles, | ||
| 105 | actorIdFallback, | ||
| 106 | }: UseConnectionFormDialogFormParams) { | ||
| 107 | const sourceOptions = useMemo(() => buildSourceOptions(sources), [sources]); | ||
| 108 | const connectionOptions = useMemo(() => buildConnectionOptions(credentials), [credentials]); | ||
| 109 | const [form, dispatch] = useReducer( | ||
| 110 | formReducer, | ||
| 111 | initialFormState({ | ||
| 112 | editing, | ||
| 113 | initialSourceKey, | ||
| 114 | sourceOptions, | ||
| 115 | sourceAuthProfiles, | ||
| 116 | actorIdFallback, | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | |||
| 120 | const { | ||
| 121 | sourceKey, | ||
| 122 | scope, | ||
| 123 | actorId, | ||
| 124 | connectionMode, | ||
| 125 | existingConnectionId, | ||
| 126 | tokenValue, | ||
| 127 | apiKeyValue, | ||
| 128 | basicUsername, | ||
| 129 | basicPassword, | ||
| 130 | customHeadersText, | ||
| 131 | } = form; | ||
| 132 | const compatibleConnectionOptions = useMemo( | ||
| 133 | () => compatibleConnections(connectionOptions, scope, actorId), | ||
| 134 | [actorId, connectionOptions, scope], | ||
| 135 | ); | ||
| 136 | const selectedAuth = useMemo( | ||
| 137 | () => sourceAuthForKey(sourceOptions, sourceKey, sourceAuthProfiles), | ||
| 138 | [sourceAuthProfiles, sourceKey, sourceOptions], | ||
| 139 | ); | ||
| 140 | const authBadge = useMemo( | ||
| 141 | () => selectedAuthBadge(selectedAuth.type, selectedAuth.mode), | ||
| 142 | [selectedAuth.mode, selectedAuth.type], | ||
| 143 | ); | ||
| 144 | |||
| 145 | useEffect(() => { | ||
| 146 | if (!existingConnectionId) { | ||
| 147 | return; | ||
| 148 | } | ||
| 149 | if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { | ||
| 150 | dispatch({ type: "patch", patch: { existingConnectionId: "" } }); | ||
| 151 | } | ||
| 152 | }, [compatibleConnectionOptions, existingConnectionId]); | ||
| 153 | |||
| 154 | useEffect(() => { | ||
| 155 | if (!open) { | ||
| 156 | return; | ||
| 157 | } | ||
| 158 | |||
| 159 | if (editing) { | ||
| 160 | dispatch({ | ||
| 161 | type: "reset", | ||
| 162 | next: initialFormState({ | ||
| 163 | editing, | ||
| 164 | initialSourceKey, | ||
| 165 | sourceOptions, | ||
| 166 | sourceAuthProfiles, | ||
| 167 | actorIdFallback, | ||
| 168 | }), | ||
| 169 | }); | ||
| 170 | return; | ||
| 171 | } | ||
| 172 | |||
| 173 | dispatch({ | ||
| 174 | type: "reset", | ||
| 175 | next: initialFormState({ | ||
| 176 | editing: null, | ||
| 177 | initialSourceKey, | ||
| 178 | sourceOptions, | ||
| 179 | sourceAuthProfiles, | ||
| 180 | actorIdFallback, | ||
| 181 | }), | ||
| 182 | }); | ||
| 183 | }, [actorIdFallback, editing, initialSourceKey, open, sourceAuthProfiles, sourceOptions]); | ||
| 184 | |||
| 185 | const handleSourceKeyChange = (nextSourceKey: string) => { | ||
| 186 | const patch: Partial<FormState> = { sourceKey: nextSourceKey }; | ||
| 187 | const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles); | ||
| 188 | if (!editing) { | ||
| 189 | patch.scope = auth.mode ?? "workspace"; | ||
| 190 | } | ||
| 191 | dispatch({ type: "patch", patch }); | ||
| 192 | }; | ||
| 193 | |||
| 194 | const setScope = (nextScope: CredentialScope) => { | ||
| 195 | dispatch({ type: "patch", patch: { scope: nextScope } }); | ||
| 196 | }; | ||
| 197 | |||
| 198 | const setActorId = (nextActorId: string) => { | ||
| 199 | dispatch({ type: "patch", patch: { actorId: nextActorId } }); | ||
| 200 | }; | ||
| 201 | |||
| 202 | const setConnectionMode = (nextMode: ConnectionMode) => { | ||
| 203 | dispatch({ type: "patch", patch: { connectionMode: nextMode } }); | ||
| 204 | }; | ||
| 205 | |||
| 206 | const setExistingConnectionId = (nextConnectionId: string) => { | ||
| 207 | dispatch({ type: "patch", patch: { existingConnectionId: nextConnectionId } }); | ||
| 208 | }; | ||
| 209 | |||
| 210 | const setTokenValue = (nextToken: string) => { | ||
| 211 | dispatch({ type: "patch", patch: { tokenValue: nextToken } }); | ||
| 212 | }; | ||
| 213 | |||
| 214 | const setApiKeyValue = (nextApiKeyValue: string) => { | ||
| 215 | dispatch({ type: "patch", patch: { apiKeyValue: nextApiKeyValue } }); | ||
| 216 | }; | ||
| 217 | |||
| 218 | const setBasicUsername = (nextBasicUsername: string) => { | ||
| 219 | dispatch({ type: "patch", patch: { basicUsername: nextBasicUsername } }); | ||
| 220 | }; | ||
| 221 | |||
| 222 | const setBasicPassword = (nextBasicPassword: string) => { | ||
| 223 | dispatch({ type: "patch", patch: { basicPassword: nextBasicPassword } }); | ||
| 224 | }; | ||
| 225 | |||
| 226 | const setCustomHeadersText = (nextCustomHeadersText: string) => { | ||
| 227 | dispatch({ type: "patch", patch: { customHeadersText: nextCustomHeadersText } }); | ||
| 228 | }; | ||
| 229 | |||
| 230 | return { | ||
| 231 | sourceKey, | ||
| 232 | scope, | ||
| 233 | actorId, | ||
| 234 | connectionMode, | ||
| 235 | existingConnectionId, | ||
| 236 | tokenValue, | ||
| 237 | apiKeyValue, | ||
| 238 | basicUsername, | ||
| 239 | basicPassword, | ||
| 240 | customHeadersText, | ||
| 241 | sourceOptions, | ||
| 242 | connectionOptions, | ||
| 243 | compatibleConnectionOptions, | ||
| 244 | selectedAuth, | ||
| 245 | authBadge, | ||
| 246 | setScope, | ||
| 247 | setActorId, | ||
| 248 | setConnectionMode, | ||
| 249 | setExistingConnectionId, | ||
| 250 | setTokenValue, | ||
| 251 | setApiKeyValue, | ||
| 252 | setBasicUsername, | ||
| 253 | setBasicPassword, | ||
| 254 | setCustomHeadersText, | ||
| 255 | handleSourceKeyChange, | ||
| 256 | }; | ||
| 257 | } | ||
| @@ -0,0 +1,82 @@ | |||
| 1 | import type { | ||
| 2 | CredentialRecord, | ||
| 3 | CredentialScope, | ||
| 4 | SourceAuthType, | ||
| 5 | ToolSourceRecord, | ||
| 6 | } from "@/lib/types"; | ||
| 7 | import { | ||
| 8 | sourceOptionLabel, | ||
| 9 | type SourceOption, | ||
| 10 | } from "@/lib/credentials-source-helpers"; | ||
| 11 | import { sourceKeyForSource } from "@/lib/tools-source-helpers"; | ||
| 12 | |||
| 13 | export type ConnectionMode = "new" | "existing"; | ||
| 14 | |||
| 15 | export type ConnectionOption = { | ||
| 16 | id: string; | ||
| 17 | scope: CredentialScope; | ||
| 18 | actorId?: string; | ||
| 19 | sourceKeys: Set<string>; | ||
| 20 | updatedAt: number; | ||
| 21 | }; | ||
| 22 | |||
| 23 | export function buildSourceOptions(sources: ToolSourceRecord[]): SourceOption[] { | ||
| 24 | return sources | ||
| 25 | .map((source) => { | ||
| 26 | const key = sourceKeyForSource(source); | ||
| 27 | return { | ||
| 28 | source, | ||
| 29 | key, | ||
| 30 | label: sourceOptionLabel(source), | ||
| 31 | }; | ||
| 32 | }) | ||
| 33 | .filter((entry): entry is SourceOption => entry.key !== null); | ||
| 34 | } | ||
| 35 | |||
| 36 | export function buildConnectionOptions(credentials: CredentialRecord[]): ConnectionOption[] { | ||
| 37 | const grouped = new Map<string, ConnectionOption>(); | ||
| 38 | |||
| 39 | for (const credential of credentials) { | ||
| 40 | const existing = grouped.get(credential.id); | ||
| 41 | if (existing) { | ||
| 42 | existing.sourceKeys.add(credential.sourceKey); | ||
| 43 | existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt); | ||
| 44 | } else { | ||
| 45 | grouped.set(credential.id, { | ||
| 46 | id: credential.id, | ||
| 47 | scope: credential.scope, | ||
| 48 | actorId: credential.actorId, | ||
| 49 | sourceKeys: new Set([credential.sourceKey]), | ||
| 50 | updatedAt: credential.updatedAt, | ||
| 51 | }); | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt); | ||
| 56 | } | ||
| 57 | |||
| 58 | export function compatibleConnections( | ||
| 59 | options: ConnectionOption[], | ||
| 60 | scope: CredentialScope, | ||
| 61 | actorId: string, | ||
| 62 | ): ConnectionOption[] { | ||
| 63 | return options.filter((connection) => { | ||
| 64 | if (connection.scope !== scope) { | ||
| 65 | return false; | ||
| 66 | } | ||
| 67 | if (scope === "actor") { | ||
| 68 | return connection.actorId === actorId.trim(); | ||
| 69 | } | ||
| 70 | return true; | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | |||
| 74 | export function selectedAuthBadge(type: SourceAuthType, mode?: CredentialScope): string { | ||
| 75 | if (type === "none") { | ||
| 76 | return "none"; | ||
| 77 | } | ||
| 78 | if (type === "mixed") { | ||
| 79 | return "mixed"; | ||
| 80 | } | ||
| 81 | return `${type}:${mode ?? "workspace"}`; | ||
| 82 | } | ||
| @@ -0,0 +1,334 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useState } from "react"; | ||
| 4 | import { useAction } from "convex/react"; | ||
| 5 | import { toast } from "sonner"; | ||
| 6 | import { Button } from "@/components/ui/button"; | ||
| 7 | import { Input } from "@/components/ui/input"; | ||
| 8 | import { Label } from "@/components/ui/label"; | ||
| 9 | import { Textarea } from "@/components/ui/textarea"; | ||
| 10 | import { Badge } from "@/components/ui/badge"; | ||
| 11 | import { | ||
| 12 | Select, | ||
| 13 | SelectContent, | ||
| 14 | SelectItem, | ||
| 15 | SelectTrigger, | ||
| 16 | SelectValue, | ||
| 17 | } from "@/components/ui/select"; | ||
| 18 | import { | ||
| 19 | Dialog, | ||
| 20 | DialogContent, | ||
| 21 | DialogHeader, | ||
| 22 | DialogTitle, | ||
| 23 | } from "@/components/ui/dialog"; | ||
| 24 | import { useSession } from "@/lib/session-context"; | ||
| 25 | import { convexApi } from "@/lib/convex-api"; | ||
| 26 | import type { | ||
| 27 | CredentialRecord, | ||
| 28 | CredentialScope, | ||
| 29 | SourceAuthProfile, | ||
| 30 | ToolSourceRecord, | ||
| 31 | } from "@/lib/types"; | ||
| 32 | import { | ||
| 33 | connectionDisplayName, | ||
| 34 | parseHeaderOverrides, | ||
| 35 | } from "@/lib/credentials-source-helpers"; | ||
| 36 | import { ConnectionAuthFields } from "./tools-view-connection-auth-fields"; | ||
| 37 | import { type ConnectionMode } from "./tools-view-connection-form-dialog-state"; | ||
| 38 | import { useConnectionFormDialogForm } from "./tools-view-connection-form-dialog-form"; | ||
| 39 | import { | ||
| 40 | buildSecretJson, | ||
| 41 | connectionSubmitCopy, | ||
| 42 | connectionSuccessCopy, | ||
| 43 | } from "./tools-view-connection-form-save"; | ||
| 44 | |||
| 45 | export function ConnectionFormDialog({ | ||
| 46 | open, | ||
| 47 | onOpenChange, | ||
| 48 | editing, | ||
| 49 | initialSourceKey, | ||
| 50 | sources, | ||
| 51 | credentials, | ||
| 52 | sourceAuthProfiles, | ||
| 53 | }: { | ||
| 54 | open: boolean; | ||
| 55 | onOpenChange: (open: boolean) => void; | ||
| 56 | editing: CredentialRecord | null; | ||
| 57 | initialSourceKey?: string | null; | ||
| 58 | sources: ToolSourceRecord[]; | ||
| 59 | credentials: CredentialRecord[]; | ||
| 60 | sourceAuthProfiles: Record<string, SourceAuthProfile>; | ||
| 61 | }) { | ||
| 62 | const { context, clientConfig } = useSession(); | ||
| 63 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | ||
| 64 | const [saving, setSaving] = useState(false); | ||
| 65 | const { | ||
| 66 | sourceKey, | ||
| 67 | scope, | ||
| 68 | actorId, | ||
| 69 | connectionMode, | ||
| 70 | existingConnectionId, | ||
| 71 | tokenValue, | ||
| 72 | apiKeyValue, | ||
| 73 | basicUsername, | ||
| 74 | basicPassword, | ||
| 75 | customHeadersText, | ||
| 76 | sourceOptions, | ||
| 77 | connectionOptions, | ||
| 78 | compatibleConnectionOptions, | ||
| 79 | selectedAuth, | ||
| 80 | authBadge, | ||
| 81 | setScope, | ||
| 82 | setActorId, | ||
| 83 | setConnectionMode, | ||
| 84 | setExistingConnectionId, | ||
| 85 | setTokenValue, | ||
| 86 | setApiKeyValue, | ||
| 87 | setBasicUsername, | ||
| 88 | setBasicPassword, | ||
| 89 | setCustomHeadersText, | ||
| 90 | handleSourceKeyChange, | ||
| 91 | } = useConnectionFormDialogForm({ | ||
| 92 | open, | ||
| 93 | editing, | ||
| 94 | initialSourceKey, | ||
| 95 | sources, | ||
| 96 | credentials, | ||
| 97 | sourceAuthProfiles, | ||
| 98 | actorIdFallback: context?.actorId, | ||
| 99 | }); | ||
| 100 | |||
| 101 | const storageCopy = clientConfig?.authProviderMode === "workos" | ||
| 102 | ? "Stored encrypted" | ||
| 103 | : "Stored locally on this machine"; | ||
| 104 | |||
| 105 | const handleSave = async () => { | ||
| 106 | if (!context) { | ||
| 107 | return; | ||
| 108 | } | ||
| 109 | if (!sourceKey.trim()) { | ||
| 110 | toast.error("Source key is required"); | ||
| 111 | return; | ||
| 112 | } | ||
| 113 | if (scope === "actor" && !actorId.trim()) { | ||
| 114 | toast.error("Actor ID is required for actor-scoped credentials"); | ||
| 115 | return; | ||
| 116 | } | ||
| 117 | |||
| 118 | const parsedHeaders = parseHeaderOverrides(customHeadersText); | ||
| 119 | if (!parsedHeaders.value) { | ||
| 120 | toast.error(parsedHeaders.error ?? "Invalid header overrides"); | ||
| 121 | return; | ||
| 122 | } | ||
| 123 | |||
| 124 | const linkExisting = !editing && connectionMode === "existing"; | ||
| 125 | if (linkExisting && !existingConnectionId) { | ||
| 126 | toast.error("Select an existing connection to link"); | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { | ||
| 130 | toast.error("Selected connection does not match this scope"); | ||
| 131 | return; | ||
| 132 | } | ||
| 133 | |||
| 134 | if (selectedAuth.type === "none") { | ||
| 135 | toast.error("This source does not require auth"); | ||
| 136 | return; | ||
| 137 | } | ||
| 138 | |||
| 139 | if (selectedAuth.type === "mixed" && !linkExisting && !editing) { | ||
| 140 | toast.error("Mixed-auth sources must link to an existing connection"); | ||
| 141 | return; | ||
| 142 | } | ||
| 143 | |||
| 144 | const secretResult = buildSecretJson({ | ||
| 145 | selectedAuthType: selectedAuth.type, | ||
| 146 | linkExisting, | ||
| 147 | editing: Boolean(editing), | ||
| 148 | basicUsername, | ||
| 149 | basicPassword, | ||
| 150 | apiKeyValue, | ||
| 151 | tokenValue, | ||
| 152 | parsedHeaders: parsedHeaders.value, | ||
| 153 | }); | ||
| 154 | |||
| 155 | if (!secretResult.secretJson) { | ||
| 156 | toast.error(secretResult.error ?? "Failed to prepare secret payload"); | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | |||
| 160 | setSaving(true); | ||
| 161 | try { | ||
| 162 | await upsertCredential({ | ||
| 163 | ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}), | ||
| 164 | workspaceId: context.workspaceId, | ||
| 165 | sessionId: context.sessionId, | ||
| 166 | sourceKey: sourceKey.trim(), | ||
| 167 | scope, | ||
| 168 | ...(scope === "actor" ? { actorId: actorId.trim() } : {}), | ||
| 169 | secretJson: secretResult.secretJson, | ||
| 170 | }); | ||
| 171 | |||
| 172 | toast.success(connectionSuccessCopy(Boolean(editing), linkExisting)); | ||
| 173 | onOpenChange(false); | ||
| 174 | } catch (err) { | ||
| 175 | toast.error(err instanceof Error ? err.message : "Failed to save connection"); | ||
| 176 | } finally { | ||
| 177 | setSaving(false); | ||
| 178 | } | ||
| 179 | }; | ||
| 180 | |||
| 181 | return ( | ||
| 182 | <Dialog open={open} onOpenChange={onOpenChange}> | ||
| 183 | <DialogContent className="bg-card border-border sm:max-w-lg"> | ||
| 184 | <DialogHeader> | ||
| 185 | <DialogTitle className="text-sm font-medium"> | ||
| 186 | {editing ? "Edit Connection" : "Add Connection"} | ||
| 187 | </DialogTitle> | ||
| 188 | </DialogHeader> | ||
| 189 | <div className="space-y-3"> | ||
| 190 | <div className="space-y-1.5"> | ||
| 191 | <Label className="text-xs text-muted-foreground">Source</Label> | ||
| 192 | {sourceOptions.length > 0 ? ( | ||
| 193 | <Select value={sourceKey} onValueChange={handleSourceKeyChange}> | ||
| 194 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 195 | <SelectValue /> | ||
| 196 | </SelectTrigger> | ||
| 197 | <SelectContent> | ||
| 198 | {sourceOptions.map((entry) => ( | ||
| 199 | <SelectItem key={entry.key} value={entry.key} className="text-xs"> | ||
| 200 | {entry.label} | ||
| 201 | </SelectItem> | ||
| 202 | ))} | ||
| 203 | </SelectContent> | ||
| 204 | </Select> | ||
| 205 | ) : ( | ||
| 206 | <Input | ||
| 207 | value={sourceKey} | ||
| 208 | onChange={(e) => handleSourceKeyChange(e.target.value)} | ||
| 209 | placeholder="source:<source-id>" | ||
| 210 | className="h-8 text-xs font-mono bg-background" | ||
| 211 | /> | ||
| 212 | )} | ||
| 213 | {sourceKey && ( | ||
| 214 | <p className="text-[10px] text-muted-foreground font-mono">key: {sourceKey}</p> | ||
| 215 | )} | ||
| 216 | </div> | ||
| 217 | |||
| 218 | <div className="flex items-center gap-2 flex-wrap"> | ||
| 219 | <span className="text-[11px] text-muted-foreground">Detected auth</span> | ||
| 220 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | ||
| 221 | {authBadge} | ||
| 222 | </Badge> | ||
| 223 | {selectedAuth.inferred && ( | ||
| 224 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | ||
| 225 | inferred | ||
| 226 | </Badge> | ||
| 227 | )} | ||
| 228 | {selectedAuth.type === "apiKey" && selectedAuth.header && ( | ||
| 229 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | ||
| 230 | header: {selectedAuth.header} | ||
| 231 | </span> | ||
| 232 | )} | ||
| 233 | </div> | ||
| 234 | |||
| 235 | <div className="grid grid-cols-2 gap-3"> | ||
| 236 | <div className="space-y-1.5"> | ||
| 237 | <Label className="text-xs text-muted-foreground">Scope</Label> | ||
| 238 | <Select value={scope} onValueChange={(value) => setScope(value as CredentialScope)}> | ||
| 239 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 240 | <SelectValue /> | ||
| 241 | </SelectTrigger> | ||
| 242 | <SelectContent> | ||
| 243 | <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | ||
| 244 | <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem> | ||
| 245 | </SelectContent> | ||
| 246 | </Select> | ||
| 247 | </div> | ||
| 248 | <div className="space-y-1.5"> | ||
| 249 | <Label className="text-xs text-muted-foreground">Storage</Label> | ||
| 250 | <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" /> | ||
| 251 | </div> | ||
| 252 | </div> | ||
| 253 | |||
| 254 | {scope === "actor" && ( | ||
| 255 | <div className="space-y-1.5"> | ||
| 256 | <Label className="text-xs text-muted-foreground">Actor ID</Label> | ||
| 257 | <Input | ||
| 258 | value={actorId} | ||
| 259 | onChange={(e) => setActorId(e.target.value)} | ||
| 260 | placeholder="actor_123" | ||
| 261 | className="h-8 text-xs font-mono bg-background" | ||
| 262 | /> | ||
| 263 | </div> | ||
| 264 | )} | ||
| 265 | |||
| 266 | {!editing && connectionOptions.length > 0 && ( | ||
| 267 | <div className="space-y-1.5"> | ||
| 268 | <Label className="text-xs text-muted-foreground">Connection Mode</Label> | ||
| 269 | <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as ConnectionMode)}> | ||
| 270 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 271 | <SelectValue /> | ||
| 272 | </SelectTrigger> | ||
| 273 | <SelectContent> | ||
| 274 | <SelectItem value="new" className="text-xs">Create new connection</SelectItem> | ||
| 275 | <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem> | ||
| 276 | </SelectContent> | ||
| 277 | </Select> | ||
| 278 | </div> | ||
| 279 | )} | ||
| 280 | |||
| 281 | {!editing && connectionMode === "existing" && ( | ||
| 282 | <div className="space-y-1.5"> | ||
| 283 | <Label className="text-xs text-muted-foreground">Existing Connection</Label> | ||
| 284 | <Select value={existingConnectionId} onValueChange={setExistingConnectionId}> | ||
| 285 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 286 | <SelectValue placeholder="Select a connection" /> | ||
| 287 | </SelectTrigger> | ||
| 288 | <SelectContent> | ||
| 289 | {compatibleConnectionOptions.map((connection) => ( | ||
| 290 | <SelectItem key={connection.id} value={connection.id} className="text-xs"> | ||
| 291 | {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"}) | ||
| 292 | </SelectItem> | ||
| 293 | ))} | ||
| 294 | </SelectContent> | ||
| 295 | </Select> | ||
| 296 | {compatibleConnectionOptions.length === 0 && ( | ||
| 297 | <p className="text-[10px] text-muted-foreground">No compatible existing connections for this scope.</p> | ||
| 298 | )} | ||
| 299 | </div> | ||
| 300 | )} | ||
| 301 | |||
| 302 | <ConnectionAuthFields | ||
| 303 | editing={Boolean(editing)} | ||
| 304 | connectionMode={connectionMode} | ||
| 305 | selectedAuth={selectedAuth} | ||
| 306 | tokenValue={tokenValue} | ||
| 307 | apiKeyValue={apiKeyValue} | ||
| 308 | basicUsername={basicUsername} | ||
| 309 | basicPassword={basicPassword} | ||
| 310 | onTokenValueChange={setTokenValue} | ||
| 311 | onApiKeyValueChange={setApiKeyValue} | ||
| 312 | onBasicUsernameChange={setBasicUsername} | ||
| 313 | onBasicPasswordChange={setBasicPassword} | ||
| 314 | /> | ||
| 315 | |||
| 316 | <div className="space-y-1.5"> | ||
| 317 | <Label className="text-xs text-muted-foreground">Custom Headers (optional)</Label> | ||
| 318 | <Textarea | ||
| 319 | value={customHeadersText} | ||
| 320 | onChange={(e) => setCustomHeadersText(e.target.value)} | ||
| 321 | rows={4} | ||
| 322 | placeholder="x-tenant-id: acme\nx-env: staging" | ||
| 323 | className="text-xs font-mono bg-background" | ||
| 324 | /> | ||
| 325 | </div> | ||
| 326 | |||
| 327 | <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | ||
| 328 | {connectionSubmitCopy(Boolean(editing), saving, connectionMode)} | ||
| 329 | </Button> | ||
| 330 | </div> | ||
| 331 | </DialogContent> | ||
| 332 | </Dialog> | ||
| 333 | ); | ||
| 334 | } | ||
| @@ -0,0 +1,76 @@ | |||
| 1 | import type { SourceAuthType } from "@/lib/types"; | ||
| 2 | |||
| 3 | type BuildSecretJsonParams = { | ||
| 4 | selectedAuthType: SourceAuthType; | ||
| 5 | linkExisting: boolean; | ||
| 6 | editing: boolean; | ||
| 7 | basicUsername: string; | ||
| 8 | basicPassword: string; | ||
| 9 | apiKeyValue: string; | ||
| 10 | tokenValue: string; | ||
| 11 | parsedHeaders: Record<string, string>; | ||
| 12 | }; | ||
| 13 | |||
| 14 | export function buildSecretJson({ | ||
| 15 | selectedAuthType, | ||
| 16 | linkExisting, | ||
| 17 | editing, | ||
| 18 | basicUsername, | ||
| 19 | basicPassword, | ||
| 20 | apiKeyValue, | ||
| 21 | tokenValue, | ||
| 22 | parsedHeaders, | ||
| 23 | }: BuildSecretJsonParams): { secretJson?: Record<string, unknown>; error?: string } { | ||
| 24 | const secretJson: Record<string, unknown> = {}; | ||
| 25 | if (!linkExisting) { | ||
| 26 | if (selectedAuthType === "basic") { | ||
| 27 | const hasUsername = basicUsername.trim().length > 0; | ||
| 28 | const hasPassword = basicPassword.trim().length > 0; | ||
| 29 | if (hasUsername || hasPassword) { | ||
| 30 | if (!hasUsername || !hasPassword) { | ||
| 31 | return { error: "Username and password are required for basic auth" }; | ||
| 32 | } | ||
| 33 | secretJson.username = basicUsername; | ||
| 34 | secretJson.password = basicPassword; | ||
| 35 | } | ||
| 36 | } else if (selectedAuthType === "apiKey") { | ||
| 37 | if (apiKeyValue.trim()) { | ||
| 38 | secretJson.value = apiKeyValue.trim(); | ||
| 39 | } | ||
| 40 | } else if (selectedAuthType === "bearer") { | ||
| 41 | if (tokenValue.trim()) { | ||
| 42 | secretJson.token = tokenValue.trim(); | ||
| 43 | } | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | if (Object.keys(parsedHeaders).length > 0) { | ||
| 48 | secretJson.__headers = parsedHeaders; | ||
| 49 | } | ||
| 50 | |||
| 51 | if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) { | ||
| 52 | if (selectedAuthType === "basic") { | ||
| 53 | return { error: "Username and password are required" }; | ||
| 54 | } | ||
| 55 | if (selectedAuthType === "apiKey") { | ||
| 56 | return { error: "API key value is required" }; | ||
| 57 | } | ||
| 58 | return { error: "Token is required" }; | ||
| 59 | } | ||
| 60 | |||
| 61 | return { secretJson }; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function connectionSuccessCopy(editing: boolean, linkExisting: boolean): string { | ||
| 65 | return editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved"; | ||
| 66 | } | ||
| 67 | |||
| 68 | export function connectionSubmitCopy(editing: boolean, saving: boolean, connectionMode: "new" | "existing"): string { | ||
| 69 | if (saving) { | ||
| 70 | return "Saving..."; | ||
| 71 | } | ||
| 72 | if (editing) { | ||
| 73 | return "Update Connection"; | ||
| 74 | } | ||
| 75 | return connectionMode === "existing" ? "Link Connection" : "Save Connection"; | ||
| 76 | } | ||
| @@ -0,0 +1,228 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useMemo, useState } from "react"; | ||
| 4 | import { KeyRound, Plus } from "lucide-react"; | ||
| 5 | import Image from "next/image"; | ||
| 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||
| 7 | import { Button } from "@/components/ui/button"; | ||
| 8 | import { Badge } from "@/components/ui/badge"; | ||
| 9 | import { Skeleton } from "@/components/ui/skeleton"; | ||
| 10 | import { useSession } from "@/lib/session-context"; | ||
| 11 | import type { | ||
| 12 | CredentialRecord, | ||
| 13 | CredentialScope, | ||
| 14 | SourceAuthProfile, | ||
| 15 | ToolSourceRecord, | ||
| 16 | } from "@/lib/types"; | ||
| 17 | import { | ||
| 18 | connectionDisplayName, | ||
| 19 | providerLabel, | ||
| 20 | } from "@/lib/credentials-source-helpers"; | ||
| 21 | import { | ||
| 22 | getSourceFavicon, | ||
| 23 | sourceForCredentialKey, | ||
| 24 | } from "@/lib/tools-source-helpers"; | ||
| 25 | import { ConnectionFormDialog } from "@/components/tools-view-connection-form-dialog"; | ||
| 26 | |||
| 27 | export function CredentialsPanel({ | ||
| 28 | sources, | ||
| 29 | credentials, | ||
| 30 | sourceAuthProfiles, | ||
| 31 | loading, | ||
| 32 | focusSourceKey, | ||
| 33 | onFocusHandled, | ||
| 34 | }: { | ||
| 35 | sources: ToolSourceRecord[]; | ||
| 36 | credentials: CredentialRecord[]; | ||
| 37 | sourceAuthProfiles: Record<string, SourceAuthProfile>; | ||
| 38 | loading: boolean; | ||
| 39 | focusSourceKey?: string | null; | ||
| 40 | onFocusHandled?: () => void; | ||
| 41 | }) { | ||
| 42 | const { clientConfig } = useSession(); | ||
| 43 | const [open, setOpen] = useState(false); | ||
| 44 | const [editing, setEditing] = useState<CredentialRecord | null>(null); | ||
| 45 | const [initialSourceKey, setInitialSourceKey] = useState<string | null>(null); | ||
| 46 | |||
| 47 | const storageCopy = clientConfig?.authProviderMode === "workos" | ||
| 48 | ? "Stored encrypted" | ||
| 49 | : "Stored locally on this machine"; | ||
| 50 | |||
| 51 | const connectionOptions = useMemo(() => { | ||
| 52 | const grouped = new Map<string, { | ||
| 53 | id: string; | ||
| 54 | scope: CredentialScope; | ||
| 55 | actorId?: string; | ||
| 56 | provider: "local-convex" | "workos-vault"; | ||
| 57 | sourceKeys: Set<string>; | ||
| 58 | updatedAt: number; | ||
| 59 | }>(); | ||
| 60 | |||
| 61 | for (const credential of credentials) { | ||
| 62 | const existing = grouped.get(credential.id); | ||
| 63 | if (existing) { | ||
| 64 | existing.sourceKeys.add(credential.sourceKey); | ||
| 65 | existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt); | ||
| 66 | } else { | ||
| 67 | grouped.set(credential.id, { | ||
| 68 | id: credential.id, | ||
| 69 | scope: credential.scope, | ||
| 70 | actorId: credential.actorId, | ||
| 71 | provider: credential.provider, | ||
| 72 | sourceKeys: new Set([credential.sourceKey]), | ||
| 73 | updatedAt: credential.updatedAt, | ||
| 74 | }); | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt); | ||
| 79 | }, [credentials]); | ||
| 80 | |||
| 81 | const representativeCredentialByConnection = useMemo(() => { | ||
| 82 | const map = new Map<string, CredentialRecord>(); | ||
| 83 | for (const credential of credentials) { | ||
| 84 | if (!map.has(credential.id)) { | ||
| 85 | map.set(credential.id, credential); | ||
| 86 | } | ||
| 87 | } | ||
| 88 | return map; | ||
| 89 | }, [credentials]); | ||
| 90 | |||
| 91 | const openForCreate = () => { | ||
| 92 | setEditing(null); | ||
| 93 | setInitialSourceKey(null); | ||
| 94 | setOpen(true); | ||
| 95 | }; | ||
| 96 | |||
| 97 | const openForEdit = (credential: CredentialRecord) => { | ||
| 98 | setEditing(credential); | ||
| 99 | setInitialSourceKey(null); | ||
| 100 | setOpen(true); | ||
| 101 | }; | ||
| 102 | |||
| 103 | const handleOpenChange = (nextOpen: boolean) => { | ||
| 104 | setOpen(nextOpen); | ||
| 105 | if (focusSourceKey) { | ||
| 106 | onFocusHandled?.(); | ||
| 107 | } | ||
| 108 | if (!nextOpen) { | ||
| 109 | setEditing(null); | ||
| 110 | setInitialSourceKey(null); | ||
| 111 | } | ||
| 112 | }; | ||
| 113 | |||
| 114 | const effectiveOpen = open || !!focusSourceKey; | ||
| 115 | const effectiveEditing = focusSourceKey ? null : editing; | ||
| 116 | const effectiveInitialSourceKey = focusSourceKey ?? initialSourceKey; | ||
| 117 | |||
| 118 | return ( | ||
| 119 | <Card className="bg-card border-border"> | ||
| 120 | <CardHeader className="pb-3"> | ||
| 121 | <div className="flex items-center justify-between"> | ||
| 122 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 123 | <KeyRound className="h-4 w-4 text-muted-foreground" /> | ||
| 124 | Connections | ||
| 125 | </CardTitle> | ||
| 126 | <Button size="sm" className="h-8 text-xs" onClick={openForCreate}> | ||
| 127 | <Plus className="h-3.5 w-3.5 mr-1.5" /> | ||
| 128 | Add Connection | ||
| 129 | </Button> | ||
| 130 | </div> | ||
| 131 | </CardHeader> | ||
| 132 | <CardContent className="pt-0 space-y-3"> | ||
| 133 | {loading ? ( | ||
| 134 | <div className="space-y-2"> | ||
| 135 | {Array.from({ length: 3 }).map((_, i) => ( | ||
| 136 | <Skeleton key={i} className="h-14" /> | ||
| 137 | ))} | ||
| 138 | </div> | ||
| 139 | ) : connectionOptions.length === 0 ? ( | ||
| 140 | <div className="flex flex-col items-center justify-center py-10 gap-2"> | ||
| 141 | <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center"> | ||
| 142 | <KeyRound className="h-5 w-5 text-muted-foreground/50" /> | ||
| 143 | </div> | ||
| 144 | <p className="text-sm text-muted-foreground">No connections configured</p> | ||
| 145 | <p className="text-[11px] text-muted-foreground/70 text-center max-w-md"> | ||
| 146 | Add a source, then create or link a reusable connection. | ||
| 147 | </p> | ||
| 148 | </div> | ||
| 149 | ) : ( | ||
| 150 | <div className="space-y-2"> | ||
| 151 | {connectionOptions.map((connection) => { | ||
| 152 | const representative = representativeCredentialByConnection.get(connection.id); | ||
| 153 | if (!representative) { | ||
| 154 | return null; | ||
| 155 | } | ||
| 156 | const firstSource = sourceForCredentialKey(sources, representative.sourceKey); | ||
| 157 | const favicon = firstSource ? getSourceFavicon(firstSource) : null; | ||
| 158 | |||
| 159 | return ( | ||
| 160 | <div | ||
| 161 | key={connection.id} | ||
| 162 | className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" | ||
| 163 | > | ||
| 164 | <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> | ||
| 165 | {favicon ? ( | ||
| 166 | <Image | ||
| 167 | src={favicon} | ||
| 168 | alt="" | ||
| 169 | width={20} | ||
| 170 | height={20} | ||
| 171 | className="w-5 h-5" | ||
| 172 | loading="lazy" | ||
| 173 | unoptimized | ||
| 174 | /> | ||
| 175 | ) : ( | ||
| 176 | <KeyRound className="h-4 w-4 text-muted-foreground" /> | ||
| 177 | )} | ||
| 178 | </div> | ||
| 179 | <div className="flex-1 min-w-0"> | ||
| 180 | <div className="flex items-center gap-1.5 mb-1 flex-wrap"> | ||
| 181 | <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span> | ||
| 182 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | ||
| 183 | {connection.scope} | ||
| 184 | </Badge> | ||
| 185 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | ||
| 186 | {providerLabel(connection.provider)} | ||
| 187 | </Badge> | ||
| 188 | {connection.scope === "actor" && connection.actorId && ( | ||
| 189 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | ||
| 190 | {connection.actorId} | ||
| 191 | </span> | ||
| 192 | )} | ||
| 193 | </div> | ||
| 194 | <p className="text-[11px] text-muted-foreground"> | ||
| 195 | Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} - {storageCopy} | ||
| 196 | </p> | ||
| 197 | <p className="text-[11px] text-muted-foreground mt-0.5"> | ||
| 198 | ID: <span className="font-mono">{connection.id}</span> -{" "} | ||
| 199 | Updated {new Date(connection.updatedAt).toLocaleString()} | ||
| 200 | </p> | ||
| 201 | </div> | ||
| 202 | <Button | ||
| 203 | variant="outline" | ||
| 204 | size="sm" | ||
| 205 | className="h-7 text-[11px]" | ||
| 206 | onClick={() => openForEdit(representative)} | ||
| 207 | > | ||
| 208 | Edit | ||
| 209 | </Button> | ||
| 210 | </div> | ||
| 211 | ); | ||
| 212 | })} | ||
| 213 | </div> | ||
| 214 | )} | ||
| 215 | </CardContent> | ||
| 216 | |||
| 217 | <ConnectionFormDialog | ||
| 218 | open={effectiveOpen} | ||
| 219 | onOpenChange={handleOpenChange} | ||
| 220 | editing={effectiveEditing} | ||
| 221 | initialSourceKey={effectiveInitialSourceKey} | ||
| 222 | sources={sources} | ||
| 223 | credentials={credentials} | ||
| 224 | sourceAuthProfiles={sourceAuthProfiles} | ||
| 225 | /> | ||
| 226 | </Card> | ||
| 227 | ); | ||
| 228 | } | ||
| @@ -0,0 +1,192 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useState } from "react"; | ||
| 4 | import { Pencil } from "lucide-react"; | ||
| 5 | import { useMutation } from "convex/react"; | ||
| 6 | import { toast } from "sonner"; | ||
| 7 | import { Button } from "@/components/ui/button"; | ||
| 8 | import { Input } from "@/components/ui/input"; | ||
| 9 | import { Label } from "@/components/ui/label"; | ||
| 10 | import { | ||
| 11 | Select, | ||
| 12 | SelectContent, | ||
| 13 | SelectItem, | ||
| 14 | SelectTrigger, | ||
| 15 | SelectValue, | ||
| 16 | } from "@/components/ui/select"; | ||
| 17 | import { | ||
| 18 | Dialog, | ||
| 19 | DialogContent, | ||
| 20 | DialogHeader, | ||
| 21 | DialogTitle, | ||
| 22 | DialogTrigger, | ||
| 23 | } from "@/components/ui/dialog"; | ||
| 24 | import { useSession } from "@/lib/session-context"; | ||
| 25 | import { convexApi } from "@/lib/convex-api"; | ||
| 26 | import type { SourceAuthProfile, ToolSourceRecord } from "@/lib/types"; | ||
| 27 | import { | ||
| 28 | readSourceAuth, | ||
| 29 | sourceKeyForSource, | ||
| 30 | type SourceAuthMode, | ||
| 31 | type SourceAuthType, | ||
| 32 | } from "@/lib/tools-source-helpers"; | ||
| 33 | |||
| 34 | export function ConfigureSourceAuthDialog({ | ||
| 35 | source, | ||
| 36 | inferredProfile, | ||
| 37 | }: { | ||
| 38 | source: ToolSourceRecord; | ||
| 39 | inferredProfile?: SourceAuthProfile; | ||
| 40 | }) { | ||
| 41 | const { context } = useSession(); | ||
| 42 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | ||
| 43 | const [open, setOpen] = useState(false); | ||
| 44 | const [saving, setSaving] = useState(false); | ||
| 45 | |||
| 46 | const currentAuth = readSourceAuth(source, inferredProfile); | ||
| 47 | const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type; | ||
| 48 | const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>( | ||
| 49 | editableInitialAuthType, | ||
| 50 | ); | ||
| 51 | const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace"); | ||
| 52 | const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key"); | ||
| 53 | |||
| 54 | const configurable = source.type === "openapi" || source.type === "graphql"; | ||
| 55 | |||
| 56 | const resetFromSource = () => { | ||
| 57 | const auth = readSourceAuth(source, inferredProfile); | ||
| 58 | setAuthType(auth.type === "mixed" ? "bearer" : auth.type); | ||
| 59 | setAuthMode(auth.mode ?? "workspace"); | ||
| 60 | setApiKeyHeader(auth.header ?? "x-api-key"); | ||
| 61 | }; | ||
| 62 | |||
| 63 | const handleOpenChange = (isOpen: boolean) => { | ||
| 64 | setOpen(isOpen); | ||
| 65 | if (isOpen) { | ||
| 66 | resetFromSource(); | ||
| 67 | } | ||
| 68 | }; | ||
| 69 | |||
| 70 | const handleSave = async () => { | ||
| 71 | if (!context || !configurable) { | ||
| 72 | return; | ||
| 73 | } | ||
| 74 | |||
| 75 | setSaving(true); | ||
| 76 | try { | ||
| 77 | const authConfig: Record<string, unknown> = | ||
| 78 | authType === "none" | ||
| 79 | ? { type: "none" } | ||
| 80 | : authType === "apiKey" | ||
| 81 | ? { type: "apiKey", mode: authMode, header: apiKeyHeader.trim() || "x-api-key" } | ||
| 82 | : { type: authType, mode: authMode }; | ||
| 83 | |||
| 84 | await upsertToolSource({ | ||
| 85 | id: source.id, | ||
| 86 | workspaceId: context.workspaceId, | ||
| 87 | sessionId: context.sessionId, | ||
| 88 | name: source.name, | ||
| 89 | type: source.type, | ||
| 90 | config: { | ||
| 91 | ...source.config, | ||
| 92 | auth: authConfig, | ||
| 93 | }, | ||
| 94 | }); | ||
| 95 | |||
| 96 | toast.success(`Updated auth for ${source.name}`); | ||
| 97 | setOpen(false); | ||
| 98 | } catch (err) { | ||
| 99 | toast.error(err instanceof Error ? err.message : "Failed to update auth"); | ||
| 100 | } finally { | ||
| 101 | setSaving(false); | ||
| 102 | } | ||
| 103 | }; | ||
| 104 | |||
| 105 | if (!configurable) { | ||
| 106 | return null; | ||
| 107 | } | ||
| 108 | |||
| 109 | return ( | ||
| 110 | <Dialog open={open} onOpenChange={handleOpenChange}> | ||
| 111 | <DialogTrigger asChild> | ||
| 112 | <Button variant="outline" size="sm" className="h-7 text-[11px]"> | ||
| 113 | <Pencil className="h-3 w-3 mr-1.5" /> | ||
| 114 | Auth | ||
| 115 | </Button> | ||
| 116 | </DialogTrigger> | ||
| 117 | <DialogContent className="bg-card border-border sm:max-w-md"> | ||
| 118 | <DialogHeader> | ||
| 119 | <DialogTitle className="text-sm font-medium">Configure Source Auth</DialogTitle> | ||
| 120 | </DialogHeader> | ||
| 121 | <div className="space-y-3"> | ||
| 122 | <div className="space-y-1.5"> | ||
| 123 | <Label className="text-xs text-muted-foreground">Source</Label> | ||
| 124 | <Input value={source.name} readOnly className="h-8 text-xs font-mono bg-background" /> | ||
| 125 | </div> | ||
| 126 | |||
| 127 | <div className="space-y-1.5"> | ||
| 128 | <Label className="text-xs text-muted-foreground">Auth Type</Label> | ||
| 129 | <Select | ||
| 130 | value={authType} | ||
| 131 | onValueChange={(value) => setAuthType(value as Exclude<SourceAuthType, "mixed">)} | ||
| 132 | > | ||
| 133 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 134 | <SelectValue /> | ||
| 135 | </SelectTrigger> | ||
| 136 | <SelectContent> | ||
| 137 | <SelectItem value="none" className="text-xs">None</SelectItem> | ||
| 138 | <SelectItem value="bearer" className="text-xs">Bearer token</SelectItem> | ||
| 139 | <SelectItem value="apiKey" className="text-xs">API key header</SelectItem> | ||
| 140 | <SelectItem value="basic" className="text-xs">Basic auth</SelectItem> | ||
| 141 | </SelectContent> | ||
| 142 | </Select> | ||
| 143 | </div> | ||
| 144 | |||
| 145 | {currentAuth.inferred && ( | ||
| 146 | <p className="text-[11px] text-muted-foreground"> | ||
| 147 | Suggested from spec inference. Save to pin an explicit auth config. | ||
| 148 | </p> | ||
| 149 | )} | ||
| 150 | |||
| 151 | {authType !== "none" && ( | ||
| 152 | <> | ||
| 153 | <div className="space-y-1.5"> | ||
| 154 | <Label className="text-xs text-muted-foreground">Credential Scope</Label> | ||
| 155 | <Select value={authMode} onValueChange={(value) => setAuthMode(value as SourceAuthMode)}> | ||
| 156 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 157 | <SelectValue /> | ||
| 158 | </SelectTrigger> | ||
| 159 | <SelectContent> | ||
| 160 | <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | ||
| 161 | <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem> | ||
| 162 | </SelectContent> | ||
| 163 | </Select> | ||
| 164 | </div> | ||
| 165 | |||
| 166 | {authType === "apiKey" && ( | ||
| 167 | <div className="space-y-1.5"> | ||
| 168 | <Label className="text-xs text-muted-foreground">Header Name</Label> | ||
| 169 | <Input | ||
| 170 | value={apiKeyHeader} | ||
| 171 | onChange={(event) => setApiKeyHeader(event.target.value)} | ||
| 172 | placeholder="x-api-key" | ||
| 173 | className="h-8 text-xs font-mono bg-background" | ||
| 174 | /> | ||
| 175 | </div> | ||
| 176 | )} | ||
| 177 | |||
| 178 | <p className="text-[11px] text-muted-foreground"> | ||
| 179 | Save this first, then add a connection in the Connections tab using source key | ||
| 180 | <code className="ml-1">{sourceKeyForSource(source)}</code>. | ||
| 181 | </p> | ||
| 182 | </> | ||
| 183 | )} | ||
| 184 | |||
| 185 | <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | ||
| 186 | {saving ? "Saving..." : "Save Auth"} | ||
| 187 | </Button> | ||
| 188 | </div> | ||
| 189 | </DialogContent> | ||
| 190 | </Dialog> | ||
| 191 | ); | ||
| 192 | } | ||
| @@ -0,0 +1,176 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useState } from "react"; | ||
| 4 | import { Globe, Server, Trash2 } from "lucide-react"; | ||
| 5 | import { useMutation } from "convex/react"; | ||
| 6 | import Image from "next/image"; | ||
| 7 | import { toast } from "sonner"; | ||
| 8 | import { Button } from "@/components/ui/button"; | ||
| 9 | import { Badge } from "@/components/ui/badge"; | ||
| 10 | import { useSession } from "@/lib/session-context"; | ||
| 11 | import { convexApi } from "@/lib/convex-api"; | ||
| 12 | import type { | ||
| 13 | OpenApiSourceQuality, | ||
| 14 | SourceAuthProfile, | ||
| 15 | ToolSourceRecord, | ||
| 16 | } from "@/lib/types"; | ||
| 17 | import { cn } from "@/lib/utils"; | ||
| 18 | import { displaySourceName } from "@/lib/tool-source-utils"; | ||
| 19 | import { | ||
| 20 | compactEndpointLabel, | ||
| 21 | formatSourceAuthBadge, | ||
| 22 | getSourceFavicon, | ||
| 23 | readSourceAuth, | ||
| 24 | sourceEndpointLabel, | ||
| 25 | sourceKeyForSource, | ||
| 26 | } from "@/lib/tools-source-helpers"; | ||
| 27 | import { ConfigureSourceAuthDialog } from "./tools-view-source-auth-dialog"; | ||
| 28 | import { | ||
| 29 | OpenApiQualityDetails, | ||
| 30 | SourceQualitySummary, | ||
| 31 | } from "./tools-view-source-quality-details"; | ||
| 32 | |||
| 33 | export function SourceCard({ | ||
| 34 | source, | ||
| 35 | quality, | ||
| 36 | qualityLoading, | ||
| 37 | credentialStats, | ||
| 38 | sourceAuthProfiles, | ||
| 39 | selected = false, | ||
| 40 | onFocusSource, | ||
| 41 | }: { | ||
| 42 | source: ToolSourceRecord; | ||
| 43 | quality?: OpenApiSourceQuality; | ||
| 44 | qualityLoading?: boolean; | ||
| 45 | credentialStats: { workspaceCount: number; actorCount: number }; | ||
| 46 | sourceAuthProfiles: Record<string, SourceAuthProfile>; | ||
| 47 | selected?: boolean; | ||
| 48 | onFocusSource?: (sourceName: string) => void; | ||
| 49 | }) { | ||
| 50 | const { context } = useSession(); | ||
| 51 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); | ||
| 52 | const [deleting, setDeleting] = useState(false); | ||
| 53 | |||
| 54 | const handleDelete = async () => { | ||
| 55 | if (!context) { | ||
| 56 | return; | ||
| 57 | } | ||
| 58 | setDeleting(true); | ||
| 59 | try { | ||
| 60 | await deleteToolSource({ | ||
| 61 | workspaceId: context.workspaceId, | ||
| 62 | sessionId: context.sessionId, | ||
| 63 | sourceId: source.id, | ||
| 64 | }); | ||
| 65 | toast.success(`Removed "${source.name}"`); | ||
| 66 | } catch (err) { | ||
| 67 | toast.error(err instanceof Error ? err.message : "Failed to delete"); | ||
| 68 | } finally { | ||
| 69 | setDeleting(false); | ||
| 70 | } | ||
| 71 | }; | ||
| 72 | |||
| 73 | const TypeIcon = source.type === "mcp" ? Server : Globe; | ||
| 74 | const favicon = getSourceFavicon(source); | ||
| 75 | const sourceKey = sourceKeyForSource(source) ?? ""; | ||
| 76 | const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]); | ||
| 77 | const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]); | ||
| 78 | const hasAuthConfigured = auth.type !== "none"; | ||
| 79 | const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount; | ||
| 80 | const prettyName = displaySourceName(source.name); | ||
| 81 | const compactEndpoint = compactEndpointLabel(source); | ||
| 82 | const showTypeSummary = source.type === "openapi" && (quality || qualityLoading); | ||
| 83 | |||
| 84 | return ( | ||
| 85 | <div | ||
| 86 | className={cn( | ||
| 87 | "group flex items-start gap-3 rounded-lg border border-border/60 bg-gradient-to-b from-muted/45 to-muted/20 px-3 py-3", | ||
| 88 | selected && "border-primary/35 bg-primary/5", | ||
| 89 | )} | ||
| 90 | > | ||
| 91 | <div className="mt-0.5 h-9 w-9 rounded-md bg-muted/80 flex items-center justify-center shrink-0 overflow-hidden"> | ||
| 92 | {favicon ? ( | ||
| 93 | <Image | ||
| 94 | src={favicon} | ||
| 95 | alt="" | ||
| 96 | width={20} | ||
| 97 | height={20} | ||
| 98 | className="w-5 h-5" | ||
| 99 | loading="lazy" | ||
| 100 | unoptimized | ||
| 101 | /> | ||
| 102 | ) : ( | ||
| 103 | <TypeIcon className="h-4 w-4 text-muted-foreground" /> | ||
| 104 | )} | ||
| 105 | </div> | ||
| 106 | <div className="flex-1 min-w-0"> | ||
| 107 | <div className="flex items-center gap-2 min-w-0"> | ||
| 108 | <span className="text-sm font-medium truncate" title={source.name}> | ||
| 109 | {prettyName} | ||
| 110 | </span> | ||
| 111 | <Badge variant="outline" className="text-[9px] uppercase tracking-wide"> | ||
| 112 | {source.type} | ||
| 113 | </Badge> | ||
| 114 | {!source.enabled && ( | ||
| 115 | <Badge | ||
| 116 | variant="outline" | ||
| 117 | className="text-[9px] uppercase tracking-wide text-terminal-red border-terminal-red/30" | ||
| 118 | > | ||
| 119 | disabled | ||
| 120 | </Badge> | ||
| 121 | )} | ||
| 122 | {authBadge && ( | ||
| 123 | <Badge | ||
| 124 | variant="outline" | ||
| 125 | className="text-[9px] uppercase tracking-wide text-primary border-primary/30" | ||
| 126 | > | ||
| 127 | {authBadge} | ||
| 128 | </Badge> | ||
| 129 | )} | ||
| 130 | {hasAuthConfigured && ( | ||
| 131 | <Badge | ||
| 132 | variant="outline" | ||
| 133 | className={cn( | ||
| 134 | "text-[9px] uppercase tracking-wide", | ||
| 135 | totalCredentials > 0 | ||
| 136 | ? "text-terminal-green border-terminal-green/30" | ||
| 137 | : "text-terminal-amber border-terminal-amber/30", | ||
| 138 | )} | ||
| 139 | > | ||
| 140 | {totalCredentials > 0 ? "connections ready" : "connection needed"} | ||
| 141 | </Badge> | ||
| 142 | )} | ||
| 143 | </div> | ||
| 144 | <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}> | ||
| 145 | {compactEndpoint} | ||
| 146 | </span> | ||
| 147 | {showTypeSummary && <SourceQualitySummary quality={quality} qualityLoading={qualityLoading} />} | ||
| 148 | {source.type === "openapi" && ( | ||
| 149 | <OpenApiQualityDetails quality={quality} qualityLoading={qualityLoading} /> | ||
| 150 | )} | ||
| 151 | </div> | ||
| 152 | <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> | ||
| 153 | {onFocusSource ? ( | ||
| 154 | <Button | ||
| 155 | variant={selected ? "default" : "outline"} | ||
| 156 | size="sm" | ||
| 157 | className="h-7 text-[11px]" | ||
| 158 | onClick={() => onFocusSource(source.name)} | ||
| 159 | > | ||
| 160 | {selected ? "Viewing" : "View tools"} | ||
| 161 | </Button> | ||
| 162 | ) : null} | ||
| 163 | <ConfigureSourceAuthDialog source={source} inferredProfile={sourceAuthProfiles[sourceKey]} /> | ||
| 164 | <Button | ||
| 165 | variant="ghost" | ||
| 166 | size="icon" | ||
| 167 | className="h-7 w-7 text-muted-foreground hover:text-terminal-red" | ||
| 168 | onClick={handleDelete} | ||
| 169 | disabled={deleting} | ||
| 170 | > | ||
| 171 | <Trash2 className="h-3.5 w-3.5" /> | ||
| 172 | </Button> | ||
| 173 | </div> | ||
| 174 | </div> | ||
| 175 | ); | ||
| 176 | } | ||
| @@ -0,0 +1,110 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useState } from "react"; | ||
| 4 | import { ChevronRight } from "lucide-react"; | ||
| 5 | import { Badge } from "@/components/ui/badge"; | ||
| 6 | import { Button } from "@/components/ui/button"; | ||
| 7 | import { | ||
| 8 | Collapsible, | ||
| 9 | CollapsibleContent, | ||
| 10 | CollapsibleTrigger, | ||
| 11 | } from "@/components/ui/collapsible"; | ||
| 12 | import type { OpenApiSourceQuality } from "@/lib/types"; | ||
| 13 | import { cn } from "@/lib/utils"; | ||
| 14 | import { | ||
| 15 | formatQualityPercent, | ||
| 16 | qualitySummaryLabel, | ||
| 17 | qualityToneClass, | ||
| 18 | } from "@/lib/tools-source-helpers"; | ||
| 19 | |||
| 20 | export function SourceQualitySummary({ | ||
| 21 | quality, | ||
| 22 | qualityLoading, | ||
| 23 | }: { | ||
| 24 | quality?: OpenApiSourceQuality; | ||
| 25 | qualityLoading?: boolean; | ||
| 26 | }) { | ||
| 27 | if (!quality && !qualityLoading) { | ||
| 28 | return null; | ||
| 29 | } | ||
| 30 | |||
| 31 | return ( | ||
| 32 | <div className="mt-1.5 flex items-center gap-2"> | ||
| 33 | {quality ? ( | ||
| 34 | <Badge | ||
| 35 | variant="outline" | ||
| 36 | className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))} | ||
| 37 | > | ||
| 38 | {formatQualityPercent(quality.overallQuality)} {qualitySummaryLabel(quality)} | ||
| 39 | </Badge> | ||
| 40 | ) : ( | ||
| 41 | <Badge variant="outline" className="text-[9px] uppercase tracking-wide text-muted-foreground"> | ||
| 42 | analyzing type quality | ||
| 43 | </Badge> | ||
| 44 | )} | ||
| 45 | </div> | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | export function OpenApiQualityDetails({ | ||
| 50 | quality, | ||
| 51 | qualityLoading, | ||
| 52 | }: { | ||
| 53 | quality?: OpenApiSourceQuality; | ||
| 54 | qualityLoading?: boolean; | ||
| 55 | }) { | ||
| 56 | const [detailsOpen, setDetailsOpen] = useState(false); | ||
| 57 | |||
| 58 | return ( | ||
| 59 | <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}> | ||
| 60 | <div className="mt-1.5"> | ||
| 61 | <CollapsibleTrigger asChild> | ||
| 62 | <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground"> | ||
| 63 | <ChevronRight | ||
| 64 | className={cn("mr-1 h-3 w-3 transition-transform", detailsOpen && "rotate-90")} | ||
| 65 | /> | ||
| 66 | {detailsOpen ? "Hide details" : "View details"} | ||
| 67 | </Button> | ||
| 68 | </CollapsibleTrigger> | ||
| 69 | </div> | ||
| 70 | <CollapsibleContent className="mt-1.5"> | ||
| 71 | <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2"> | ||
| 72 | {quality && ( | ||
| 73 | <div className="space-y-1.5 text-[10px]"> | ||
| 74 | <div className="flex items-center justify-between"> | ||
| 75 | <span className="text-muted-foreground">Type quality</span> | ||
| 76 | <span className={cn("font-medium", qualityToneClass(quality))}> | ||
| 77 | {formatQualityPercent(quality.overallQuality)} | ||
| 78 | </span> | ||
| 79 | </div> | ||
| 80 | <div className="flex items-center justify-between text-muted-foreground"> | ||
| 81 | <span>Args quality</span> | ||
| 82 | <span>{formatQualityPercent(quality.argsQuality)}</span> | ||
| 83 | </div> | ||
| 84 | <div className="flex items-center justify-between text-muted-foreground"> | ||
| 85 | <span>Returns quality</span> | ||
| 86 | <span>{formatQualityPercent(quality.returnsQuality)}</span> | ||
| 87 | </div> | ||
| 88 | <div className="flex items-center justify-between text-muted-foreground"> | ||
| 89 | <span>Inferred returns</span> | ||
| 90 | <span>{quality.unknownReturnsCount}</span> | ||
| 91 | </div> | ||
| 92 | </div> | ||
| 93 | )} | ||
| 94 | {!quality && qualityLoading && ( | ||
| 95 | <div className="flex items-center justify-between text-[10px] text-muted-foreground"> | ||
| 96 | <span>Analyzing OpenAPI typing</span> | ||
| 97 | <span className="inline-flex items-center gap-1 text-[10px]"> | ||
| 98 | <span className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-pulse" /> | ||
| 99 | in progress | ||
| 100 | </span> | ||
| 101 | </div> | ||
| 102 | )} | ||
| 103 | {!quality && !qualityLoading && ( | ||
| 104 | <div className="text-[10px] text-muted-foreground">Type quality data unavailable.</div> | ||
| 105 | )} | ||
| 106 | </div> | ||
| 107 | </CollapsibleContent> | ||
| 108 | </Collapsible> | ||
| 109 | ); | ||
| 110 | } | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | export { AddSourceDialog } from "./tools-view-add-source-dialog"; | ||
| 2 | export { SourceCard } from "./tools-view-source-card"; | ||
| @@ -1,1867 +1,33 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useEffect, useMemo, useState } from "react"; | 3 | import { useState } from "react"; |
| 4 | import { | 4 | import { |
| 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"; |
| 15 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 16 | import { Button } from "@/components/ui/button"; | 9 | import { Button } from "@/components/ui/button"; |
| 17 | import { Input } from "@/components/ui/input"; | ||
| 18 | import { Label } from "@/components/ui/label"; | ||
| 19 | import { Textarea } from "@/components/ui/textarea"; | ||
| 20 | import { Badge } from "@/components/ui/badge"; | ||
| 21 | import { | ||
| 22 | Select, | ||
| 23 | SelectContent, | ||
| 24 | SelectItem, | ||
| 25 | SelectTrigger, | ||
| 26 | SelectValue, | ||
| 27 | } from "@/components/ui/select"; | ||
| 28 | import { | ||
| 29 | Dialog, | ||
| 30 | DialogContent, | ||
| 31 | DialogHeader, | ||
| 32 | DialogTitle, | ||
| 33 | DialogTrigger, | ||
| 34 | } from "@/components/ui/dialog"; | ||
| 35 | import { | ||
| 36 | Collapsible, | ||
| 37 | CollapsibleContent, | ||
| 38 | CollapsibleTrigger, | ||
| 39 | } from "@/components/ui/collapsible"; | ||
| 40 | import { Separator } from "@/components/ui/separator"; | ||
| 41 | import { Skeleton } from "@/components/ui/skeleton"; | 10 | import { Skeleton } from "@/components/ui/skeleton"; |
| 42 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | 11 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| 43 | import { PageHeader } from "@/components/page-header"; | 12 | import { PageHeader } from "@/components/page-header"; |
| 44 | import { McpSetupCard } from "@/components/mcp-setup-card"; | 13 | import { McpSetupCard } from "@/components/mcp-setup-card"; |
| 45 | import { ToolExplorer } from "@/components/tool-explorer"; | 14 | import { ToolExplorer } from "@/components/tool-explorer"; |
| 15 | import { AddSourceDialog, SourceCard } from "@/components/tools-view-sources"; | ||
| 16 | import { CredentialsPanel } from "@/components/tools-view-credentials"; | ||
| 46 | import { useSession } from "@/lib/session-context"; | 17 | import { useSession } from "@/lib/session-context"; |
| 47 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | 18 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 48 | import { useAction, useMutation, useQuery } from "convex/react"; | 19 | import { useQuery } from "convex/react"; |
| 49 | import { convexApi } from "@/lib/convex-api"; | 20 | import { convexApi } from "@/lib/convex-api"; |
| 50 | import type { | 21 | import type { |
| 51 | ToolSourceRecord, | 22 | ToolSourceRecord, |
| 52 | CredentialRecord, | 23 | CredentialRecord, |
| 53 | CredentialScope, | ||
| 54 | SourceAuthProfile, | ||
| 55 | OpenApiSourceQuality, | ||
| 56 | } from "@/lib/types"; | 24 | } from "@/lib/types"; |
| 57 | import { parse as parseDomain } from "tldts"; | 25 | import { |
| 58 | import { toast } from "sonner"; | 26 | credentialStatsForSource, |
| 59 | import { cn } from "@/lib/utils"; | 27 | sourceKeyForSource, |
| 60 | import { Streamdown } from "streamdown"; | 28 | toolSourceLabelForSource, |
| 61 | 29 | } from "@/lib/tools-source-helpers"; | |
| 62 | interface CatalogCollectionItem { | 30 | import { 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 | |||
| 76 | const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [ | ||
| 77 | { | ||
| 78 | id: "github-rest", | ||
| 79 | name: "GitHub REST API", | ||
| 80 | summary: "Manage repositories, pull requests, issues, and org settings.", | ||
| 81 | specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | ||
| 82 | originUrl: "https://docs.github.com/en/rest", | ||
| 83 | providerName: "GitHub", | ||
| 84 | categories: "developer-tools", | ||
| 85 | version: "latest", | ||
| 86 | rank: 1, | ||
| 87 | addedAt: "2026-01-10", | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | id: "stripe-api", | ||
| 91 | name: "Stripe API", | ||
| 92 | summary: "Create payments, manage customers, and handle billing workflows.", | ||
| 93 | specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | ||
| 94 | originUrl: "https://docs.stripe.com/api", | ||
| 95 | providerName: "Stripe", | ||
| 96 | categories: "payments", | ||
| 97 | version: "2026-01", | ||
| 98 | rank: 2, | ||
| 99 | addedAt: "2026-01-08", | ||
| 100 | }, | ||
| 101 | { | ||
| 102 | id: "openai-api", | ||
| 103 | name: "OpenAI API", | ||
| 104 | summary: "Generate text, run reasoning models, and process multimodal inputs.", | ||
| 105 | specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | ||
| 106 | originUrl: "https://platform.openai.com/docs/api-reference", | ||
| 107 | providerName: "OpenAI", | ||
| 108 | categories: "ai", | ||
| 109 | version: "latest", | ||
| 110 | rank: 3, | ||
| 111 | addedAt: "2026-01-06", | ||
| 112 | }, | ||
| 113 | { | ||
| 114 | id: "cloudflare-api", | ||
| 115 | name: "Cloudflare API", | ||
| 116 | summary: "Control zones, DNS records, workers, and edge configuration.", | ||
| 117 | specUrl: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml", | ||
| 118 | originUrl: "https://api.cloudflare.com/", | ||
| 119 | providerName: "Cloudflare", | ||
| 120 | categories: "infrastructure", | ||
| 121 | version: "latest", | ||
| 122 | rank: 4, | ||
| 123 | addedAt: "2026-01-04", | ||
| 124 | }, | ||
| 125 | { | ||
| 126 | id: "vercel-api", | ||
| 127 | name: "Vercel API", | ||
| 128 | summary: "Manage deployments, projects, domains, and team resources.", | ||
| 129 | specUrl: "https://openapi.vercel.sh", | ||
| 130 | originUrl: "https://vercel.com/docs/rest-api", | ||
| 131 | providerName: "Vercel", | ||
| 132 | categories: "developer-tools", | ||
| 133 | version: "latest", | ||
| 134 | rank: 5, | ||
| 135 | addedAt: "2025-12-18", | ||
| 136 | }, | ||
| 137 | { | ||
| 138 | id: "slack-api", | ||
| 139 | name: "Slack API", | ||
| 140 | summary: "Work with channels, messages, users, and workspace automation.", | ||
| 141 | specUrl: "https://api.slack.com/specs/openapi/v2/slack_web.json", | ||
| 142 | originUrl: "https://api.slack.com/web", | ||
| 143 | providerName: "Slack", | ||
| 144 | categories: "communications", | ||
| 145 | version: "v2", | ||
| 146 | rank: 6, | ||
| 147 | addedAt: "2025-12-10", | ||
| 148 | }, | ||
| 149 | { | ||
| 150 | id: "sentry-api", | ||
| 151 | name: "Sentry API", | ||
| 152 | summary: "Query issues, releases, projects, and alerting configuration.", | ||
| 153 | specUrl: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json", | ||
| 154 | originUrl: "https://docs.sentry.io/api/", | ||
| 155 | providerName: "Sentry", | ||
| 156 | categories: "observability", | ||
| 157 | version: "latest", | ||
| 158 | rank: 7, | ||
| 159 | addedAt: "2025-11-30", | ||
| 160 | }, | ||
| 161 | { | ||
| 162 | id: "jira-cloud-api", | ||
| 163 | name: "Jira Cloud Platform", | ||
| 164 | summary: "Manage projects, issues, workflows, and Jira metadata.", | ||
| 165 | specUrl: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", | ||
| 166 | originUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/", | ||
| 167 | providerName: "Atlassian", | ||
| 168 | categories: "project-management", | ||
| 169 | version: "v3", | ||
| 170 | rank: 8, | ||
| 171 | addedAt: "2025-11-15", | ||
| 172 | }, | ||
| 173 | ]; | ||
| 174 | |||
| 175 | /** Derive a favicon URL from any URL string via Google's favicon service. */ | ||
| 176 | function faviconForUrl(url: string | undefined | null): string | null { | ||
| 177 | if (!url) return null; | ||
| 178 | try { | ||
| 179 | const domain = new URL(url).hostname; | ||
| 180 | return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; | ||
| 181 | } catch { | ||
| 182 | return null; | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | function getSourceFavicon(source: ToolSourceRecord): string | null { | ||
| 187 | if (source.type === "mcp") { | ||
| 188 | return faviconForUrl((source.config.url as string) ?? null); | ||
| 189 | } | ||
| 190 | if (source.type === "graphql") { | ||
| 191 | return faviconForUrl((source.config.endpoint as string) ?? null); | ||
| 192 | } | ||
| 193 | const spec = source.config.spec as string | undefined; | ||
| 194 | if (typeof spec === "string" && spec.startsWith("postman:")) { | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | const baseUrl = source.config.baseUrl as string | undefined; | ||
| 198 | const collectionUrl = source.config.collectionUrl as string | undefined; | ||
| 199 | const specUrl = typeof spec === "string" && spec.startsWith("http") ? spec : null; | ||
| 200 | return faviconForUrl(baseUrl ?? collectionUrl ?? specUrl); | ||
| 201 | } | ||
| 202 | |||
| 203 | function sourceEndpointLabel(source: ToolSourceRecord): string { | ||
| 204 | if (source.type === "mcp") return (source.config.url as string) ?? ""; | ||
| 205 | if (source.type === "graphql") return (source.config.endpoint as string) ?? ""; | ||
| 206 | |||
| 207 | const spec = source.config.spec; | ||
| 208 | if (typeof spec === "string" && spec.startsWith("postman:")) { | ||
| 209 | const uid = spec.slice("postman:".length).trim(); | ||
| 210 | if (uid.length > 0) { | ||
| 211 | return `catalog:${uid}`; | ||
| 212 | } | ||
| 213 | return "catalog:collection"; | ||
| 214 | } | ||
| 215 | |||
| 216 | return (source.config.spec as string) ?? ""; | ||
| 217 | } | ||
| 218 | |||
| 219 | function sourceKeyForSource(source: ToolSourceRecord): string | null { | ||
| 220 | if (source.type === "openapi") return `source:${source.id}`; | ||
| 221 | if (source.type === "graphql") return `source:${source.id}`; | ||
| 222 | return null; | ||
| 223 | } | ||
| 224 | |||
| 225 | function toolSourceLabelForSource(source: ToolSourceRecord): string { | ||
| 226 | return `${source.type}:${source.name}`; | ||
| 227 | } | ||
| 228 | |||
| 229 | function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): ToolSourceRecord | null { | ||
| 230 | const prefix = "source:"; | ||
| 231 | if (!sourceKey.startsWith(prefix)) return null; | ||
| 232 | const sourceId = sourceKey.slice(prefix.length); | ||
| 233 | if (!sourceId) return null; | ||
| 234 | return sources.find((source) => source.id === sourceId) ?? null; | ||
| 235 | } | ||
| 236 | |||
| 237 | type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed"; | ||
| 238 | type SourceAuthMode = "workspace" | "actor"; | ||
| 239 | |||
| 240 | function normalizeSourceAuthProfile(profile: SourceAuthProfile | undefined): { | ||
| 241 | type: SourceAuthType; | ||
| 242 | mode?: SourceAuthMode; | ||
| 243 | header?: string; | ||
| 244 | inferred?: boolean; | ||
| 245 | } { | ||
| 246 | if (!profile) { | ||
| 247 | return { type: "none" }; | ||
| 248 | } | ||
| 249 | |||
| 250 | const type = profile.type === "mixed" | ||
| 251 | ? "mixed" | ||
| 252 | : profile.type === "basic" | ||
| 253 | ? "basic" | ||
| 254 | : profile.type === "apiKey" | ||
| 255 | ? "apiKey" | ||
| 256 | : profile.type === "bearer" | ||
| 257 | ? "bearer" | ||
| 258 | : "none"; | ||
| 259 | |||
| 260 | const mode = profile.mode === "actor" ? "actor" : profile.mode === "workspace" ? "workspace" : undefined; | ||
| 261 | const header = typeof profile.header === "string" && profile.header.trim().length > 0 | ||
| 262 | ? profile.header.trim() | ||
| 263 | : undefined; | ||
| 264 | |||
| 265 | return { | ||
| 266 | type, | ||
| 267 | ...(mode ? { mode } : {}), | ||
| 268 | ...(header ? { header } : {}), | ||
| 269 | inferred: Boolean(profile.inferred), | ||
| 270 | }; | ||
| 271 | } | ||
| 272 | |||
| 273 | function readSourceAuth( | ||
| 274 | source: ToolSourceRecord, | ||
| 275 | inferredProfile?: SourceAuthProfile, | ||
| 276 | ): { | ||
| 277 | type: SourceAuthType; | ||
| 278 | mode?: SourceAuthMode; | ||
| 279 | header?: string; | ||
| 280 | inferred?: boolean; | ||
| 281 | } { | ||
| 282 | if (source.type !== "openapi" && source.type !== "graphql") { | ||
| 283 | return { type: "none" }; | ||
| 284 | } | ||
| 285 | |||
| 286 | const inferred = normalizeSourceAuthProfile(inferredProfile); | ||
| 287 | |||
| 288 | const auth = source.config.auth as Record<string, unknown> | undefined; | ||
| 289 | const type = | ||
| 290 | auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic", "mixed"].includes(auth.type) | ||
| 291 | ? (auth.type as SourceAuthType) | ||
| 292 | : inferred.type; | ||
| 293 | |||
| 294 | const mode = | ||
| 295 | auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor") | ||
| 296 | ? (auth.mode as SourceAuthMode) | ||
| 297 | : inferred.mode; | ||
| 298 | |||
| 299 | const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0 | ||
| 300 | ? auth.header.trim() | ||
| 301 | : inferred.header; | ||
| 302 | |||
| 303 | return { | ||
| 304 | type, | ||
| 305 | ...(mode ? { mode } : {}), | ||
| 306 | ...(header ? { header } : {}), | ||
| 307 | inferred: auth?.type === undefined ? inferred.inferred : false, | ||
| 308 | }; | ||
| 309 | } | ||
| 310 | |||
| 311 | function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null { | ||
| 312 | const auth = readSourceAuth(source, inferredProfile); | ||
| 313 | if (auth.type === "none") return null; | ||
| 314 | if (auth.type === "mixed") return "Mixed auth"; | ||
| 315 | const mode = auth.mode ?? "workspace"; | ||
| 316 | const authLabel = | ||
| 317 | auth.type === "apiKey" | ||
| 318 | ? "API Key" | ||
| 319 | : auth.type === "bearer" | ||
| 320 | ? "Bearer" | ||
| 321 | : auth.type === "basic" | ||
| 322 | ? "Basic" | ||
| 323 | : "Auth"; | ||
| 324 | return `${authLabel} · ${mode === "actor" ? "user" : "workspace"}`; | ||
| 325 | } | ||
| 326 | |||
| 327 | function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): { | ||
| 328 | workspaceCount: number; | ||
| 329 | actorCount: number; | ||
| 330 | } { | ||
| 331 | const sourceKey = sourceKeyForSource(source); | ||
| 332 | if (!sourceKey) { | ||
| 333 | return { workspaceCount: 0, actorCount: 0 }; | ||
| 334 | } | ||
| 335 | |||
| 336 | let workspaceCount = 0; | ||
| 337 | let actorCount = 0; | ||
| 338 | for (const credential of credentials) { | ||
| 339 | if (credential.sourceKey !== sourceKey) continue; | ||
| 340 | if (credential.scope === "workspace") workspaceCount += 1; | ||
| 341 | if (credential.scope === "actor") actorCount += 1; | ||
| 342 | } | ||
| 343 | |||
| 344 | return { workspaceCount, actorCount }; | ||
| 345 | } | ||
| 346 | |||
| 347 | function formatQualityPercent(value: number): string { | ||
| 348 | return `${Math.round(value * 100)}%`; | ||
| 349 | } | ||
| 350 | |||
| 351 | function qualityToneClass(quality: OpenApiSourceQuality): string { | ||
| 352 | if (quality.overallQuality >= 0.95) { | ||
| 353 | return "text-terminal-green"; | ||
| 354 | } | ||
| 355 | if (quality.overallQuality >= 0.85) { | ||
| 356 | return "text-terminal-amber"; | ||
| 357 | } | ||
| 358 | return "text-terminal-red"; | ||
| 359 | } | ||
| 360 | |||
| 361 | function qualitySummaryLabel(quality: OpenApiSourceQuality): string { | ||
| 362 | if (quality.overallQuality >= 0.95) { | ||
| 363 | return "strong typing"; | ||
| 364 | } | ||
| 365 | if (quality.overallQuality >= 0.85) { | ||
| 366 | return "mostly typed"; | ||
| 367 | } | ||
| 368 | return "needs type cleanup"; | ||
| 369 | } | ||
| 370 | |||
| 371 | function displaySourceName(name: string): string { | ||
| 372 | const parts = name.split(/[-_.]+/).filter(Boolean); | ||
| 373 | if (parts.length === 0) return name; | ||
| 374 | |||
| 375 | const deduped = parts.filter((part, index, all) => { | ||
| 376 | if (index === 0) return true; | ||
| 377 | return part.toLowerCase() !== all[index - 1]?.toLowerCase(); | ||
| 378 | }); | ||
| 379 | |||
| 380 | const tokenMap: Record<string, string> = { | ||
| 381 | api: "API", | ||
| 382 | oauth: "OAuth", | ||
| 383 | graphql: "GraphQL", | ||
| 384 | mcp: "MCP", | ||
| 385 | github: "GitHub", | ||
| 386 | }; | ||
| 387 | |||
| 388 | return deduped | ||
| 389 | .map((token) => { | ||
| 390 | const lower = token.toLowerCase(); | ||
| 391 | if (tokenMap[lower]) return tokenMap[lower]; | ||
| 392 | return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`; | ||
| 393 | }) | ||
| 394 | .join(" "); | ||
| 395 | } | ||
| 396 | |||
| 397 | function compactEndpointLabel(source: ToolSourceRecord): string { | ||
| 398 | const endpoint = sourceEndpointLabel(source); | ||
| 399 | if (endpoint.startsWith("catalog:")) return endpoint; | ||
| 400 | try { | ||
| 401 | const parsed = new URL(endpoint); | ||
| 402 | const path = parsed.pathname === "/" ? "" : parsed.pathname; | ||
| 403 | return `${parsed.hostname}${path}`; | ||
| 404 | } catch { | ||
| 405 | return endpoint; | ||
| 406 | } | ||
| 407 | } | ||
| 408 | |||
| 409 | // ── Add Source Dialog ── | ||
| 410 | |||
| 411 | const RAW_HOSTS = new Set([ | ||
| 412 | "raw.githubusercontent.com", | ||
| 413 | "cdn.jsdelivr.net", | ||
| 414 | "unpkg.com", | ||
| 415 | "raw.github.com", | ||
| 416 | ]); | ||
| 417 | |||
| 418 | function inferNameFromUrl(url: string): string { | ||
| 419 | try { | ||
| 420 | const u = new URL(url); | ||
| 421 | const parsed = parseDomain(url); | ||
| 422 | |||
| 423 | if (RAW_HOSTS.has(u.hostname)) { | ||
| 424 | const segments = u.pathname.split("/").filter(Boolean); | ||
| 425 | if (segments.length > 0) return segments[0].toLowerCase(); | ||
| 426 | } | ||
| 427 | |||
| 428 | if (parsed.domainWithoutSuffix) { | ||
| 429 | return parsed.domainWithoutSuffix; | ||
| 430 | } | ||
| 431 | |||
| 432 | if (parsed.domain) { | ||
| 433 | return parsed.domain.split(".")[0]; | ||
| 434 | } | ||
| 435 | |||
| 436 | return u.hostname.replace(/\./g, "-"); | ||
| 437 | } catch { | ||
| 438 | return ""; | ||
| 439 | } | ||
| 440 | } | ||
| 441 | |||
| 442 | function sanitizeSourceName(value: string): string { | ||
| 443 | const slug = value | ||
| 444 | .toLowerCase() | ||
| 445 | .replace(/[^a-z0-9]+/g, "-") | ||
| 446 | .replace(/-+/g, "-") | ||
| 447 | .replace(/^-|-$/g, "") | ||
| 448 | .slice(0, 48); | ||
| 449 | return slug || "source"; | ||
| 450 | } | ||
| 451 | |||
| 452 | function withUniqueSourceName(baseName: string, takenNames: Set<string>): string { | ||
| 453 | const loweredTaken = new Set([...takenNames].map((name) => name.toLowerCase())); | ||
| 454 | const candidate = sanitizeSourceName(baseName); | ||
| 455 | if (!loweredTaken.has(candidate.toLowerCase())) { | ||
| 456 | return candidate; | ||
| 457 | } | ||
| 458 | |||
| 459 | let suffix = 2; | ||
| 460 | while (true) { | ||
| 461 | const next = `${candidate}-${suffix}`; | ||
| 462 | if (!loweredTaken.has(next.toLowerCase())) { | ||
| 463 | return next; | ||
| 464 | } | ||
| 465 | suffix += 1; | ||
| 466 | } | ||
| 467 | } | ||
| 468 | |||
| 469 | function catalogSourceName(item: CatalogCollectionItem): string { | ||
| 470 | const owner = sanitizeSourceName(item.providerName || "catalog"); | ||
| 471 | const title = sanitizeSourceName(item.name); | ||
| 472 | return sanitizeSourceName(`${owner}-${title}`); | ||
| 473 | } | ||
| 474 | |||
| 475 | function AddSourceDialog({ | ||
| 476 | existingSourceNames, | ||
| 477 | onSourceAdded, | ||
| 478 | }: { | ||
| 479 | existingSourceNames: Set<string>; | ||
| 480 | onSourceAdded?: (source: ToolSourceRecord) => void; | ||
| 481 | }) { | ||
| 482 | const { context } = useSession(); | ||
| 483 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | ||
| 484 | const [open, setOpen] = useState(false); | ||
| 485 | const [view, setView] = useState<"catalog" | "custom">("catalog"); | ||
| 486 | const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp"); | ||
| 487 | const [name, setName] = useState(""); | ||
| 488 | const [nameManuallyEdited, setNameManuallyEdited] = useState(false); | ||
| 489 | const [endpoint, setEndpoint] = useState(""); | ||
| 490 | const [baseUrl, setBaseUrl] = useState(""); | ||
| 491 | const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto"); | ||
| 492 | const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId"); | ||
| 493 | const [submitting, setSubmitting] = useState(false); | ||
| 494 | const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]); | ||
| 495 | const [catalogQuery, setCatalogQuery] = useState(""); | ||
| 496 | const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular"); | ||
| 497 | const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null); | ||
| 498 | |||
| 499 | const visibleCatalogItems = useMemo(() => { | ||
| 500 | const query = catalogQuery.trim().toLowerCase(); | ||
| 501 | const filtered = HARD_CODED_CATALOG_ITEMS.filter((item) => { | ||
| 502 | if (!query) return true; | ||
| 503 | return [ | ||
| 504 | item.name, | ||
| 505 | item.providerName, | ||
| 506 | item.summary, | ||
| 507 | item.categories ?? "", | ||
| 508 | ].some((value) => value.toLowerCase().includes(query)); | ||
| 509 | }); | ||
| 510 | |||
| 511 | return [...filtered].sort((a, b) => { | ||
| 512 | if (catalogSort === "recent") { | ||
| 513 | return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime(); | ||
| 514 | } | ||
| 515 | return a.rank - b.rank; | ||
| 516 | }); | ||
| 517 | }, [catalogQuery, catalogSort]); | ||
| 518 | |||
| 519 | const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]); | ||
| 520 | |||
| 521 | const reserveSourceName = (sourceName: string) => { | ||
| 522 | setLocallyReservedNames((current) => | ||
| 523 | current.includes(sourceName) | ||
| 524 | ? current | ||
| 525 | : [...current, sourceName] | ||
| 526 | ); | ||
| 527 | }; | ||
| 528 | |||
| 529 | const getUniqueAutoSourceName = (candidate: string) => { | ||
| 530 | return withUniqueSourceName(candidate, getTakenSourceNames()); | ||
| 531 | }; | ||
| 532 | |||
| 533 | const handleEndpointChange = (value: string) => { | ||
| 534 | setEndpoint(value); | ||
| 535 | if (!nameManuallyEdited) { | ||
| 536 | const inferred = inferNameFromUrl(value); | ||
| 537 | if (inferred) setName(inferred); | ||
| 538 | } | ||
| 539 | }; | ||
| 540 | |||
| 541 | const handleNameChange = (value: string) => { | ||
| 542 | setName(value); | ||
| 543 | setNameManuallyEdited(true); | ||
| 544 | }; | ||
| 545 | |||
| 546 | const resetDialogState = () => { | ||
| 547 | setView("catalog"); | ||
| 548 | setType("mcp"); | ||
| 549 | setName(""); | ||
| 550 | setEndpoint(""); | ||
| 551 | setBaseUrl(""); | ||
| 552 | setMcpTransport("auto"); | ||
| 553 | setMcpActorQueryParamKey("userId"); | ||
| 554 | setNameManuallyEdited(false); | ||
| 555 | setLocallyReservedNames([]); | ||
| 556 | setCatalogQuery(""); | ||
| 557 | setCatalogSort("popular"); | ||
| 558 | setAddingCatalogId(null); | ||
| 559 | }; | ||
| 560 | |||
| 561 | const handleOpenChange = (isOpen: boolean) => { | ||
| 562 | setOpen(isOpen); | ||
| 563 | if (!isOpen) { | ||
| 564 | resetDialogState(); | ||
| 565 | } | ||
| 566 | }; | ||
| 567 | |||
| 568 | const addSource = async ( | ||
| 569 | sourceName: string, | ||
| 570 | sourceType: "mcp" | "openapi" | "graphql", | ||
| 571 | config: Record<string, unknown>, | ||
| 572 | ) => { | ||
| 573 | if (!context) return; | ||
| 574 | const created = await upsertToolSource({ | ||
| 575 | workspaceId: context.workspaceId, | ||
| 576 | sessionId: context.sessionId, | ||
| 577 | name: sourceName, | ||
| 578 | type: sourceType, | ||
| 579 | config, | ||
| 580 | }); | ||
| 581 | onSourceAdded?.(created as ToolSourceRecord); | ||
| 582 | toast.success(`Source "${sourceName}" added — loading tools…`); | ||
| 583 | }; | ||
| 584 | |||
| 585 | const handleCatalogAdd = async (item: CatalogCollectionItem) => { | ||
| 586 | if (!item.specUrl.trim()) { | ||
| 587 | toast.error("Missing OpenAPI spec URL for this API source"); | ||
| 588 | return; | ||
| 589 | } | ||
| 590 | |||
| 591 | setAddingCatalogId(item.id); | ||
| 592 | try { | ||
| 593 | const sourceName = getUniqueAutoSourceName(catalogSourceName(item)); | ||
| 594 | await addSource(sourceName, "openapi", { | ||
| 595 | spec: item.specUrl, | ||
| 596 | }); | ||
| 597 | reserveSourceName(sourceName); | ||
| 598 | } catch (error) { | ||
| 599 | toast.error(error instanceof Error ? error.message : "Failed to add API source"); | ||
| 600 | } finally { | ||
| 601 | setAddingCatalogId(null); | ||
| 602 | } | ||
| 603 | }; | ||
| 604 | |||
| 605 | const handleCustomSubmit = async () => { | ||
| 606 | if (!context || !name.trim() || !endpoint.trim()) return; | ||
| 607 | |||
| 608 | const takenNames = [...getTakenSourceNames()].map((entry) => entry.toLowerCase()); | ||
| 609 | if (takenNames.includes(name.trim().toLowerCase())) { | ||
| 610 | toast.error(`Source name "${name.trim()}" already exists`); | ||
| 611 | return; | ||
| 612 | } | ||
| 613 | |||
| 614 | setSubmitting(true); | ||
| 615 | try { | ||
| 616 | const config: Record<string, unknown> = | ||
| 617 | type === "mcp" | ||
| 618 | ? { | ||
| 619 | url: endpoint, | ||
| 620 | ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}), | ||
| 621 | ...(mcpActorQueryParamKey.trim() && context.actorId | ||
| 622 | ? { queryParams: { [mcpActorQueryParamKey.trim()]: context.actorId } } | ||
| 623 | : {}), | ||
| 624 | } | ||
| 625 | : type === "graphql" | ||
| 626 | ? { endpoint: endpoint } | ||
| 627 | : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; | ||
| 628 | await addSource(name.trim(), type, config); | ||
| 629 | reserveSourceName(name.trim()); | ||
| 630 | resetDialogState(); | ||
| 631 | setOpen(false); | ||
| 632 | } catch (err) { | ||
| 633 | toast.error(err instanceof Error ? err.message : "Failed to add source"); | ||
| 634 | } finally { | ||
| 635 | setSubmitting(false); | ||
| 636 | } | ||
| 637 | }; | ||
| 638 | |||
| 639 | return ( | ||
| 640 | <Dialog open={open} onOpenChange={handleOpenChange}> | ||
| 641 | <DialogTrigger asChild> | ||
| 642 | <Button size="sm" className="h-8 text-xs"> | ||
| 643 | <Plus className="h-3.5 w-3.5 mr-1.5" /> | ||
| 644 | Add Source | ||
| 645 | </Button> | ||
| 646 | </DialogTrigger> | ||
| 647 | <DialogContent className="bg-card border-border sm:max-w-md p-0 gap-0"> | ||
| 648 | <DialogHeader className="px-5 pt-5 pb-0"> | ||
| 649 | <DialogTitle className="text-sm font-medium"> | ||
| 650 | Add Tool Source | ||
| 651 | </DialogTitle> | ||
| 652 | </DialogHeader> | ||
| 653 | |||
| 654 | <div className="p-5 space-y-4"> | ||
| 655 | {view === "catalog" ? ( | ||
| 656 | <div className="space-y-3"> | ||
| 657 | <div className="flex items-center gap-2 flex-wrap"> | ||
| 658 | <Input | ||
| 659 | value={catalogQuery} | ||
| 660 | onChange={(event) => setCatalogQuery(event.target.value)} | ||
| 661 | placeholder="Search APIs" | ||
| 662 | className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]" | ||
| 663 | /> | ||
| 664 | <Select | ||
| 665 | value={catalogSort} | ||
| 666 | onValueChange={(value) => setCatalogSort(value as "popular" | "recent")} | ||
| 667 | > | ||
| 668 | <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0"> | ||
| 669 | <SelectValue /> | ||
| 670 | </SelectTrigger> | ||
| 671 | <SelectContent> | ||
| 672 | <SelectItem value="popular" className="text-xs">Popular</SelectItem> | ||
| 673 | <SelectItem value="recent" className="text-xs">Recent</SelectItem> | ||
| 674 | </SelectContent> | ||
| 675 | </Select> | ||
| 676 | </div> | ||
| 677 | |||
| 678 | <p className="text-[11px] text-muted-foreground"> | ||
| 679 | Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}. | ||
| 680 | </p> | ||
| 681 | |||
| 682 | <Separator /> | ||
| 683 | |||
| 684 | <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1"> | ||
| 685 | <button | ||
| 686 | type="button" | ||
| 687 | onClick={() => setView("custom")} | ||
| 688 | className="w-full max-w-full overflow-hidden text-left px-3 py-2 rounded-md border border-border/70 bg-muted/40 hover:bg-accent/30 transition-colors" | ||
| 689 | > | ||
| 690 | <div className="flex items-center gap-2"> | ||
| 691 | <span className="inline-flex h-6 w-6 items-center justify-center rounded bg-muted"> | ||
| 692 | <Plus className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 693 | </span> | ||
| 694 | <div className="min-w-0 w-0 flex-1 overflow-hidden"> | ||
| 695 | <p className="text-xs font-medium">Add custom source</p> | ||
| 696 | <p className="text-[10px] text-muted-foreground">MCP, OpenAPI, or GraphQL endpoint</p> | ||
| 697 | </div> | ||
| 698 | </div> | ||
| 699 | </button> | ||
| 700 | |||
| 701 | {visibleCatalogItems.map((item) => ( | ||
| 702 | <div | ||
| 703 | key={item.id} | ||
| 704 | className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50" | ||
| 705 | > | ||
| 706 | {item.logoUrl && ( | ||
| 707 | <img | ||
| 708 | src={item.logoUrl} | ||
| 709 | alt="" | ||
| 710 | className="w-5 h-5 rounded shrink-0 mt-0.5 object-contain" | ||
| 711 | loading="lazy" | ||
| 712 | /> | ||
| 713 | )} | ||
| 714 | <div className="flex-1 min-w-0 w-0 overflow-hidden"> | ||
| 715 | <p className="text-xs font-medium truncate">{item.name}</p> | ||
| 716 | <p className="text-[10px] text-muted-foreground truncate"> | ||
| 717 | {item.providerName} | ||
| 718 | {item.version ? ` · v${item.version}` : ""} | ||
| 719 | </p> | ||
| 720 | {item.summary && ( | ||
| 721 | <div | ||
| 722 | className="mt-0.5 w-full min-w-0 max-w-full overflow-hidden text-[10px] text-muted-foreground/90 leading-relaxed break-words [overflow-wrap:anywhere] [&_*]:max-w-full [&_*]:min-w-0 [&_*]:break-words [&_a]:break-all [&_a]:whitespace-normal [&_code]:break-all [&_code]:whitespace-pre-wrap [&_p]:m-0 [&_pre]:max-w-full [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:break-words line-clamp-2" | ||
| 723 | style={{ overflowWrap: "anywhere", wordBreak: "break-word" }} | ||
| 724 | > | ||
| 725 | <Streamdown controls={false}>{item.summary}</Streamdown> | ||
| 726 | </div> | ||
| 727 | )} | ||
| 728 | </div> | ||
| 729 | <div className="flex items-center gap-1 shrink-0"> | ||
| 730 | <Button | ||
| 731 | type="button" | ||
| 732 | size="sm" | ||
| 733 | className="h-7 px-2 text-[11px]" | ||
| 734 | onClick={() => void handleCatalogAdd(item)} | ||
| 735 | disabled={Boolean(addingCatalogId)} | ||
| 736 | > | ||
| 737 | {addingCatalogId === item.id ? "Adding..." : "Add"} | ||
| 738 | </Button> | ||
| 739 | </div> | ||
| 740 | </div> | ||
| 741 | ))} | ||
| 742 | |||
| 743 | {visibleCatalogItems.length === 0 && ( | ||
| 744 | <p className="text-[11px] text-muted-foreground px-1 py-1"> | ||
| 745 | No collections found for this query. | ||
| 746 | </p> | ||
| 747 | )} | ||
| 748 | </div> | ||
| 749 | </div> | ||
| 750 | ) : ( | ||
| 751 | <div className="space-y-3"> | ||
| 752 | <Button | ||
| 753 | type="button" | ||
| 754 | variant="ghost" | ||
| 755 | size="sm" | ||
| 756 | className="h-7 px-2 text-xs" | ||
| 757 | onClick={() => setView("catalog")} | ||
| 758 | > | ||
| 759 | <ChevronRight className="h-3.5 w-3.5 mr-1 rotate-180" /> | ||
| 760 | Back to API list | ||
| 761 | </Button> | ||
| 762 | |||
| 763 | <div className="space-y-1.5"> | ||
| 764 | <Label className="text-xs text-muted-foreground">Type</Label> | ||
| 765 | <Select | ||
| 766 | value={type} | ||
| 767 | onValueChange={(value) => setType(value as "mcp" | "openapi" | "graphql")} | ||
| 768 | > | ||
| 769 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 770 | <SelectValue /> | ||
| 771 | </SelectTrigger> | ||
| 772 | <SelectContent> | ||
| 773 | <SelectItem value="mcp" className="text-xs">MCP Server</SelectItem> | ||
| 774 | <SelectItem value="openapi" className="text-xs">OpenAPI Spec</SelectItem> | ||
| 775 | <SelectItem value="graphql" className="text-xs">GraphQL</SelectItem> | ||
| 776 | </SelectContent> | ||
| 777 | </Select> | ||
| 778 | </div> | ||
| 779 | |||
| 780 | <div className="space-y-1.5"> | ||
| 781 | <Label className="text-xs text-muted-foreground"> | ||
| 782 | {type === "mcp" ? "Endpoint URL" : type === "graphql" ? "GraphQL Endpoint" : "Spec URL"} | ||
| 783 | </Label> | ||
| 784 | <Input | ||
| 785 | value={endpoint} | ||
| 786 | onChange={(event) => handleEndpointChange(event.target.value)} | ||
| 787 | placeholder={ | ||
| 788 | type === "mcp" | ||
| 789 | ? "https://mcp-server.example.com/sse" | ||
| 790 | : type === "graphql" | ||
| 791 | ? "https://api.example.com/graphql" | ||
| 792 | : "https://api.example.com/openapi.json" | ||
| 793 | } | ||
| 794 | className="h-8 text-xs font-mono bg-background" | ||
| 795 | /> | ||
| 796 | </div> | ||
| 797 | |||
| 798 | <div className="space-y-1.5"> | ||
| 799 | <Label className="text-xs text-muted-foreground">Name</Label> | ||
| 800 | <Input | ||
| 801 | value={name} | ||
| 802 | onChange={(event) => handleNameChange(event.target.value)} | ||
| 803 | placeholder="e.g. my-service" | ||
| 804 | className="h-8 text-xs font-mono bg-background" | ||
| 805 | /> | ||
| 806 | </div> | ||
| 807 | |||
| 808 | {type === "openapi" && ( | ||
| 809 | <div className="space-y-1.5"> | ||
| 810 | <Label className="text-xs text-muted-foreground">Base URL (optional)</Label> | ||
| 811 | <Input | ||
| 812 | value={baseUrl} | ||
| 813 | onChange={(event) => setBaseUrl(event.target.value)} | ||
| 814 | placeholder="https://api.example.com" | ||
| 815 | className="h-8 text-xs font-mono bg-background" | ||
| 816 | /> | ||
| 817 | </div> | ||
| 818 | )} | ||
| 819 | |||
| 820 | {type === "mcp" && ( | ||
| 821 | <> | ||
| 822 | <div className="space-y-1.5"> | ||
| 823 | <Label className="text-xs text-muted-foreground">Transport</Label> | ||
| 824 | <Select | ||
| 825 | value={mcpTransport} | ||
| 826 | onValueChange={(value) => setMcpTransport(value as "auto" | "streamable-http" | "sse")} | ||
| 827 | > | ||
| 828 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 829 | <SelectValue /> | ||
| 830 | </SelectTrigger> | ||
| 831 | <SelectContent> | ||
| 832 | <SelectItem value="auto" className="text-xs">Auto (streamable, then SSE)</SelectItem> | ||
| 833 | <SelectItem value="streamable-http" className="text-xs">Streamable HTTP</SelectItem> | ||
| 834 | <SelectItem value="sse" className="text-xs">SSE</SelectItem> | ||
| 835 | </SelectContent> | ||
| 836 | </Select> | ||
| 837 | </div> | ||
| 838 | |||
| 839 | <div className="space-y-1.5"> | ||
| 840 | <Label className="text-xs text-muted-foreground">Anon actor query key (optional)</Label> | ||
| 841 | <Input | ||
| 842 | value={mcpActorQueryParamKey} | ||
| 843 | onChange={(event) => setMcpActorQueryParamKey(event.target.value)} | ||
| 844 | placeholder="userId" | ||
| 845 | className="h-8 text-xs font-mono bg-background" | ||
| 846 | /> | ||
| 847 | </div> | ||
| 848 | </> | ||
| 849 | )} | ||
| 850 | |||
| 851 | <Button | ||
| 852 | onClick={handleCustomSubmit} | ||
| 853 | disabled={submitting || !name.trim() || !endpoint.trim()} | ||
| 854 | className="w-full h-9" | ||
| 855 | size="sm" | ||
| 856 | > | ||
| 857 | {submitting ? "Adding..." : "Add Source"} | ||
| 858 | </Button> | ||
| 859 | </div> | ||
| 860 | )} | ||
| 861 | </div> | ||
| 862 | </DialogContent> | ||
| 863 | </Dialog> | ||
| 864 | ); | ||
| 865 | } | ||
| 866 | |||
| 867 | // ── Source Card ── | ||
| 868 | |||
| 869 | function ConfigureSourceAuthDialog({ | ||
| 870 | source, | ||
| 871 | inferredProfile, | ||
| 872 | }: { | ||
| 873 | source: ToolSourceRecord; | ||
| 874 | inferredProfile?: SourceAuthProfile; | ||
| 875 | }) { | ||
| 876 | const { context } = useSession(); | ||
| 877 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | ||
| 878 | const [open, setOpen] = useState(false); | ||
| 879 | const [saving, setSaving] = useState(false); | ||
| 880 | |||
| 881 | const currentAuth = readSourceAuth(source, inferredProfile); | ||
| 882 | const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type; | ||
| 883 | const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>(editableInitialAuthType); | ||
| 884 | const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace"); | ||
| 885 | const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key"); | ||
| 886 | |||
| 887 | const configurable = source.type === "openapi" || source.type === "graphql"; | ||
| 888 | |||
| 889 | const resetFromSource = () => { | ||
| 890 | const auth = readSourceAuth(source, inferredProfile); | ||
| 891 | setAuthType(auth.type === "mixed" ? "bearer" : auth.type); | ||
| 892 | setAuthMode(auth.mode ?? "workspace"); | ||
| 893 | setApiKeyHeader(auth.header ?? "x-api-key"); | ||
| 894 | }; | ||
| 895 | |||
| 896 | const handleOpenChange = (isOpen: boolean) => { | ||
| 897 | setOpen(isOpen); | ||
| 898 | if (isOpen) { | ||
| 899 | resetFromSource(); | ||
| 900 | } | ||
| 901 | }; | ||
| 902 | |||
| 903 | const handleSave = async () => { | ||
| 904 | if (!context || !configurable) return; | ||
| 905 | setSaving(true); | ||
| 906 | try { | ||
| 907 | const authConfig: Record<string, unknown> = | ||
| 908 | authType === "none" | ||
| 909 | ? { type: "none" } | ||
| 910 | : authType === "apiKey" | ||
| 911 | ? { type: "apiKey", mode: authMode, header: apiKeyHeader.trim() || "x-api-key" } | ||
| 912 | : { type: authType, mode: authMode }; | ||
| 913 | |||
| 914 | await upsertToolSource({ | ||
| 915 | id: source.id, | ||
| 916 | workspaceId: context.workspaceId, | ||
| 917 | sessionId: context.sessionId, | ||
| 918 | name: source.name, | ||
| 919 | type: source.type, | ||
| 920 | config: { | ||
| 921 | ...source.config, | ||
| 922 | auth: authConfig, | ||
| 923 | }, | ||
| 924 | }); | ||
| 925 | |||
| 926 | toast.success(`Updated auth for ${source.name}`); | ||
| 927 | setOpen(false); | ||
| 928 | } catch (err) { | ||
| 929 | toast.error(err instanceof Error ? err.message : "Failed to update auth"); | ||
| 930 | } finally { | ||
| 931 | setSaving(false); | ||
| 932 | } | ||
| 933 | }; | ||
| 934 | |||
| 935 | if (!configurable) { | ||
| 936 | return null; | ||
| 937 | } | ||
| 938 | |||
| 939 | return ( | ||
| 940 | <Dialog open={open} onOpenChange={handleOpenChange}> | ||
| 941 | <DialogTrigger asChild> | ||
| 942 | <Button variant="outline" size="sm" className="h-7 text-[11px]"> | ||
| 943 | <Pencil className="h-3 w-3 mr-1.5" /> | ||
| 944 | Auth | ||
| 945 | </Button> | ||
| 946 | </DialogTrigger> | ||
| 947 | <DialogContent className="bg-card border-border sm:max-w-md"> | ||
| 948 | <DialogHeader> | ||
| 949 | <DialogTitle className="text-sm font-medium">Configure Source Auth</DialogTitle> | ||
| 950 | </DialogHeader> | ||
| 951 | <div className="space-y-3"> | ||
| 952 | <div className="space-y-1.5"> | ||
| 953 | <Label className="text-xs text-muted-foreground">Source</Label> | ||
| 954 | <Input value={source.name} readOnly className="h-8 text-xs font-mono bg-background" /> | ||
| 955 | </div> | ||
| 956 | |||
| 957 | <div className="space-y-1.5"> | ||
| 958 | <Label className="text-xs text-muted-foreground">Auth Type</Label> | ||
| 959 | <Select value={authType} onValueChange={(value) => setAuthType(value as Exclude<SourceAuthType, "mixed">)}> | ||
| 960 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 961 | <SelectValue /> | ||
| 962 | </SelectTrigger> | ||
| 963 | <SelectContent> | ||
| 964 | <SelectItem value="none" className="text-xs">None</SelectItem> | ||
| 965 | <SelectItem value="bearer" className="text-xs">Bearer token</SelectItem> | ||
| 966 | <SelectItem value="apiKey" className="text-xs">API key header</SelectItem> | ||
| 967 | <SelectItem value="basic" className="text-xs">Basic auth</SelectItem> | ||
| 968 | </SelectContent> | ||
| 969 | </Select> | ||
| 970 | </div> | ||
| 971 | |||
| 972 | {currentAuth.inferred && ( | ||
| 973 | <p className="text-[11px] text-muted-foreground"> | ||
| 974 | Suggested from spec inference. Save to pin an explicit auth config. | ||
| 975 | </p> | ||
| 976 | )} | ||
| 977 | |||
| 978 | {authType !== "none" && ( | ||
| 979 | <> | ||
| 980 | <div className="space-y-1.5"> | ||
| 981 | <Label className="text-xs text-muted-foreground">Credential Scope</Label> | ||
| 982 | <Select value={authMode} onValueChange={(value) => setAuthMode(value as SourceAuthMode)}> | ||
| 983 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 984 | <SelectValue /> | ||
| 985 | </SelectTrigger> | ||
| 986 | <SelectContent> | ||
| 987 | <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | ||
| 988 | <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem> | ||
| 989 | </SelectContent> | ||
| 990 | </Select> | ||
| 991 | </div> | ||
| 992 | |||
| 993 | {authType === "apiKey" && ( | ||
| 994 | <div className="space-y-1.5"> | ||
| 995 | <Label className="text-xs text-muted-foreground">Header Name</Label> | ||
| 996 | <Input | ||
| 997 | value={apiKeyHeader} | ||
| 998 | onChange={(e) => setApiKeyHeader(e.target.value)} | ||
| 999 | placeholder="x-api-key" | ||
| 1000 | className="h-8 text-xs font-mono bg-background" | ||
| 1001 | /> | ||
| 1002 | </div> | ||
| 1003 | )} | ||
| 1004 | |||
| 1005 | <p className="text-[11px] text-muted-foreground"> | ||
| 1006 | Save this first, then add a connection in the Connections tab using source key | ||
| 1007 | <code className="ml-1">{sourceKeyForSource(source)}</code>. | ||
| 1008 | </p> | ||
| 1009 | </> | ||
| 1010 | )} | ||
| 1011 | |||
| 1012 | <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | ||
| 1013 | {saving ? "Saving..." : "Save Auth"} | ||
| 1014 | </Button> | ||
| 1015 | </div> | ||
| 1016 | </DialogContent> | ||
| 1017 | </Dialog> | ||
| 1018 | ); | ||
| 1019 | } | ||
| 1020 | |||
| 1021 | function SourceCard({ | ||
| 1022 | source, | ||
| 1023 | quality, | ||
| 1024 | qualityLoading, | ||
| 1025 | credentialStats, | ||
| 1026 | sourceAuthProfiles, | ||
| 1027 | selected = false, | ||
| 1028 | onFocusSource, | ||
| 1029 | }: { | ||
| 1030 | source: ToolSourceRecord; | ||
| 1031 | quality?: OpenApiSourceQuality; | ||
| 1032 | qualityLoading?: boolean; | ||
| 1033 | credentialStats: { workspaceCount: number; actorCount: number }; | ||
| 1034 | sourceAuthProfiles: Record<string, SourceAuthProfile>; | ||
| 1035 | selected?: boolean; | ||
| 1036 | onFocusSource?: (sourceName: string) => void; | ||
| 1037 | }) { | ||
| 1038 | const { context } = useSession(); | ||
| 1039 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); | ||
| 1040 | const [deleting, setDeleting] = useState(false); | ||
| 1041 | const [detailsOpen, setDetailsOpen] = useState(false); | ||
| 1042 | |||
| 1043 | const handleDelete = async () => { | ||
| 1044 | if (!context) return; | ||
| 1045 | setDeleting(true); | ||
| 1046 | try { | ||
| 1047 | await deleteToolSource({ | ||
| 1048 | workspaceId: context.workspaceId, | ||
| 1049 | sessionId: context.sessionId, | ||
| 1050 | sourceId: source.id, | ||
| 1051 | }); | ||
| 1052 | toast.success(`Removed "${source.name}"`); | ||
| 1053 | } catch (err) { | ||
| 1054 | toast.error(err instanceof Error ? err.message : "Failed to delete"); | ||
| 1055 | } finally { | ||
| 1056 | setDeleting(false); | ||
| 1057 | } | ||
| 1058 | }; | ||
| 1059 | |||
| 1060 | const TypeIcon = source.type === "mcp" ? Server : Globe; | ||
| 1061 | const favicon = getSourceFavicon(source); | ||
| 1062 | const sourceKey = sourceKeyForSource(source) ?? ""; | ||
| 1063 | const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]); | ||
| 1064 | const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]); | ||
| 1065 | const hasAuthConfigured = auth.type !== "none"; | ||
| 1066 | const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount; | ||
| 1067 | const prettyName = displaySourceName(source.name); | ||
| 1068 | const compactEndpoint = compactEndpointLabel(source); | ||
| 1069 | const showTypeSummary = source.type === "openapi" && (quality || qualityLoading); | ||
| 1070 | |||
| 1071 | return ( | ||
| 1072 | <div | ||
| 1073 | className={cn( | ||
| 1074 | "group flex items-start gap-3 rounded-lg border border-border/60 bg-gradient-to-b from-muted/45 to-muted/20 px-3 py-3", | ||
| 1075 | selected && "border-primary/35 bg-primary/5", | ||
| 1076 | )} | ||
| 1077 | > | ||
| 1078 | <div className="mt-0.5 h-9 w-9 rounded-md bg-muted/80 flex items-center justify-center shrink-0 overflow-hidden"> | ||
| 1079 | {favicon ? ( | ||
| 1080 | <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> | ||
| 1081 | ) : ( | ||
| 1082 | <TypeIcon className="h-4 w-4 text-muted-foreground" /> | ||
| 1083 | )} | ||
| 1084 | </div> | ||
| 1085 | <div className="flex-1 min-w-0"> | ||
| 1086 | <div className="flex items-center gap-2 min-w-0"> | ||
| 1087 | <span className="text-sm font-medium truncate" title={source.name}> | ||
| 1088 | {prettyName} | ||
| 1089 | </span> | ||
| 1090 | <Badge | ||
| 1091 | variant="outline" | ||
| 1092 | className="text-[9px] uppercase tracking-wide" | ||
| 1093 | > | ||
| 1094 | {source.type} | ||
| 1095 | </Badge> | ||
| 1096 | {!source.enabled && ( | ||
| 1097 | <Badge | ||
| 1098 | variant="outline" | ||
| 1099 | className="text-[9px] uppercase tracking-wide text-terminal-red border-terminal-red/30" | ||
| 1100 | > | ||
| 1101 | disabled | ||
| 1102 | </Badge> | ||
| 1103 | )} | ||
| 1104 | {authBadge && ( | ||
| 1105 | <Badge | ||
| 1106 | variant="outline" | ||
| 1107 | className="text-[9px] uppercase tracking-wide text-primary border-primary/30" | ||
| 1108 | > | ||
| 1109 | {authBadge} | ||
| 1110 | </Badge> | ||
| 1111 | )} | ||
| 1112 | {hasAuthConfigured && ( | ||
| 1113 | <Badge | ||
| 1114 | variant="outline" | ||
| 1115 | className={cn( | ||
| 1116 | "text-[9px] uppercase tracking-wide", | ||
| 1117 | totalCredentials > 0 | ||
| 1118 | ? "text-terminal-green border-terminal-green/30" | ||
| 1119 | : "text-terminal-amber border-terminal-amber/30", | ||
| 1120 | )} | ||
| 1121 | > | ||
| 1122 | {totalCredentials > 0 ? "connections ready" : "connection needed"} | ||
| 1123 | </Badge> | ||
| 1124 | )} | ||
| 1125 | </div> | ||
| 1126 | <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}> | ||
| 1127 | {compactEndpoint} | ||
| 1128 | </span> | ||
| 1129 | {showTypeSummary && ( | ||
| 1130 | <div className="mt-1.5 flex items-center gap-2"> | ||
| 1131 | {quality ? ( | ||
| 1132 | <Badge | ||
| 1133 | variant="outline" | ||
| 1134 | className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))} | ||
| 1135 | > | ||
| 1136 | {formatQualityPercent(quality.overallQuality)} {qualitySummaryLabel(quality)} | ||
| 1137 | </Badge> | ||
| 1138 | ) : ( | ||
| 1139 | <Badge variant="outline" className="text-[9px] uppercase tracking-wide text-muted-foreground"> | ||
| 1140 | analyzing type quality | ||
| 1141 | </Badge> | ||
| 1142 | )} | ||
| 1143 | </div> | ||
| 1144 | )} | ||
| 1145 | {source.type === "openapi" && ( | ||
| 1146 | <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}> | ||
| 1147 | <div className="mt-1.5"> | ||
| 1148 | <CollapsibleTrigger asChild> | ||
| 1149 | <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground"> | ||
| 1150 | <ChevronRight | ||
| 1151 | className={cn("mr-1 h-3 w-3 transition-transform", detailsOpen && "rotate-90")} | ||
| 1152 | /> | ||
| 1153 | {detailsOpen ? "Hide details" : "View details"} | ||
| 1154 | </Button> | ||
| 1155 | </CollapsibleTrigger> | ||
| 1156 | </div> | ||
| 1157 | <CollapsibleContent className="mt-1.5"> | ||
| 1158 | <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2"> | ||
| 1159 | {quality && ( | ||
| 1160 | <div className="space-y-1.5 text-[10px]"> | ||
| 1161 | <div className="flex items-center justify-between"> | ||
| 1162 | <span className="text-muted-foreground">Type quality</span> | ||
| 1163 | <span className={cn("font-medium", qualityToneClass(quality))}> | ||
| 1164 | {formatQualityPercent(quality.overallQuality)} | ||
| 1165 | </span> | ||
| 1166 | </div> | ||
| 1167 | <div className="flex items-center justify-between text-muted-foreground"> | ||
| 1168 | <span>Args quality</span> | ||
| 1169 | <span>{formatQualityPercent(quality.argsQuality)}</span> | ||
| 1170 | </div> | ||
| 1171 | <div className="flex items-center justify-between text-muted-foreground"> | ||
| 1172 | <span>Returns quality</span> | ||
| 1173 | <span>{formatQualityPercent(quality.returnsQuality)}</span> | ||
| 1174 | </div> | ||
| 1175 | <div className="flex items-center justify-between text-muted-foreground"> | ||
| 1176 | <span>Inferred returns</span> | ||
| 1177 | <span>{quality.unknownReturnsCount}</span> | ||
| 1178 | </div> | ||
| 1179 | </div> | ||
| 1180 | )} | ||
| 1181 | {!quality && qualityLoading && ( | ||
| 1182 | <div className="flex items-center justify-between text-[10px] text-muted-foreground"> | ||
| 1183 | <span>Analyzing OpenAPI typing</span> | ||
| 1184 | <span className="inline-flex items-center gap-1 text-[10px]"> | ||
| 1185 | <span className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-pulse" /> | ||
| 1186 | in progress | ||
| 1187 | </span> | ||
| 1188 | </div> | ||
| 1189 | )} | ||
| 1190 | {!quality && !qualityLoading && ( | ||
| 1191 | <div className="text-[10px] text-muted-foreground">Type quality data unavailable.</div> | ||
| 1192 | )} | ||
| 1193 | </div> | ||
| 1194 | </CollapsibleContent> | ||
| 1195 | </Collapsible> | ||
| 1196 | )} | ||
| 1197 | </div> | ||
| 1198 | <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> | ||
| 1199 | {onFocusSource ? ( | ||
| 1200 | <Button | ||
| 1201 | variant={selected ? "default" : "outline"} | ||
| 1202 | size="sm" | ||
| 1203 | className="h-7 text-[11px]" | ||
| 1204 | onClick={() => onFocusSource(source.name)} | ||
| 1205 | > | ||
| 1206 | {selected ? "Viewing" : "View tools"} | ||
| 1207 | </Button> | ||
| 1208 | ) : null} | ||
| 1209 | <ConfigureSourceAuthDialog source={source} inferredProfile={sourceAuthProfiles[sourceKey]} /> | ||
| 1210 | <Button | ||
| 1211 | variant="ghost" | ||
| 1212 | size="icon" | ||
| 1213 | className="h-7 w-7 text-muted-foreground hover:text-terminal-red" | ||
| 1214 | onClick={handleDelete} | ||
| 1215 | disabled={deleting} | ||
| 1216 | > | ||
| 1217 | <Trash2 className="h-3.5 w-3.5" /> | ||
| 1218 | </Button> | ||
| 1219 | </div> | ||
| 1220 | </div> | ||
| 1221 | ); | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | type SourceOption = { source: ToolSourceRecord; key: string; label: string }; | ||
| 1225 | |||
| 1226 | function sourceAuthForKey( | ||
| 1227 | sourceOptions: SourceOption[], | ||
| 1228 | key: string, | ||
| 1229 | inferredProfiles: Record<string, SourceAuthProfile> = {}, | ||
| 1230 | ): { | ||
| 1231 | type: SourceAuthType; | ||
| 1232 | mode?: SourceAuthMode; | ||
| 1233 | header?: string; | ||
| 1234 | inferred?: boolean; | ||
| 1235 | } { | ||
| 1236 | const match = sourceOptions.find((entry) => entry.key === key); | ||
| 1237 | if (!match) { | ||
| 1238 | return { type: "bearer" }; | ||
| 1239 | } | ||
| 1240 | return readSourceAuth(match.source, inferredProfiles[key]); | ||
| 1241 | } | ||
| 1242 | |||
| 1243 | function sourceOptionLabel(source: ToolSourceRecord): string { | ||
| 1244 | return `${source.name} (${source.type})`; | ||
| 1245 | } | ||
| 1246 | |||
| 1247 | function providerLabel(provider: "local-convex" | "workos-vault"): string { | ||
| 1248 | return provider === "workos-vault" ? "encrypted" : "local"; | ||
| 1249 | } | ||
| 1250 | |||
| 1251 | function connectionDisplayName( | ||
| 1252 | sources: ToolSourceRecord[], | ||
| 1253 | connection: { | ||
| 1254 | scope: CredentialScope; | ||
| 1255 | sourceKeys: Set<string>; | ||
| 1256 | actorId?: string; | ||
| 1257 | }, | ||
| 1258 | ): string { | ||
| 1259 | const sourceNames = [...connection.sourceKeys] | ||
| 1260 | .map((sourceKey) => sourceForCredentialKey(sources, sourceKey)) | ||
| 1261 | .filter((source): source is ToolSourceRecord => Boolean(source)) | ||
| 1262 | .map((source) => displaySourceName(source.name)); | ||
| 1263 | |||
| 1264 | const primary = sourceNames[0] ?? "API"; | ||
| 1265 | const extraCount = Math.max(sourceNames.length - 1, 0); | ||
| 1266 | const base = extraCount > 0 ? `${primary} +${extraCount}` : primary; | ||
| 1267 | |||
| 1268 | if (connection.scope === "actor") { | ||
| 1269 | if (connection.actorId) { | ||
| 1270 | return `${base} personal (${connection.actorId})`; | ||
| 1271 | } | ||
| 1272 | return `${base} personal`; | ||
| 1273 | } | ||
| 1274 | |||
| 1275 | return `${base} workspace`; | ||
| 1276 | } | ||
| 1277 | |||
| 1278 | function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } { | ||
| 1279 | const lines = text | ||
| 1280 | .split("\n") | ||
| 1281 | .map((line) => line.trim()) | ||
| 1282 | .filter((line) => line.length > 0); | ||
| 1283 | if (lines.length === 0) { | ||
| 1284 | return { value: {} }; | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | const headers: Record<string, string> = {}; | ||
| 1288 | for (const line of lines) { | ||
| 1289 | const separator = line.indexOf(":"); | ||
| 1290 | if (separator <= 0) { | ||
| 1291 | return { error: `Invalid header line: ${line}` }; | ||
| 1292 | } | ||
| 1293 | const name = line.slice(0, separator).trim(); | ||
| 1294 | const value = line.slice(separator + 1).trim(); | ||
| 1295 | if (!name || !value) { | ||
| 1296 | return { error: `Invalid header line: ${line}` }; | ||
| 1297 | } | ||
| 1298 | headers[name] = value; | ||
| 1299 | } | ||
| 1300 | |||
| 1301 | return { value: headers }; | ||
| 1302 | } | ||
| 1303 | |||
| 1304 | function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { | ||
| 1305 | const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; | ||
| 1306 | return Object.entries(headers) | ||
| 1307 | .map(([key, value]) => `${key}: ${String(value)}`) | ||
| 1308 | .join("\n"); | ||
| 1309 | } | ||
| 1310 | |||
| 1311 | function CredentialsPanel({ | ||
| 1312 | sources, | ||
| 1313 | credentials, | ||
| 1314 | sourceAuthProfiles, | ||
| 1315 | loading, | ||
| 1316 | focusSourceKey, | ||
| 1317 | onFocusHandled, | ||
| 1318 | }: { | ||
| 1319 | sources: ToolSourceRecord[]; | ||
| 1320 | credentials: CredentialRecord[]; | ||
| 1321 | sourceAuthProfiles: Record<string, SourceAuthProfile>; | ||
| 1322 | loading: boolean; | ||
| 1323 | focusSourceKey?: string | null; | ||
| 1324 | onFocusHandled?: () => void; | ||
| 1325 | }) { | ||
| 1326 | const { context, clientConfig } = useSession(); | ||
| 1327 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | ||
| 1328 | const [open, setOpen] = useState(false); | ||
| 1329 | const [saving, setSaving] = useState(false); | ||
| 1330 | const [editing, setEditing] = useState<CredentialRecord | null>(null); | ||
| 1331 | const [sourceKey, setSourceKey] = useState(""); | ||
| 1332 | const [scope, setScope] = useState<CredentialScope>("workspace"); | ||
| 1333 | const [actorId, setActorId] = useState(""); | ||
| 1334 | const [connectionMode, setConnectionMode] = useState<"new" | "existing">("new"); | ||
| 1335 | const [existingConnectionId, setExistingConnectionId] = useState(""); | ||
| 1336 | const [tokenValue, setTokenValue] = useState(""); | ||
| 1337 | const [apiKeyValue, setApiKeyValue] = useState(""); | ||
| 1338 | const [basicUsername, setBasicUsername] = useState(""); | ||
| 1339 | const [basicPassword, setBasicPassword] = useState(""); | ||
| 1340 | const [customHeadersText, setCustomHeadersText] = useState(""); | ||
| 1341 | |||
| 1342 | const storageCopy = clientConfig?.authProviderMode === "workos" | ||
| 1343 | ? "Stored encrypted" | ||
| 1344 | : "Stored locally on this machine"; | ||
| 1345 | |||
| 1346 | const sourceOptions = sources | ||
| 1347 | .map((source) => { | ||
| 1348 | const key = sourceKeyForSource(source); | ||
| 1349 | return { | ||
| 1350 | source, | ||
| 1351 | key, | ||
| 1352 | label: sourceOptionLabel(source), | ||
| 1353 | }; | ||
| 1354 | }) | ||
| 1355 | .filter((entry): entry is SourceOption => entry.key !== null); | ||
| 1356 | |||
| 1357 | const connectionOptions = useMemo(() => { | ||
| 1358 | const grouped = new Map<string, { | ||
| 1359 | id: string; | ||
| 1360 | scope: CredentialScope; | ||
| 1361 | actorId?: string; | ||
| 1362 | provider: "local-convex" | "workos-vault"; | ||
| 1363 | sourceKeys: Set<string>; | ||
| 1364 | updatedAt: number; | ||
| 1365 | }>(); | ||
| 1366 | |||
| 1367 | for (const credential of credentials) { | ||
| 1368 | const existing = grouped.get(credential.id); | ||
| 1369 | if (existing) { | ||
| 1370 | existing.sourceKeys.add(credential.sourceKey); | ||
| 1371 | existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt); | ||
| 1372 | } else { | ||
| 1373 | grouped.set(credential.id, { | ||
| 1374 | id: credential.id, | ||
| 1375 | scope: credential.scope, | ||
| 1376 | actorId: credential.actorId, | ||
| 1377 | provider: credential.provider, | ||
| 1378 | sourceKeys: new Set([credential.sourceKey]), | ||
| 1379 | updatedAt: credential.updatedAt, | ||
| 1380 | }); | ||
| 1381 | } | ||
| 1382 | } | ||
| 1383 | |||
| 1384 | return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt); | ||
| 1385 | }, [credentials]); | ||
| 1386 | |||
| 1387 | const representativeCredentialByConnection = useMemo(() => { | ||
| 1388 | const map = new Map<string, CredentialRecord>(); | ||
| 1389 | for (const credential of credentials) { | ||
| 1390 | if (!map.has(credential.id)) { | ||
| 1391 | map.set(credential.id, credential); | ||
| 1392 | } | ||
| 1393 | } | ||
| 1394 | return map; | ||
| 1395 | }, [credentials]); | ||
| 1396 | |||
| 1397 | const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey, sourceAuthProfiles); | ||
| 1398 | const compatibleConnectionOptions = connectionOptions.filter((connection) => { | ||
| 1399 | if (connection.scope !== scope) { | ||
| 1400 | return false; | ||
| 1401 | } | ||
| 1402 | if (scope === "actor") { | ||
| 1403 | return connection.actorId === actorId.trim(); | ||
| 1404 | } | ||
| 1405 | return true; | ||
| 1406 | }); | ||
| 1407 | const selectedAuthBadge = selectedAuth.type === "none" | ||
| 1408 | ? "none" | ||
| 1409 | : selectedAuth.type === "mixed" | ||
| 1410 | ? "mixed" | ||
| 1411 | : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`; | ||
| 1412 | |||
| 1413 | useEffect(() => { | ||
| 1414 | if (!existingConnectionId) { | ||
| 1415 | return; | ||
| 1416 | } | ||
| 1417 | if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { | ||
| 1418 | setExistingConnectionId(""); | ||
| 1419 | } | ||
| 1420 | }, [compatibleConnectionOptions, existingConnectionId]); | ||
| 1421 | |||
| 1422 | const resetForm = () => { | ||
| 1423 | const defaultSourceKey = sourceOptions[0]?.key ?? ""; | ||
| 1424 | setSourceKey(defaultSourceKey); | ||
| 1425 | const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey, sourceAuthProfiles); | ||
| 1426 | setScope(defaultAuth.mode ?? "workspace"); | ||
| 1427 | setActorId(context?.actorId ?? ""); | ||
| 1428 | setConnectionMode("new"); | ||
| 1429 | setExistingConnectionId(""); | ||
| 1430 | setTokenValue(""); | ||
| 1431 | setApiKeyValue(""); | ||
| 1432 | setBasicUsername(""); | ||
| 1433 | setBasicPassword(""); | ||
| 1434 | setCustomHeadersText(""); | ||
| 1435 | setEditing(null); | ||
| 1436 | }; | ||
| 1437 | |||
| 1438 | useEffect(() => { | ||
| 1439 | if (!focusSourceKey) { | ||
| 1440 | return; | ||
| 1441 | } | ||
| 1442 | resetForm(); | ||
| 1443 | setSourceKey(focusSourceKey); | ||
| 1444 | const auth = sourceAuthForKey(sourceOptions, focusSourceKey, sourceAuthProfiles); | ||
| 1445 | setScope(auth.mode ?? "workspace"); | ||
| 1446 | setOpen(true); | ||
| 1447 | onFocusHandled?.(); | ||
| 1448 | }, [focusSourceKey, onFocusHandled, sourceAuthProfiles, sourceOptions]); | ||
| 1449 | |||
| 1450 | const openForCreate = () => { | ||
| 1451 | resetForm(); | ||
| 1452 | setOpen(true); | ||
| 1453 | }; | ||
| 1454 | |||
| 1455 | const openForEdit = (credential: CredentialRecord) => { | ||
| 1456 | setEditing(credential); | ||
| 1457 | setSourceKey(credential.sourceKey); | ||
| 1458 | setScope(credential.scope); | ||
| 1459 | setActorId(credential.actorId ?? context?.actorId ?? ""); | ||
| 1460 | setConnectionMode("new"); | ||
| 1461 | setExistingConnectionId(credential.id); | ||
| 1462 | setTokenValue(""); | ||
| 1463 | setApiKeyValue(""); | ||
| 1464 | setBasicUsername(""); | ||
| 1465 | setBasicPassword(""); | ||
| 1466 | setCustomHeadersText(formatHeaderOverrides(credential.overridesJson)); | ||
| 1467 | setOpen(true); | ||
| 1468 | }; | ||
| 1469 | |||
| 1470 | const handleSourceKeyChange = (nextSourceKey: string) => { | ||
| 1471 | setSourceKey(nextSourceKey); | ||
| 1472 | const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles); | ||
| 1473 | if (!editing) { | ||
| 1474 | setScope(auth.mode ?? "workspace"); | ||
| 1475 | } | ||
| 1476 | }; | ||
| 1477 | |||
| 1478 | const handleSave = async () => { | ||
| 1479 | if (!context) return; | ||
| 1480 | if (!sourceKey.trim()) { | ||
| 1481 | toast.error("Source key is required"); | ||
| 1482 | return; | ||
| 1483 | } | ||
| 1484 | if (scope === "actor" && !actorId.trim()) { | ||
| 1485 | toast.error("Actor ID is required for actor-scoped credentials"); | ||
| 1486 | return; | ||
| 1487 | } | ||
| 1488 | |||
| 1489 | const parsedHeaders = parseHeaderOverrides(customHeadersText); | ||
| 1490 | if (!parsedHeaders.value) { | ||
| 1491 | toast.error(parsedHeaders.error ?? "Invalid header overrides"); | ||
| 1492 | return; | ||
| 1493 | } | ||
| 1494 | |||
| 1495 | const linkExisting = !editing && connectionMode === "existing"; | ||
| 1496 | if (linkExisting && !existingConnectionId) { | ||
| 1497 | toast.error("Select an existing connection to link"); | ||
| 1498 | return; | ||
| 1499 | } | ||
| 1500 | if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { | ||
| 1501 | toast.error("Selected connection does not match this scope"); | ||
| 1502 | return; | ||
| 1503 | } | ||
| 1504 | |||
| 1505 | if (selectedAuth.type === "none") { | ||
| 1506 | toast.error("This source does not require auth"); | ||
| 1507 | return; | ||
| 1508 | } | ||
| 1509 | |||
| 1510 | if (selectedAuth.type === "mixed" && !linkExisting && !editing) { | ||
| 1511 | toast.error("Mixed-auth sources must link to an existing connection"); | ||
| 1512 | return; | ||
| 1513 | } | ||
| 1514 | |||
| 1515 | const secretJson: Record<string, unknown> = {}; | ||
| 1516 | if (!linkExisting) { | ||
| 1517 | if (selectedAuth.type === "basic") { | ||
| 1518 | const hasUsername = basicUsername.trim().length > 0; | ||
| 1519 | const hasPassword = basicPassword.trim().length > 0; | ||
| 1520 | if (hasUsername || hasPassword) { | ||
| 1521 | if (!hasUsername || !hasPassword) { | ||
| 1522 | toast.error("Username and password are required for basic auth"); | ||
| 1523 | return; | ||
| 1524 | } | ||
| 1525 | secretJson.username = basicUsername; | ||
| 1526 | secretJson.password = basicPassword; | ||
| 1527 | } | ||
| 1528 | } else if (selectedAuth.type === "apiKey") { | ||
| 1529 | if (apiKeyValue.trim()) { | ||
| 1530 | secretJson.value = apiKeyValue.trim(); | ||
| 1531 | } | ||
| 1532 | } else if (selectedAuth.type === "bearer") { | ||
| 1533 | if (tokenValue.trim()) { | ||
| 1534 | secretJson.token = tokenValue.trim(); | ||
| 1535 | } | ||
| 1536 | } | ||
| 1537 | } | ||
| 1538 | |||
| 1539 | if (Object.keys(parsedHeaders.value).length > 0) { | ||
| 1540 | secretJson.__headers = parsedHeaders.value; | ||
| 1541 | } | ||
| 1542 | |||
| 1543 | if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) { | ||
| 1544 | if (selectedAuth.type === "basic") { | ||
| 1545 | toast.error("Username and password are required"); | ||
| 1546 | } else if (selectedAuth.type === "apiKey") { | ||
| 1547 | toast.error("API key value is required"); | ||
| 1548 | } else { | ||
| 1549 | toast.error("Token is required"); | ||
| 1550 | } | ||
| 1551 | return; | ||
| 1552 | } | ||
| 1553 | |||
| 1554 | setSaving(true); | ||
| 1555 | try { | ||
| 1556 | await upsertCredential({ | ||
| 1557 | ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}), | ||
| 1558 | workspaceId: context.workspaceId, | ||
| 1559 | sessionId: context.sessionId, | ||
| 1560 | sourceKey: sourceKey.trim(), | ||
| 1561 | scope, | ||
| 1562 | ...(scope === "actor" ? { actorId: actorId.trim() } : {}), | ||
| 1563 | secretJson, | ||
| 1564 | }); | ||
| 1565 | |||
| 1566 | toast.success(editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved"); | ||
| 1567 | setOpen(false); | ||
| 1568 | resetForm(); | ||
| 1569 | } catch (err) { | ||
| 1570 | toast.error(err instanceof Error ? err.message : "Failed to save connection"); | ||
| 1571 | } finally { | ||
| 1572 | setSaving(false); | ||
| 1573 | } | ||
| 1574 | }; | ||
| 1575 | |||
| 1576 | return ( | ||
| 1577 | <Card className="bg-card border-border"> | ||
| 1578 | <CardHeader className="pb-3"> | ||
| 1579 | <div className="flex items-center justify-between"> | ||
| 1580 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 1581 | <KeyRound className="h-4 w-4 text-muted-foreground" /> | ||
| 1582 | Connections | ||
| 1583 | </CardTitle> | ||
| 1584 | <Button size="sm" className="h-8 text-xs" onClick={openForCreate}> | ||
| 1585 | <Plus className="h-3.5 w-3.5 mr-1.5" /> | ||
| 1586 | Add Connection | ||
| 1587 | </Button> | ||
| 1588 | </div> | ||
| 1589 | </CardHeader> | ||
| 1590 | <CardContent className="pt-0 space-y-3"> | ||
| 1591 | {loading ? ( | ||
| 1592 | <div className="space-y-2"> | ||
| 1593 | {Array.from({ length: 3 }).map((_, i) => ( | ||
| 1594 | <Skeleton key={i} className="h-14" /> | ||
| 1595 | ))} | ||
| 1596 | </div> | ||
| 1597 | ) : connectionOptions.length === 0 ? ( | ||
| 1598 | <div className="flex flex-col items-center justify-center py-10 gap-2"> | ||
| 1599 | <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center"> | ||
| 1600 | <KeyRound className="h-5 w-5 text-muted-foreground/50" /> | ||
| 1601 | </div> | ||
| 1602 | <p className="text-sm text-muted-foreground">No connections configured</p> | ||
| 1603 | <p className="text-[11px] text-muted-foreground/70 text-center max-w-md"> | ||
| 1604 | Add a source, then create or link a reusable connection. | ||
| 1605 | </p> | ||
| 1606 | </div> | ||
| 1607 | ) : ( | ||
| 1608 | <div className="space-y-2"> | ||
| 1609 | {connectionOptions.map((connection) => { | ||
| 1610 | const representative = representativeCredentialByConnection.get(connection.id); | ||
| 1611 | if (!representative) return null; | ||
| 1612 | const firstSource = sourceForCredentialKey(sources, representative.sourceKey); | ||
| 1613 | const favicon = firstSource ? getSourceFavicon(firstSource) : null; | ||
| 1614 | |||
| 1615 | return ( | ||
| 1616 | <div | ||
| 1617 | key={connection.id} | ||
| 1618 | className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" | ||
| 1619 | > | ||
| 1620 | <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> | ||
| 1621 | {favicon ? ( | ||
| 1622 | <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> | ||
| 1623 | ) : ( | ||
| 1624 | <KeyRound className="h-4 w-4 text-muted-foreground" /> | ||
| 1625 | )} | ||
| 1626 | </div> | ||
| 1627 | <div className="flex-1 min-w-0"> | ||
| 1628 | <div className="flex items-center gap-1.5 mb-1 flex-wrap"> | ||
| 1629 | <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span> | ||
| 1630 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | ||
| 1631 | {connection.scope} | ||
| 1632 | </Badge> | ||
| 1633 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | ||
| 1634 | {providerLabel(connection.provider)} | ||
| 1635 | </Badge> | ||
| 1636 | {connection.scope === "actor" && connection.actorId && ( | ||
| 1637 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | ||
| 1638 | {connection.actorId} | ||
| 1639 | </span> | ||
| 1640 | )} | ||
| 1641 | </div> | ||
| 1642 | <p className="text-[11px] text-muted-foreground"> | ||
| 1643 | Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} • {storageCopy} | ||
| 1644 | </p> | ||
| 1645 | <p className="text-[11px] text-muted-foreground mt-0.5"> | ||
| 1646 | ID: <span className="font-mono">{connection.id}</span> • | ||
| 1647 | {" "} | ||
| 1648 | Updated {new Date(connection.updatedAt).toLocaleString()} | ||
| 1649 | </p> | ||
| 1650 | </div> | ||
| 1651 | <Button | ||
| 1652 | variant="outline" | ||
| 1653 | size="sm" | ||
| 1654 | className="h-7 text-[11px]" | ||
| 1655 | onClick={() => openForEdit(representative)} | ||
| 1656 | > | ||
| 1657 | Edit | ||
| 1658 | </Button> | ||
| 1659 | </div> | ||
| 1660 | ); | ||
| 1661 | })} | ||
| 1662 | </div> | ||
| 1663 | )} | ||
| 1664 | </CardContent> | ||
| 1665 | |||
| 1666 | <Dialog open={open} onOpenChange={setOpen}> | ||
| 1667 | <DialogContent className="bg-card border-border sm:max-w-lg"> | ||
| 1668 | <DialogHeader> | ||
| 1669 | <DialogTitle className="text-sm font-medium"> | ||
| 1670 | {editing ? "Edit Connection" : "Add Connection"} | ||
| 1671 | </DialogTitle> | ||
| 1672 | </DialogHeader> | ||
| 1673 | <div className="space-y-3"> | ||
| 1674 | <div className="space-y-1.5"> | ||
| 1675 | <Label className="text-xs text-muted-foreground">Source</Label> | ||
| 1676 | {sourceOptions.length > 0 ? ( | ||
| 1677 | <Select value={sourceKey} onValueChange={handleSourceKeyChange}> | ||
| 1678 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 1679 | <SelectValue /> | ||
| 1680 | </SelectTrigger> | ||
| 1681 | <SelectContent> | ||
| 1682 | {sourceOptions.map((entry) => ( | ||
| 1683 | <SelectItem key={entry.key} value={entry.key} className="text-xs"> | ||
| 1684 | {entry.label} | ||
| 1685 | </SelectItem> | ||
| 1686 | ))} | ||
| 1687 | </SelectContent> | ||
| 1688 | </Select> | ||
| 1689 | ) : ( | ||
| 1690 | <Input | ||
| 1691 | value={sourceKey} | ||
| 1692 | onChange={(e) => handleSourceKeyChange(e.target.value)} | ||
| 1693 | placeholder="source:<source-id>" | ||
| 1694 | className="h-8 text-xs font-mono bg-background" | ||
| 1695 | /> | ||
| 1696 | )} | ||
| 1697 | {sourceKey && ( | ||
| 1698 | <p className="text-[10px] text-muted-foreground font-mono">key: {sourceKey}</p> | ||
| 1699 | )} | ||
| 1700 | </div> | ||
| 1701 | |||
| 1702 | <div className="flex items-center gap-2 flex-wrap"> | ||
| 1703 | <span className="text-[11px] text-muted-foreground">Detected auth</span> | ||
| 1704 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | ||
| 1705 | {selectedAuthBadge} | ||
| 1706 | </Badge> | ||
| 1707 | {selectedAuth.inferred && ( | ||
| 1708 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | ||
| 1709 | inferred | ||
| 1710 | </Badge> | ||
| 1711 | )} | ||
| 1712 | {selectedAuth.type === "apiKey" && selectedAuth.header && ( | ||
| 1713 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | ||
| 1714 | header: {selectedAuth.header} | ||
| 1715 | </span> | ||
| 1716 | )} | ||
| 1717 | </div> | ||
| 1718 | |||
| 1719 | <div className="grid grid-cols-2 gap-3"> | ||
| 1720 | <div className="space-y-1.5"> | ||
| 1721 | <Label className="text-xs text-muted-foreground">Scope</Label> | ||
| 1722 | <Select value={scope} onValueChange={(value) => setScope(value as CredentialScope)}> | ||
| 1723 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 1724 | <SelectValue /> | ||
| 1725 | </SelectTrigger> | ||
| 1726 | <SelectContent> | ||
| 1727 | <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | ||
| 1728 | <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem> | ||
| 1729 | </SelectContent> | ||
| 1730 | </Select> | ||
| 1731 | </div> | ||
| 1732 | <div className="space-y-1.5"> | ||
| 1733 | <Label className="text-xs text-muted-foreground">Storage</Label> | ||
| 1734 | <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" /> | ||
| 1735 | </div> | ||
| 1736 | </div> | ||
| 1737 | |||
| 1738 | {scope === "actor" && ( | ||
| 1739 | <div className="space-y-1.5"> | ||
| 1740 | <Label className="text-xs text-muted-foreground">Actor ID</Label> | ||
| 1741 | <Input | ||
| 1742 | value={actorId} | ||
| 1743 | onChange={(e) => setActorId(e.target.value)} | ||
| 1744 | placeholder="actor_123" | ||
| 1745 | className="h-8 text-xs font-mono bg-background" | ||
| 1746 | /> | ||
| 1747 | </div> | ||
| 1748 | )} | ||
| 1749 | |||
| 1750 | {!editing && connectionOptions.length > 0 && ( | ||
| 1751 | <div className="space-y-1.5"> | ||
| 1752 | <Label className="text-xs text-muted-foreground">Connection Mode</Label> | ||
| 1753 | <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as "new" | "existing") }> | ||
| 1754 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 1755 | <SelectValue /> | ||
| 1756 | </SelectTrigger> | ||
| 1757 | <SelectContent> | ||
| 1758 | <SelectItem value="new" className="text-xs">Create new connection</SelectItem> | ||
| 1759 | <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem> | ||
| 1760 | </SelectContent> | ||
| 1761 | </Select> | ||
| 1762 | </div> | ||
| 1763 | )} | ||
| 1764 | |||
| 1765 | {!editing && connectionMode === "existing" && ( | ||
| 1766 | <div className="space-y-1.5"> | ||
| 1767 | <Label className="text-xs text-muted-foreground">Existing Connection</Label> | ||
| 1768 | <Select value={existingConnectionId} onValueChange={setExistingConnectionId}> | ||
| 1769 | <SelectTrigger className="h-8 text-xs bg-background"> | ||
| 1770 | <SelectValue placeholder="Select a connection" /> | ||
| 1771 | </SelectTrigger> | ||
| 1772 | <SelectContent> | ||
| 1773 | {compatibleConnectionOptions.map((connection) => ( | ||
| 1774 | <SelectItem key={connection.id} value={connection.id} className="text-xs"> | ||
| 1775 | {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"}) | ||
| 1776 | </SelectItem> | ||
| 1777 | ))} | ||
| 1778 | </SelectContent> | ||
| 1779 | </Select> | ||
| 1780 | {compatibleConnectionOptions.length === 0 && ( | ||
| 1781 | <p className="text-[10px] text-muted-foreground">No compatible existing connections for this scope.</p> | ||
| 1782 | )} | ||
| 1783 | </div> | ||
| 1784 | )} | ||
| 1785 | |||
| 1786 | {(editing || connectionMode === "new") && ( | ||
| 1787 | <> | ||
| 1788 | {editing && ( | ||
| 1789 | <p className="text-[11px] text-muted-foreground"> | ||
| 1790 | Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it. | ||
| 1791 | </p> | ||
| 1792 | )} | ||
| 1793 | |||
| 1794 | {selectedAuth.type === "none" ? ( | ||
| 1795 | <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p> | ||
| 1796 | ) : selectedAuth.type === "mixed" ? ( | ||
| 1797 | <p className="text-[11px] text-terminal-amber"> | ||
| 1798 | This source has mixed auth requirements. Link an existing connection for now. | ||
| 1799 | </p> | ||
| 1800 | ) : selectedAuth.type === "apiKey" ? ( | ||
| 1801 | <div className="space-y-1.5"> | ||
| 1802 | <Label className="text-xs text-muted-foreground">API Key Value</Label> | ||
| 1803 | <Input | ||
| 1804 | value={apiKeyValue} | ||
| 1805 | onChange={(e) => setApiKeyValue(e.target.value)} | ||
| 1806 | placeholder="sk_live_..." | ||
| 1807 | className="h-8 text-xs font-mono bg-background" | ||
| 1808 | /> | ||
| 1809 | </div> | ||
| 1810 | ) : selectedAuth.type === "basic" ? ( | ||
| 1811 | <div className="grid grid-cols-2 gap-3"> | ||
| 1812 | <div className="space-y-1.5"> | ||
| 1813 | <Label className="text-xs text-muted-foreground">Username</Label> | ||
| 1814 | <Input | ||
| 1815 | value={basicUsername} | ||
| 1816 | onChange={(e) => setBasicUsername(e.target.value)} | ||
| 1817 | className="h-8 text-xs font-mono bg-background" | ||
| 1818 | /> | ||
| 1819 | </div> | ||
| 1820 | <div className="space-y-1.5"> | ||
| 1821 | <Label className="text-xs text-muted-foreground">Password</Label> | ||
| 1822 | <Input | ||
| 1823 | type="password" | ||
| 1824 | value={basicPassword} | ||
| 1825 | onChange={(e) => setBasicPassword(e.target.value)} | ||
| 1826 | className="h-8 text-xs font-mono bg-background" | ||
| 1827 | /> | ||
| 1828 | </div> | ||
| 1829 | </div> | ||
| 1830 | ) : ( | ||
| 1831 | <div className="space-y-1.5"> | ||
| 1832 | <Label className="text-xs text-muted-foreground">Bearer Token</Label> | ||
| 1833 | <Input | ||
| 1834 | type="password" | ||
| 1835 | value={tokenValue} | ||
| 1836 | onChange={(e) => setTokenValue(e.target.value)} | ||
| 1837 | placeholder="ghp_..." | ||
| 1838 | className="h-8 text-xs font-mono bg-background" | ||
| 1839 | /> | ||
| 1840 | </div> | ||
| 1841 | )} | ||
| 1842 | </> | ||
| 1843 | )} | ||
| 1844 | |||
| 1845 | <div className="space-y-1.5"> | ||
| 1846 | <Label className="text-xs text-muted-foreground">Custom Headers (optional)</Label> | ||
| 1847 | <Textarea | ||
| 1848 | value={customHeadersText} | ||
| 1849 | onChange={(e) => setCustomHeadersText(e.target.value)} | ||
| 1850 | rows={4} | ||
| 1851 | placeholder="x-tenant-id: acme\nx-env: staging" | ||
| 1852 | className="text-xs font-mono bg-background" | ||
| 1853 | /> | ||
| 1854 | </div> | ||
| 1855 | |||
| 1856 | <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | ||
| 1857 | {saving ? "Saving..." : editing ? "Update Connection" : connectionMode === "existing" ? "Link Connection" : "Save Connection"} | ||
| 1858 | </Button> | ||
| 1859 | </div> | ||
| 1860 | </DialogContent> | ||
| 1861 | </Dialog> | ||
| 1862 | </Card> | ||
| 1863 | ); | ||
| 1864 | } | ||
| 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; |
| @@ -0,0 +1,351 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useMemo, useState, type FormEvent } from "react"; | ||
| 4 | import { useNavigate } from "react-router"; | ||
| 5 | import { useMutation } from "convex/react"; | ||
| 6 | import { ChevronsUpDown, Plus, Check, Settings } from "lucide-react"; | ||
| 7 | import Image from "next/image"; | ||
| 8 | import { Button } from "@/components/ui/button"; | ||
| 9 | import { | ||
| 10 | DropdownMenu, | ||
| 11 | DropdownMenuContent, | ||
| 12 | DropdownMenuItem, | ||
| 13 | DropdownMenuSeparator, | ||
| 14 | DropdownMenuTrigger, | ||
| 15 | } from "@/components/ui/dropdown-menu"; | ||
| 16 | import { | ||
| 17 | Dialog, | ||
| 18 | DialogContent, | ||
| 19 | DialogDescription, | ||
| 20 | DialogFooter, | ||
| 21 | DialogHeader, | ||
| 22 | DialogTitle, | ||
| 23 | } from "@/components/ui/dialog"; | ||
| 24 | import { Input } from "@/components/ui/input"; | ||
| 25 | import { useSession } from "@/lib/session-context"; | ||
| 26 | import { convexApi } from "@/lib/convex-api"; | ||
| 27 | import { cn } from "@/lib/utils"; | ||
| 28 | |||
| 29 | export function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { | ||
| 30 | const navigate = useNavigate(); | ||
| 31 | const createOrganizationMutation = useMutation(convexApi.organizations.create); | ||
| 32 | const { | ||
| 33 | context, | ||
| 34 | mode, | ||
| 35 | workspaces, | ||
| 36 | switchWorkspace, | ||
| 37 | creatingWorkspace, | ||
| 38 | createWorkspace, | ||
| 39 | } = useSession(); | ||
| 40 | const [createOpen, setCreateOpen] = useState(false); | ||
| 41 | const [newWorkspaceName, setNewWorkspaceName] = useState(""); | ||
| 42 | const [newWorkspaceIcon, setNewWorkspaceIcon] = useState<File | null>(null); | ||
| 43 | const [createScope, setCreateScope] = useState<"current_org" | "new_org">("current_org"); | ||
| 44 | const [createTargetOrganizationId, setCreateTargetOrganizationId] = useState<(typeof workspaces)[number]["organizationId"]>(null); | ||
| 45 | const [createTargetOrganizationName, setCreateTargetOrganizationName] = useState<string | null>(null); | ||
| 46 | const [creatingOrganization, setCreatingOrganization] = useState(false); | ||
| 47 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 48 | |||
| 49 | const activeWorkspace = context | ||
| 50 | ? workspaces.find((workspace) => workspace.id === context.workspaceId) | ||
| 51 | : null; | ||
| 52 | const supportsOrganizationManagement = mode !== "guest"; | ||
| 53 | const activeOrganizationLabel = activeWorkspace?.organizationName ?? "Organization"; | ||
| 54 | const activeWorkspaceLabel = activeWorkspace?.name ?? (mode === "guest" ? "Anonymous Workspace" : "Select workspace"); | ||
| 55 | const activeWorkspaceInitial = (activeWorkspaceLabel[0] ?? "W").toUpperCase(); | ||
| 56 | |||
| 57 | const workspaceGroups = useMemo(() => { | ||
| 58 | if (!supportsOrganizationManagement) { | ||
| 59 | return []; | ||
| 60 | } | ||
| 61 | |||
| 62 | const groups: Array<{ | ||
| 63 | key: string; | ||
| 64 | organizationId: (typeof workspaces)[number]["organizationId"]; | ||
| 65 | organizationName: string; | ||
| 66 | workspaces: Array<(typeof workspaces)[number]>; | ||
| 67 | }> = []; | ||
| 68 | const byKey = new Map<string, number>(); | ||
| 69 | |||
| 70 | for (const workspace of workspaces) { | ||
| 71 | const key = workspace.organizationId ? `org:${workspace.organizationId}` : `workspace:${workspace.id}`; | ||
| 72 | const existingIndex = byKey.get(key); | ||
| 73 | if (existingIndex === undefined) { | ||
| 74 | byKey.set(key, groups.length); | ||
| 75 | groups.push({ | ||
| 76 | key, | ||
| 77 | organizationId: workspace.organizationId, | ||
| 78 | organizationName: workspace.organizationName, | ||
| 79 | workspaces: [workspace], | ||
| 80 | }); | ||
| 81 | continue; | ||
| 82 | } | ||
| 83 | |||
| 84 | groups[existingIndex].workspaces.push(workspace); | ||
| 85 | } | ||
| 86 | |||
| 87 | return groups; | ||
| 88 | }, [supportsOrganizationManagement, workspaces]); | ||
| 89 | |||
| 90 | const openCreateWorkspaceForOrganization = ( | ||
| 91 | organizationId: (typeof workspaces)[number]["organizationId"], | ||
| 92 | organizationName: string, | ||
| 93 | ) => { | ||
| 94 | setCreateError(null); | ||
| 95 | setNewWorkspaceName(""); | ||
| 96 | setNewWorkspaceIcon(null); | ||
| 97 | setCreateScope("current_org"); | ||
| 98 | setCreateTargetOrganizationId(organizationId); | ||
| 99 | setCreateTargetOrganizationName(organizationName); | ||
| 100 | setCreateOpen(true); | ||
| 101 | }; | ||
| 102 | |||
| 103 | const openCreateOrganization = () => { | ||
| 104 | setCreateError(null); | ||
| 105 | setNewWorkspaceName(""); | ||
| 106 | setNewWorkspaceIcon(null); | ||
| 107 | setCreateScope("new_org"); | ||
| 108 | setCreateTargetOrganizationId(null); | ||
| 109 | setCreateTargetOrganizationName(null); | ||
| 110 | setCreateOpen(true); | ||
| 111 | }; | ||
| 112 | |||
| 113 | const handleCreateWorkspace = async (event?: FormEvent<HTMLFormElement>) => { | ||
| 114 | event?.preventDefault(); | ||
| 115 | |||
| 116 | const trimmed = newWorkspaceName.trim(); | ||
| 117 | if (trimmed.length < 2) { | ||
| 118 | setCreateError("Workspace name must be at least 2 characters."); | ||
| 119 | return; | ||
| 120 | } | ||
| 121 | |||
| 122 | try { | ||
| 123 | if (createScope === "new_org") { | ||
| 124 | setCreatingOrganization(true); | ||
| 125 | const created = await createOrganizationMutation({ name: trimmed }); | ||
| 126 | switchWorkspace(created.workspace.id); | ||
| 127 | } else { | ||
| 128 | if (!createTargetOrganizationId) { | ||
| 129 | throw new Error("Select an organization first"); | ||
| 130 | } | ||
| 131 | |||
| 132 | await createWorkspace(trimmed, newWorkspaceIcon, createTargetOrganizationId); | ||
| 133 | } | ||
| 134 | |||
| 135 | setCreateError(null); | ||
| 136 | setNewWorkspaceName(""); | ||
| 137 | setNewWorkspaceIcon(null); | ||
| 138 | setCreateOpen(false); | ||
| 139 | } catch (cause) { | ||
| 140 | const fallback = createScope === "new_org" ? "Failed to create organization" : "Failed to create workspace"; | ||
| 141 | setCreateError(cause instanceof Error ? cause.message : fallback); | ||
| 142 | } finally { | ||
| 143 | setCreatingOrganization(false); | ||
| 144 | } | ||
| 145 | }; | ||
| 146 | |||
| 147 | const triggerClassName = inHeader | ||
| 148 | ? "h-full w-full justify-between rounded-none border-0 bg-transparent px-3 text-[12px] font-medium shadow-none hover:bg-accent/40" | ||
| 149 | : "h-8 w-full justify-between text-[11px]"; | ||
| 150 | const isCreating = creatingWorkspace || creatingOrganization; | ||
| 151 | |||
| 152 | return ( | ||
| 153 | <> | ||
| 154 | <DropdownMenu> | ||
| 155 | <DropdownMenuTrigger asChild> | ||
| 156 | <Button | ||
| 157 | variant={inHeader ? "ghost" : "outline"} | ||
| 158 | size="sm" | ||
| 159 | className={triggerClassName} | ||
| 160 | > | ||
| 161 | <span className="flex items-center gap-2 min-w-0"> | ||
| 162 | {activeWorkspace?.iconUrl ? ( | ||
| 163 | <Image | ||
| 164 | src={activeWorkspace.iconUrl} | ||
| 165 | alt={activeWorkspaceLabel} | ||
| 166 | width={16} | ||
| 167 | height={16} | ||
| 168 | className="h-4 w-4 rounded-sm border border-border object-cover" | ||
| 169 | unoptimized | ||
| 170 | /> | ||
| 171 | ) : ( | ||
| 172 | <span className="h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground"> | ||
| 173 | {activeWorkspaceInitial} | ||
| 174 | </span> | ||
| 175 | )} | ||
| 176 | <span className="min-w-0"> | ||
| 177 | {supportsOrganizationManagement ? ( | ||
| 178 | <> | ||
| 179 | <span className="truncate block text-[10px] text-muted-foreground">{activeOrganizationLabel}</span> | ||
| 180 | <span className="truncate block">{activeWorkspaceLabel}</span> | ||
| 181 | </> | ||
| 182 | ) : ( | ||
| 183 | <span className="truncate block">{activeWorkspaceLabel}</span> | ||
| 184 | )} | ||
| 185 | </span> | ||
| 186 | </span> | ||
| 187 | <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" /> | ||
| 188 | </Button> | ||
| 189 | </DropdownMenuTrigger> | ||
| 190 | <DropdownMenuContent align="end" className="w-72"> | ||
| 191 | {supportsOrganizationManagement | ||
| 192 | ? ( | ||
| 193 | <> | ||
| 194 | {workspaceGroups.map((group, groupIndex) => { | ||
| 195 | const settingsWorkspace = group.workspaces.find((workspace) => workspace.id === context?.workspaceId) | ||
| 196 | ?? group.workspaces[0] | ||
| 197 | ?? null; | ||
| 198 | |||
| 199 | return ( | ||
| 200 | <div key={group.key}> | ||
| 201 | {groupIndex > 0 ? <DropdownMenuSeparator /> : null} | ||
| 202 | <div className="px-2 py-1.5 flex items-center justify-between gap-2"> | ||
| 203 | <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground truncate"> | ||
| 204 | {group.organizationName} | ||
| 205 | </span> | ||
| 206 | {settingsWorkspace ? ( | ||
| 207 | <button | ||
| 208 | type="button" | ||
| 209 | className="inline-flex h-5 w-5 items-center justify-center rounded-sm border border-border text-muted-foreground hover:bg-accent hover:text-foreground" | ||
| 210 | onClick={() => { | ||
| 211 | switchWorkspace(settingsWorkspace.id); | ||
| 212 | navigate("/organization?tab=members"); | ||
| 213 | }} | ||
| 214 | aria-label={`Open ${group.organizationName} settings`} | ||
| 215 | title="Organization settings" | ||
| 216 | > | ||
| 217 | <Settings className="h-3 w-3" /> | ||
| 218 | </button> | ||
| 219 | ) : null} | ||
| 220 | </div> | ||
| 221 | {group.workspaces.map((workspace) => { | ||
| 222 | const isActive = workspace.id === context?.workspaceId; | ||
| 223 | return ( | ||
| 224 | <DropdownMenuItem | ||
| 225 | key={workspace.id} | ||
| 226 | onSelect={() => switchWorkspace(workspace.id)} | ||
| 227 | className="text-xs" | ||
| 228 | > | ||
| 229 | <Check className={cn("mr-2 h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} /> | ||
| 230 | {workspace.iconUrl ? ( | ||
| 231 | <Image | ||
| 232 | src={workspace.iconUrl} | ||
| 233 | alt={workspace.name} | ||
| 234 | width={16} | ||
| 235 | height={16} | ||
| 236 | className="mr-2 h-4 w-4 rounded-sm border border-border object-cover" | ||
| 237 | unoptimized | ||
| 238 | /> | ||
| 239 | ) : ( | ||
| 240 | <span className="mr-2 h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground"> | ||
| 241 | {(workspace.name[0] ?? "W").toUpperCase()} | ||
| 242 | </span> | ||
| 243 | )} | ||
| 244 | <span className="min-w-0 truncate">{workspace.name}</span> | ||
| 245 | </DropdownMenuItem> | ||
| 246 | ); | ||
| 247 | })} | ||
| 248 | |||
| 249 | {group.organizationId ? ( | ||
| 250 | <DropdownMenuItem | ||
| 251 | onSelect={() => openCreateWorkspaceForOrganization(group.organizationId, group.organizationName)} | ||
| 252 | className="text-xs" | ||
| 253 | > | ||
| 254 | <Plus className="mr-2 h-3.5 w-3.5" /> | ||
| 255 | New workspace | ||
| 256 | </DropdownMenuItem> | ||
| 257 | ) : null} | ||
| 258 | </div> | ||
| 259 | ); | ||
| 260 | })} | ||
| 261 | |||
| 262 | {workspaces.length === 0 ? ( | ||
| 263 | <DropdownMenuItem disabled className="text-xs"> | ||
| 264 | No workspaces | ||
| 265 | </DropdownMenuItem> | ||
| 266 | ) : null} | ||
| 267 | </> | ||
| 268 | ) | ||
| 269 | : ( | ||
| 270 | <DropdownMenuItem disabled className="text-xs"> | ||
| 271 | Guest workspace | ||
| 272 | </DropdownMenuItem> | ||
| 273 | )} | ||
| 274 | {supportsOrganizationManagement ? ( | ||
| 275 | <> | ||
| 276 | <DropdownMenuSeparator /> | ||
| 277 | <DropdownMenuItem onSelect={openCreateOrganization} className="text-xs"> | ||
| 278 | <Plus className="mr-2 h-3.5 w-3.5" /> | ||
| 279 | New organization | ||
| 280 | </DropdownMenuItem> | ||
| 281 | |||
| 282 | </> | ||
| 283 | ) : null} | ||
| 284 | </DropdownMenuContent> | ||
| 285 | </DropdownMenu> | ||
| 286 | |||
| 287 | <Dialog open={createOpen} onOpenChange={setCreateOpen}> | ||
| 288 | <DialogContent> | ||
| 289 | <form className="space-y-4" onSubmit={handleCreateWorkspace}> | ||
| 290 | <DialogHeader> | ||
| 291 | <DialogTitle>{createScope === "new_org" ? "Create organization" : "Create workspace"}</DialogTitle> | ||
| 292 | <DialogDescription> | ||
| 293 | {createScope === "new_org" | ||
| 294 | ? "Create a new organization with a default workspace." | ||
| 295 | : `Create a workspace inside ${createTargetOrganizationName ?? "this organization"}.`} | ||
| 296 | </DialogDescription> | ||
| 297 | </DialogHeader> | ||
| 298 | <div className="space-y-2"> | ||
| 299 | <Input | ||
| 300 | value={newWorkspaceName} | ||
| 301 | onChange={(event) => { | ||
| 302 | setCreateError(null); | ||
| 303 | setNewWorkspaceName(event.target.value); | ||
| 304 | }} | ||
| 305 | placeholder={createScope === "new_org" ? "Acme" : "Marketing"} | ||
| 306 | maxLength={64} | ||
| 307 | /> | ||
| 308 | {createScope === "current_org" ? ( | ||
| 309 | <> | ||
| 310 | <Input | ||
| 311 | type="file" | ||
| 312 | accept="image/*" | ||
| 313 | onChange={(event) => { | ||
| 314 | setCreateError(null); | ||
| 315 | setNewWorkspaceIcon(event.target.files?.[0] ?? null); | ||
| 316 | }} | ||
| 317 | /> | ||
| 318 | {newWorkspaceIcon ? ( | ||
| 319 | <p className="text-[11px] text-muted-foreground truncate"> | ||
| 320 | Icon: {newWorkspaceIcon.name} | ||
| 321 | </p> | ||
| 322 | ) : null} | ||
| 323 | </> | ||
| 324 | ) : null} | ||
| 325 | {createError ? ( | ||
| 326 | <p className="text-xs text-destructive">{createError}</p> | ||
| 327 | ) : null} | ||
| 328 | </div> | ||
| 329 | <DialogFooter> | ||
| 330 | <Button | ||
| 331 | type="button" | ||
| 332 | variant="outline" | ||
| 333 | onClick={() => setCreateOpen(false)} | ||
| 334 | disabled={isCreating} | ||
| 335 | > | ||
| 336 | Cancel | ||
| 337 | </Button> | ||
| 338 | <Button type="submit" disabled={isCreating}> | ||
| 339 | {isCreating | ||
| 340 | ? "Creating..." | ||
| 341 | : createScope === "new_org" | ||
| 342 | ? "Create organization" | ||
| 343 | : "Create workspace"} | ||
| 344 | </Button> | ||
| 345 | </DialogFooter> | ||
| 346 | </form> | ||
| 347 | </DialogContent> | ||
| 348 | </Dialog> | ||
| 349 | </> | ||
| 350 | ); | ||
| 351 | } | ||
| @@ -0,0 +1,79 @@ | |||
| 1 | export type FormattedApprovalInput = { | ||
| 2 | content: string; | ||
| 3 | language: "json" | "text"; | ||
| 4 | }; | ||
| 5 | |||
| 6 | export type FormatApprovalInputOptions = { | ||
| 7 | hideSerializedNull?: boolean; | ||
| 8 | hideSerializedEmptyObject?: boolean; | ||
| 9 | }; | ||
| 10 | |||
| 11 | function shouldHideSerialized( | ||
| 12 | serialized: string, | ||
| 13 | options: FormatApprovalInputOptions, | ||
| 14 | ): boolean { | ||
| 15 | if (options.hideSerializedNull && serialized === "null") { | ||
| 16 | return true; | ||
| 17 | } | ||
| 18 | |||
| 19 | if (options.hideSerializedEmptyObject && serialized === "{}") { | ||
| 20 | return true; | ||
| 21 | } | ||
| 22 | |||
| 23 | return false; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function formatApprovalInput( | ||
| 27 | input: unknown, | ||
| 28 | options: FormatApprovalInputOptions = {}, | ||
| 29 | ): FormattedApprovalInput | null { | ||
| 30 | if (input === null || input === undefined) { | ||
| 31 | return null; | ||
| 32 | } | ||
| 33 | |||
| 34 | if (typeof input === "string") { | ||
| 35 | const trimmed = input.trim(); | ||
| 36 | if (!trimmed) { | ||
| 37 | return null; | ||
| 38 | } | ||
| 39 | |||
| 40 | try { | ||
| 41 | const serialized = JSON.stringify(JSON.parse(trimmed), null, 2); | ||
| 42 | if (shouldHideSerialized(serialized, options)) { | ||
| 43 | return null; | ||
| 44 | } | ||
| 45 | |||
| 46 | return { | ||
| 47 | content: serialized, | ||
| 48 | language: "json", | ||
| 49 | }; | ||
| 50 | } catch { | ||
| 51 | return { | ||
| 52 | content: trimmed, | ||
| 53 | language: "text", | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | try { | ||
| 59 | const serialized = JSON.stringify(input, null, 2); | ||
| 60 | if (shouldHideSerialized(serialized, options)) { | ||
| 61 | return null; | ||
| 62 | } | ||
| 63 | |||
| 64 | return { | ||
| 65 | content: serialized, | ||
| 66 | language: "json", | ||
| 67 | }; | ||
| 68 | } catch { | ||
| 69 | const fallback = String(input).trim(); | ||
| 70 | if (!fallback) { | ||
| 71 | return null; | ||
| 72 | } | ||
| 73 | |||
| 74 | return { | ||
| 75 | content: fallback, | ||
| 76 | language: "text", | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | } | ||
| @@ -0,0 +1,112 @@ | |||
| 1 | export interface CatalogCollectionItem { | ||
| 2 | id: string; | ||
| 3 | name: string; | ||
| 4 | summary: string; | ||
| 5 | specUrl: string; | ||
| 6 | originUrl?: string; | ||
| 7 | providerName: string; | ||
| 8 | logoUrl?: string; | ||
| 9 | categories?: string; | ||
| 10 | version?: string; | ||
| 11 | rank: number; | ||
| 12 | addedAt: string; | ||
| 13 | } | ||
| 14 | |||
| 15 | export const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [ | ||
| 16 | { | ||
| 17 | id: "github-rest", | ||
| 18 | name: "GitHub REST API", | ||
| 19 | summary: "Manage repositories, pull requests, issues, and org settings.", | ||
| 20 | specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | ||
| 21 | originUrl: "https://docs.github.com/en/rest", | ||
| 22 | providerName: "GitHub", | ||
| 23 | categories: "developer-tools", | ||
| 24 | version: "latest", | ||
| 25 | rank: 1, | ||
| 26 | addedAt: "2026-01-10", | ||
| 27 | }, | ||
| 28 | { | ||
| 29 | id: "stripe-api", | ||
| 30 | name: "Stripe API", | ||
| 31 | summary: "Create payments, manage customers, and handle billing workflows.", | ||
| 32 | specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | ||
| 33 | originUrl: "https://docs.stripe.com/api", | ||
| 34 | providerName: "Stripe", | ||
| 35 | categories: "payments", | ||
| 36 | version: "2026-01", | ||
| 37 | rank: 2, | ||
| 38 | addedAt: "2026-01-08", | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | id: "openai-api", | ||
| 42 | name: "OpenAI API", | ||
| 43 | summary: "Generate text, run reasoning models, and process multimodal inputs.", | ||
| 44 | specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | ||
| 45 | originUrl: "https://platform.openai.com/docs/api-reference", | ||
| 46 | providerName: "OpenAI", | ||
| 47 | categories: "ai", | ||
| 48 | version: "latest", | ||
| 49 | rank: 3, | ||
| 50 | addedAt: "2026-01-06", | ||
| 51 | }, | ||
| 52 | { | ||
| 53 | id: "cloudflare-api", | ||
| 54 | name: "Cloudflare API", | ||
| 55 | summary: "Control zones, DNS records, workers, and edge configuration.", | ||
| 56 | specUrl: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml", | ||
| 57 | originUrl: "https://api.cloudflare.com/", | ||
| 58 | providerName: "Cloudflare", | ||
| 59 | categories: "infrastructure", | ||
| 60 | version: "latest", | ||
| 61 | rank: 4, | ||
| 62 | addedAt: "2026-01-04", | ||
| 63 | }, | ||
| 64 | { | ||
| 65 | id: "vercel-api", | ||
| 66 | name: "Vercel API", | ||
| 67 | summary: "Manage deployments, projects, domains, and team resources.", | ||
| 68 | specUrl: "https://openapi.vercel.sh", | ||
| 69 | originUrl: "https://vercel.com/docs/rest-api", | ||
| 70 | providerName: "Vercel", | ||
| 71 | categories: "developer-tools", | ||
| 72 | version: "latest", | ||
| 73 | rank: 5, | ||
| 74 | addedAt: "2025-12-18", | ||
| 75 | }, | ||
| 76 | { | ||
| 77 | id: "slack-api", | ||
| 78 | name: "Slack API", | ||
| 79 | summary: "Work with channels, messages, users, and workspace automation.", | ||
| 80 | specUrl: "https://api.slack.com/specs/openapi/v2/slack_web.json", | ||
| 81 | originUrl: "https://api.slack.com/web", | ||
| 82 | providerName: "Slack", | ||
| 83 | categories: "communications", | ||
| 84 | version: "v2", | ||
| 85 | rank: 6, | ||
| 86 | addedAt: "2025-12-10", | ||
| 87 | }, | ||
| 88 | { | ||
| 89 | id: "sentry-api", | ||
| 90 | name: "Sentry API", | ||
| 91 | summary: "Query issues, releases, projects, and alerting configuration.", | ||
| 92 | specUrl: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json", | ||
| 93 | originUrl: "https://docs.sentry.io/api/", | ||
| 94 | providerName: "Sentry", | ||
| 95 | categories: "observability", | ||
| 96 | version: "latest", | ||
| 97 | rank: 7, | ||
| 98 | addedAt: "2025-11-30", | ||
| 99 | }, | ||
| 100 | { | ||
| 101 | id: "jira-cloud-api", | ||
| 102 | name: "Jira Cloud Platform", | ||
| 103 | summary: "Manage projects, issues, workflows, and Jira metadata.", | ||
| 104 | specUrl: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", | ||
| 105 | originUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/", | ||
| 106 | providerName: "Atlassian", | ||
| 107 | categories: "project-management", | ||
| 108 | version: "v3", | ||
| 109 | rank: 8, | ||
| 110 | addedAt: "2025-11-15", | ||
| 111 | }, | ||
| 112 | ]; | ||
| @@ -0,0 +1,90 @@ | |||
| 1 | import { displaySourceName } from "@/lib/tool-source-utils"; | ||
| 2 | import { readSourceAuth, sourceForCredentialKey, type SourceAuthMode, type SourceAuthType } from "@/lib/tools-source-helpers"; | ||
| 3 | import type { CredentialScope, SourceAuthProfile, ToolSourceRecord } from "@/lib/types"; | ||
| 4 | |||
| 5 | export type SourceOption = { source: ToolSourceRecord; key: string; label: string }; | ||
| 6 | |||
| 7 | export function sourceAuthForKey( | ||
| 8 | sourceOptions: SourceOption[], | ||
| 9 | key: string, | ||
| 10 | inferredProfiles: Record<string, SourceAuthProfile> = {}, | ||
| 11 | ): { | ||
| 12 | type: SourceAuthType; | ||
| 13 | mode?: SourceAuthMode; | ||
| 14 | header?: string; | ||
| 15 | inferred?: boolean; | ||
| 16 | } { | ||
| 17 | const match = sourceOptions.find((entry) => entry.key === key); | ||
| 18 | if (!match) { | ||
| 19 | return { type: "bearer" }; | ||
| 20 | } | ||
| 21 | return readSourceAuth(match.source, inferredProfiles[key]); | ||
| 22 | } | ||
| 23 | |||
| 24 | export function sourceOptionLabel(source: ToolSourceRecord): string { | ||
| 25 | return `${source.name} (${source.type})`; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function providerLabel(provider: "local-convex" | "workos-vault"): string { | ||
| 29 | return provider === "workos-vault" ? "encrypted" : "local"; | ||
| 30 | } | ||
| 31 | |||
| 32 | export function connectionDisplayName( | ||
| 33 | sources: ToolSourceRecord[], | ||
| 34 | connection: { | ||
| 35 | scope: CredentialScope; | ||
| 36 | sourceKeys: Set<string>; | ||
| 37 | actorId?: string; | ||
| 38 | }, | ||
| 39 | ): string { | ||
| 40 | const sourceNames = [...connection.sourceKeys] | ||
| 41 | .map((sourceKey) => sourceForCredentialKey(sources, sourceKey)) | ||
| 42 | .filter((source): source is ToolSourceRecord => Boolean(source)) | ||
| 43 | .map((source) => displaySourceName(source.name)); | ||
| 44 | |||
| 45 | const primary = sourceNames[0] ?? "API"; | ||
| 46 | const extraCount = Math.max(sourceNames.length - 1, 0); | ||
| 47 | const base = extraCount > 0 ? `${primary} +${extraCount}` : primary; | ||
| 48 | |||
| 49 | if (connection.scope === "actor") { | ||
| 50 | if (connection.actorId) { | ||
| 51 | return `${base} personal (${connection.actorId})`; | ||
| 52 | } | ||
| 53 | return `${base} personal`; | ||
| 54 | } | ||
| 55 | |||
| 56 | return `${base} workspace`; | ||
| 57 | } | ||
| 58 | |||
| 59 | export function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } { | ||
| 60 | const lines = text | ||
| 61 | .split("\n") | ||
| 62 | .map((line) => line.trim()) | ||
| 63 | .filter((line) => line.length > 0); | ||
| 64 | if (lines.length === 0) { | ||
| 65 | return { value: {} }; | ||
| 66 | } | ||
| 67 | |||
| 68 | const headers: Record<string, string> = {}; | ||
| 69 | for (const line of lines) { | ||
| 70 | const separator = line.indexOf(":"); | ||
| 71 | if (separator <= 0) { | ||
| 72 | return { error: `Invalid header line: ${line}` }; | ||
| 73 | } | ||
| 74 | const name = line.slice(0, separator).trim(); | ||
| 75 | const value = line.slice(separator + 1).trim(); | ||
| 76 | if (!name || !value) { | ||
| 77 | return { error: `Invalid header line: ${line}` }; | ||
| 78 | } | ||
| 79 | headers[name] = value; | ||
| 80 | } | ||
| 81 | |||
| 82 | return { value: headers }; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { | ||
| 86 | const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; | ||
| 87 | return Object.entries(headers) | ||
| 88 | .map(([key, value]) => `${key}: ${String(value)}`) | ||
| 89 | .join("\n"); | ||
| 90 | } | ||
| @@ -0,0 +1,138 @@ | |||
| 1 | import type { Id } from "@executor/convex/_generated/dataModel"; | ||
| 2 | import type { AnonymousContext } from "@/lib/types"; | ||
| 3 | |||
| 4 | export interface WorkosAccount { | ||
| 5 | _id: Id<"accounts">; | ||
| 6 | provider: "workos" | "anonymous"; | ||
| 7 | providerAccountId: string; | ||
| 8 | email: string; | ||
| 9 | name: string; | ||
| 10 | avatarUrl?: string | null; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface WorkspaceListItem { | ||
| 14 | id: Id<"workspaces">; | ||
| 15 | organizationId: Id<"organizations">; | ||
| 16 | organizationName: string; | ||
| 17 | organizationSlug: string; | ||
| 18 | name: string; | ||
| 19 | slug: string; | ||
| 20 | iconUrl?: string | null; | ||
| 21 | createdAt: number; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface SessionWorkspaceOption { | ||
| 25 | id: Id<"workspaces">; | ||
| 26 | docId: Id<"workspaces"> | null; | ||
| 27 | name: string; | ||
| 28 | organizationId: Id<"organizations"> | null; | ||
| 29 | organizationName: string; | ||
| 30 | organizationSlug: string; | ||
| 31 | iconUrl?: string | null; | ||
| 32 | } | ||
| 33 | |||
| 34 | export function resolveActiveWorkspaceId( | ||
| 35 | workspaces: WorkspaceListItem[] | undefined, | ||
| 36 | activeWorkspaceId: Id<"workspaces"> | null, | ||
| 37 | accountStoredWorkspace: Id<"workspaces"> | null, | ||
| 38 | ) { | ||
| 39 | if (!workspaces || workspaces.length === 0) { | ||
| 40 | return activeWorkspaceId; | ||
| 41 | } | ||
| 42 | |||
| 43 | if (activeWorkspaceId && workspaces.some((workspace) => workspace.id === activeWorkspaceId)) { | ||
| 44 | return activeWorkspaceId; | ||
| 45 | } | ||
| 46 | |||
| 47 | if ( | ||
| 48 | accountStoredWorkspace | ||
| 49 | && workspaces.some((workspace) => workspace.id === accountStoredWorkspace) | ||
| 50 | ) { | ||
| 51 | return accountStoredWorkspace; | ||
| 52 | } | ||
| 53 | |||
| 54 | return workspaces[0]?.id ?? null; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function buildAccountWorkspaceContext( | ||
| 58 | account: WorkosAccount | null | undefined, | ||
| 59 | workspaces: WorkspaceListItem[] | undefined, | ||
| 60 | resolvedActiveWorkspaceId: Id<"workspaces"> | null, | ||
| 61 | storedSessionId: string | null, | ||
| 62 | guestSessionId: string | null, | ||
| 63 | ) { | ||
| 64 | if (!account || !workspaces || workspaces.length === 0) { | ||
| 65 | return null; | ||
| 66 | } | ||
| 67 | |||
| 68 | const activeWorkspace = | ||
| 69 | workspaces.find((workspace) => workspace.id === resolvedActiveWorkspaceId) | ||
| 70 | ?? workspaces[0] | ||
| 71 | ?? null; | ||
| 72 | if (!activeWorkspace) { | ||
| 73 | return null; | ||
| 74 | } | ||
| 75 | |||
| 76 | const sessionId = account.provider === "workos" | ||
| 77 | ? `workos_${account._id}` | ||
| 78 | : (storedSessionId ?? guestSessionId ?? null); | ||
| 79 | if (!sessionId) { | ||
| 80 | return null; | ||
| 81 | } | ||
| 82 | |||
| 83 | const actorId = account.provider === "workos" ? account._id : account.providerAccountId; | ||
| 84 | |||
| 85 | return { | ||
| 86 | sessionId, | ||
| 87 | workspaceId: activeWorkspace.id, | ||
| 88 | actorId, | ||
| 89 | clientId: "web", | ||
| 90 | accountId: account._id, | ||
| 91 | userId: account._id, | ||
| 92 | createdAt: Date.now(), | ||
| 93 | lastSeenAt: Date.now(), | ||
| 94 | } satisfies AnonymousContext; | ||
| 95 | } | ||
| 96 | |||
| 97 | export function buildWorkspaceOptions( | ||
| 98 | mode: "guest" | "workos" | "anonymous", | ||
| 99 | workspaces: WorkspaceListItem[] | undefined, | ||
| 100 | guestContext: AnonymousContext | null, | ||
| 101 | ) { | ||
| 102 | if (mode !== "guest" && workspaces) { | ||
| 103 | return workspaces.map((workspace): SessionWorkspaceOption => { | ||
| 104 | return { | ||
| 105 | id: workspace.id, | ||
| 106 | docId: workspace.id, | ||
| 107 | name: workspace.name, | ||
| 108 | organizationId: workspace.organizationId, | ||
| 109 | organizationName: workspace.organizationName, | ||
| 110 | organizationSlug: workspace.organizationSlug, | ||
| 111 | iconUrl: workspace.iconUrl, | ||
| 112 | }; | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | |||
| 116 | if (guestContext) { | ||
| 117 | return [ | ||
| 118 | { | ||
| 119 | id: guestContext.workspaceId, | ||
| 120 | docId: null, | ||
| 121 | name: "Anonymous Workspace", | ||
| 122 | organizationId: null, | ||
| 123 | organizationName: "Anonymous Organization", | ||
| 124 | organizationSlug: "anonymous-organization", | ||
| 125 | }, | ||
| 126 | ]; | ||
| 127 | } | ||
| 128 | |||
| 129 | return []; | ||
| 130 | } | ||
| 131 | |||
| 132 | export function queryErrorMessage(error: unknown, fallback: string) { | ||
| 133 | if (error instanceof Error) { | ||
| 134 | return error.message; | ||
| 135 | } | ||
| 136 | |||
| 137 | return error ? fallback : null; | ||
| 138 | } | ||
| @@ -13,6 +13,23 @@ import { useQuery as useTanstackQuery } from "@tanstack/react-query"; · 12 unmodified lines | |||
| 13 | import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities"; | 13 | import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities"; |
| 14 | import { useWorkosAuthState } from "@/lib/convex-provider"; | 14 | import { useWorkosAuthState } from "@/lib/convex-provider"; |
| 15 | import { convexApi } from "@/lib/convex-api"; | 15 | import { convexApi } from "@/lib/convex-api"; |
| 16 | import { | ||
| 17 | buildAccountWorkspaceContext, | ||
| 18 | buildWorkspaceOptions, | ||
| 19 | queryErrorMessage, | ||
| 20 | resolveActiveWorkspaceId, | ||
| 21 | type WorkosAccount, | ||
| 22 | type WorkspaceListItem, | ||
| 23 | } from "@/lib/session-context-derivation"; | ||
| 24 | import { | ||
| 25 | clearSessionStorage, | ||
| 26 | persistActiveWorkspaceId, | ||
| 27 | persistSessionId, | ||
| 28 | persistWorkspaceForAccount, | ||
| 29 | readStoredActiveWorkspaceId, | ||
| 30 | readStoredSessionId, | ||
| 31 | readWorkspaceByAccount, | ||
| 32 | } from "@/lib/session-storage"; | ||
| 16 | import type { AnonymousContext } from "./types"; | 33 | import type { AnonymousContext } from "./types"; |
| 17 | import type { Id } from "@executor/convex/_generated/dataModel"; | 34 | import 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 | ||
| 68 | interface WorkosAccount { | ||
| 69 | _id: Id<"accounts">; | ||
| 70 | provider: "workos" | "anonymous"; | ||
| 71 | providerAccountId: string; | ||
| 72 | email: string; | ||
| 73 | name: string; | ||
| 74 | avatarUrl?: string | null; | ||
| 75 | } | ||
| 76 | |||
| 77 | interface WorkspaceListItem { | ||
| 78 | id: Id<"workspaces">; | ||
| 79 | organizationId: Id<"organizations">; | ||
| 80 | organizationName: string; | ||
| 81 | organizationSlug: string; | ||
| 82 | name: string; | ||
| 83 | slug: string; | ||
| 84 | iconUrl?: string | null; | ||
| 85 | createdAt: number; | ||
| 86 | } | ||
| 87 | |||
| 88 | const SessionContext = createContext<SessionState>({ | 85 | const 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 | ||
| 107 | const SESSION_KEY = "executor_session_id"; | ||
| 108 | const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id"; | ||
| 109 | const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account"; | ||
| 110 | |||
| 111 | function readWorkspaceByAccount() { | ||
| 112 | const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY); | ||
| 113 | if (!raw) return {} as Record<string, Id<"workspaces">>; | ||
| 114 | try { | ||
| 115 | const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>; | ||
| 116 | return parsed ?? {}; | ||
| 117 | } catch { | ||
| 118 | return {}; | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) { | ||
| 123 | localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value)); | ||
| 124 | } | ||
| 125 | |||
| 126 | export function SessionProvider({ children }: { children: ReactNode }) { | 104 | export 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 WorkOS | 286 | // 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 Error | 302 | storedSessionId |
| 376 | ? bootstrapSessionQuery.error.message | 303 | ? queryErrorMessage(bootstrapSessionQuery.error, "Failed to bootstrap session") |
| 377 | : storedSessionId && bootstrapSessionQuery.error | 304 | : 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 Error | 308 | ); |
| 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.isFetching | 315 | || 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 ( |
| @@ -0,0 +1,60 @@ | |||
| 1 | import type { Id } from "@executor/convex/_generated/dataModel"; | ||
| 2 | |||
| 3 | const SESSION_KEY = "executor_session_id"; | ||
| 4 | const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id"; | ||
| 5 | const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account"; | ||
| 6 | |||
| 7 | export function readStoredSessionId() { | ||
| 8 | if (typeof window === "undefined") { | ||
| 9 | return null; | ||
| 10 | } | ||
| 11 | |||
| 12 | return localStorage.getItem(SESSION_KEY); | ||
| 13 | } | ||
| 14 | |||
| 15 | export function readStoredActiveWorkspaceId() { | ||
| 16 | if (typeof window === "undefined") { | ||
| 17 | return null; | ||
| 18 | } | ||
| 19 | |||
| 20 | return localStorage.getItem(ACTIVE_WORKSPACE_KEY) as Id<"workspaces"> | null; | ||
| 21 | } | ||
| 22 | |||
| 23 | export function persistSessionId(sessionId: string) { | ||
| 24 | localStorage.setItem(SESSION_KEY, sessionId); | ||
| 25 | } | ||
| 26 | |||
| 27 | export function persistActiveWorkspaceId(workspaceId: Id<"workspaces">) { | ||
| 28 | localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId); | ||
| 29 | } | ||
| 30 | |||
| 31 | export function clearSessionStorage() { | ||
| 32 | localStorage.removeItem(SESSION_KEY); | ||
| 33 | localStorage.removeItem(ACTIVE_WORKSPACE_KEY); | ||
| 34 | } | ||
| 35 | |||
| 36 | export function readWorkspaceByAccount() { | ||
| 37 | const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY); | ||
| 38 | if (!raw) { | ||
| 39 | return {} as Record<string, Id<"workspaces">>; | ||
| 40 | } | ||
| 41 | |||
| 42 | try { | ||
| 43 | const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>; | ||
| 44 | return parsed ?? {}; | ||
| 45 | } catch { | ||
| 46 | return {}; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | export function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) { | ||
| 51 | localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value)); | ||
| 52 | } | ||
| 53 | |||
| 54 | export function persistWorkspaceForAccount(accountId: string, workspaceId: Id<"workspaces">) { | ||
| 55 | const byAccount = readWorkspaceByAccount(); | ||
| 56 | writeWorkspaceByAccount({ | ||
| 57 | ...byAccount, | ||
| 58 | [accountId]: workspaceId, | ||
| 59 | }); | ||
| 60 | } | ||
| @@ -0,0 +1,156 @@ | |||
| 1 | import { sourceLabel, sourceType } from "@/lib/tool-source-utils"; | ||
| 2 | import type { ToolDescriptor } from "@/lib/types"; | ||
| 3 | |||
| 4 | export interface ToolGroup { | ||
| 5 | key: string; | ||
| 6 | label: string; | ||
| 7 | type: "source" | "namespace"; | ||
| 8 | sourceType?: string; | ||
| 9 | childCount: number; | ||
| 10 | approvalCount: number; | ||
| 11 | children: ToolGroup[] | ToolDescriptor[]; | ||
| 12 | } | ||
| 13 | |||
| 14 | export function toolNamespace(path: string): string { | ||
| 15 | const parts = path.split("."); | ||
| 16 | if (parts.length >= 2) return parts.slice(0, -1).join("."); | ||
| 17 | return parts[0]; | ||
| 18 | } | ||
| 19 | |||
| 20 | function trimLeadingNamespace(path: string, prefix: string): string { | ||
| 21 | const dottedPrefix = `${prefix}.`; | ||
| 22 | if (path.startsWith(dottedPrefix)) { | ||
| 23 | return path.slice(dottedPrefix.length); | ||
| 24 | } | ||
| 25 | return path; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function toolOperation(path: string): string { | ||
| 29 | const parts = path.split("."); | ||
| 30 | return parts[parts.length - 1]; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function buildSourceTree(tools: ToolDescriptor[]): ToolGroup[] { | ||
| 34 | const bySource = new Map<string, ToolDescriptor[]>(); | ||
| 35 | for (const tool of tools) { | ||
| 36 | const src = sourceLabel(tool.source); | ||
| 37 | let list = bySource.get(src); | ||
| 38 | if (!list) { | ||
| 39 | list = []; | ||
| 40 | bySource.set(src, list); | ||
| 41 | } | ||
| 42 | list.push(tool); | ||
| 43 | } | ||
| 44 | |||
| 45 | return Array.from(bySource.entries()) | ||
| 46 | .sort((a, b) => b[1].length - a[1].length) | ||
| 47 | .map(([src, srcTools]) => { | ||
| 48 | const sType = srcTools[0] ? sourceType(srcTools[0].source) : "local"; | ||
| 49 | |||
| 50 | const byNs = new Map<string, ToolDescriptor[]>(); | ||
| 51 | for (const tool of srcTools) { | ||
| 52 | const ns = toolNamespace(tool.path); | ||
| 53 | let list = byNs.get(ns); | ||
| 54 | if (!list) { | ||
| 55 | list = []; | ||
| 56 | byNs.set(ns, list); | ||
| 57 | } | ||
| 58 | list.push(tool); | ||
| 59 | } | ||
| 60 | |||
| 61 | const nsGroups: ToolGroup[] = Array.from(byNs.entries()) | ||
| 62 | .sort((a, b) => a[0].localeCompare(b[0])) | ||
| 63 | .map(([ns, nsTools]) => ({ | ||
| 64 | key: `source:${src}:ns:${ns}`, | ||
| 65 | label: trimLeadingNamespace(ns, src), | ||
| 66 | type: "namespace" as const, | ||
| 67 | childCount: nsTools.length, | ||
| 68 | approvalCount: nsTools.filter((t) => t.approval === "required") | ||
| 69 | .length, | ||
| 70 | children: [...nsTools].sort((a, b) => | ||
| 71 | a.path.localeCompare(b.path), | ||
| 72 | ), | ||
| 73 | })); | ||
| 74 | |||
| 75 | return { | ||
| 76 | key: `source:${src}`, | ||
| 77 | label: src, | ||
| 78 | type: "source" as const, | ||
| 79 | sourceType: sType, | ||
| 80 | childCount: srcTools.length, | ||
| 81 | approvalCount: srcTools.filter((t) => t.approval === "required") | ||
| 82 | .length, | ||
| 83 | children: nsGroups, | ||
| 84 | }; | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | |||
| 88 | export function buildNamespaceTree(tools: ToolDescriptor[]): ToolGroup[] { | ||
| 89 | const byNs = new Map<string, ToolDescriptor[]>(); | ||
| 90 | for (const tool of tools) { | ||
| 91 | const ns = toolNamespace(tool.path); | ||
| 92 | let list = byNs.get(ns); | ||
| 93 | if (!list) { | ||
| 94 | list = []; | ||
| 95 | byNs.set(ns, list); | ||
| 96 | } | ||
| 97 | list.push(tool); | ||
| 98 | } | ||
| 99 | |||
| 100 | return Array.from(byNs.entries()) | ||
| 101 | .sort((a, b) => a[0].localeCompare(b[0])) | ||
| 102 | .map(([ns, nsTools]) => ({ | ||
| 103 | key: `ns:${ns}`, | ||
| 104 | label: ns, | ||
| 105 | type: "namespace" as const, | ||
| 106 | childCount: nsTools.length, | ||
| 107 | approvalCount: nsTools.filter((t) => t.approval === "required").length, | ||
| 108 | children: [...nsTools].sort((a, b) => a.path.localeCompare(b.path)), | ||
| 109 | })); | ||
| 110 | } | ||
| 111 | |||
| 112 | export function buildApprovalTree(tools: ToolDescriptor[]): ToolGroup[] { | ||
| 113 | const gated = tools.filter((t) => t.approval === "required"); | ||
| 114 | const auto = tools.filter((t) => t.approval !== "required"); | ||
| 115 | const groups: ToolGroup[] = []; | ||
| 116 | |||
| 117 | if (gated.length > 0) { | ||
| 118 | groups.push({ | ||
| 119 | key: "approval:required", | ||
| 120 | label: "Approval Required", | ||
| 121 | type: "namespace", | ||
| 122 | childCount: gated.length, | ||
| 123 | approvalCount: gated.length, | ||
| 124 | children: [...gated].sort((a, b) => a.path.localeCompare(b.path)), | ||
| 125 | }); | ||
| 126 | } | ||
| 127 | if (auto.length > 0) { | ||
| 128 | groups.push({ | ||
| 129 | key: "approval:auto", | ||
| 130 | label: "Auto-approved", | ||
| 131 | type: "namespace", | ||
| 132 | childCount: auto.length, | ||
| 133 | approvalCount: 0, | ||
| 134 | children: [...auto].sort((a, b) => a.path.localeCompare(b.path)), | ||
| 135 | }); | ||
| 136 | } | ||
| 137 | |||
| 138 | return groups; | ||
| 139 | } | ||
| 140 | |||
| 141 | export function collectGroupKeys(groups: ToolGroup[]): Set<string> { | ||
| 142 | const keys = new Set<string>(); | ||
| 143 | const stack = [...groups]; | ||
| 144 | |||
| 145 | while (stack.length > 0) { | ||
| 146 | const group = stack.pop(); | ||
| 147 | if (!group) continue; | ||
| 148 | |||
| 149 | keys.add(group.key); | ||
| 150 | if (group.children.length > 0 && "key" in group.children[0]) { | ||
| 151 | stack.push(...(group.children as ToolGroup[])); | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | return keys; | ||
| 156 | } | ||
| @@ -0,0 +1,37 @@ | |||
| 1 | export function sourceLabel(source?: string): string { | ||
| 2 | if (!source) return "built-in"; | ||
| 3 | const idx = source.indexOf(":"); | ||
| 4 | return idx >= 0 ? source.slice(idx + 1) : source; | ||
| 5 | } | ||
| 6 | |||
| 7 | export function sourceType(source?: string): string { | ||
| 8 | if (!source) return "local"; | ||
| 9 | const idx = source.indexOf(":"); | ||
| 10 | return idx >= 0 ? source.slice(0, idx) : "local"; | ||
| 11 | } | ||
| 12 | |||
| 13 | export function displaySourceName(name: string): string { | ||
| 14 | const parts = name.split(/[-_.]+/).filter(Boolean); | ||
| 15 | if (parts.length === 0) return name; | ||
| 16 | |||
| 17 | const deduped = parts.filter((part, index, all) => { | ||
| 18 | if (index === 0) return true; | ||
| 19 | return part.toLowerCase() !== all[index - 1]?.toLowerCase(); | ||
| 20 | }); | ||
| 21 | |||
| 22 | const tokenMap: Record<string, string> = { | ||
| 23 | api: "API", | ||
| 24 | oauth: "OAuth", | ||
| 25 | graphql: "GraphQL", | ||
| 26 | mcp: "MCP", | ||
| 27 | github: "GitHub", | ||
| 28 | }; | ||
| 29 | |||
| 30 | return deduped | ||
| 31 | .map((token) => { | ||
| 32 | const lower = token.toLowerCase(); | ||
| 33 | if (tokenMap[lower]) return tokenMap[lower]; | ||
| 34 | return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`; | ||
| 35 | }) | ||
| 36 | .join(" "); | ||
| 37 | } | ||
| @@ -0,0 +1,278 @@ | |||
| 1 | import { parse as parseDomain } from "tldts"; | ||
| 2 | import type { | ||
| 3 | CredentialRecord, | ||
| 4 | OpenApiSourceQuality, | ||
| 5 | SourceAuthProfile, | ||
| 6 | ToolSourceRecord, | ||
| 7 | } from "@/lib/types"; | ||
| 8 | |||
| 9 | export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed"; | ||
| 10 | export type SourceAuthMode = "workspace" | "actor"; | ||
| 11 | |||
| 12 | const RAW_HOSTS = new Set([ | ||
| 13 | "raw.githubusercontent.com", | ||
| 14 | "cdn.jsdelivr.net", | ||
| 15 | "unpkg.com", | ||
| 16 | "raw.github.com", | ||
| 17 | ]); | ||
| 18 | |||
| 19 | export function faviconForUrl(url: string | undefined | null): string | null { | ||
| 20 | if (!url) return null; | ||
| 21 | try { | ||
| 22 | const domain = new URL(url).hostname; | ||
| 23 | return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; | ||
| 24 | } catch { | ||
| 25 | return null; | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | export function getSourceFavicon(source: ToolSourceRecord): string | null { | ||
| 30 | if (source.type === "mcp") { | ||
| 31 | return faviconForUrl((source.config.url as string) ?? null); | ||
| 32 | } | ||
| 33 | if (source.type === "graphql") { | ||
| 34 | return faviconForUrl((source.config.endpoint as string) ?? null); | ||
| 35 | } | ||
| 36 | const spec = source.config.spec as string | undefined; | ||
| 37 | if (typeof spec === "string" && spec.startsWith("postman:")) { | ||
| 38 | return null; | ||
| 39 | } | ||
| 40 | const baseUrl = source.config.baseUrl as string | undefined; | ||
| 41 | const collectionUrl = source.config.collectionUrl as string | undefined; | ||
| 42 | const specUrl = typeof spec === "string" && spec.startsWith("http") ? spec : null; | ||
| 43 | return faviconForUrl(baseUrl ?? collectionUrl ?? specUrl); | ||
| 44 | } | ||
| 45 | |||
| 46 | export function sourceEndpointLabel(source: ToolSourceRecord): string { | ||
| 47 | if (source.type === "mcp") return (source.config.url as string) ?? ""; | ||
| 48 | if (source.type === "graphql") return (source.config.endpoint as string) ?? ""; | ||
| 49 | |||
| 50 | const spec = source.config.spec; | ||
| 51 | if (typeof spec === "string" && spec.startsWith("postman:")) { | ||
| 52 | const uid = spec.slice("postman:".length).trim(); | ||
| 53 | if (uid.length > 0) { | ||
| 54 | return `catalog:${uid}`; | ||
| 55 | } | ||
| 56 | return "catalog:collection"; | ||
| 57 | } | ||
| 58 | |||
| 59 | return (source.config.spec as string) ?? ""; | ||
| 60 | } | ||
| 61 | |||
| 62 | export function sourceKeyForSource(source: ToolSourceRecord): string | null { | ||
| 63 | if (source.type === "openapi") return `source:${source.id}`; | ||
| 64 | if (source.type === "graphql") return `source:${source.id}`; | ||
| 65 | return null; | ||
| 66 | } | ||
| 67 | |||
| 68 | export function toolSourceLabelForSource(source: ToolSourceRecord): string { | ||
| 69 | return `${source.type}:${source.name}`; | ||
| 70 | } | ||
| 71 | |||
| 72 | export function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): ToolSourceRecord | null { | ||
| 73 | const prefix = "source:"; | ||
| 74 | if (!sourceKey.startsWith(prefix)) return null; | ||
| 75 | const sourceId = sourceKey.slice(prefix.length); | ||
| 76 | if (!sourceId) return null; | ||
| 77 | return sources.find((source) => source.id === sourceId) ?? null; | ||
| 78 | } | ||
| 79 | |||
| 80 | export function normalizeSourceAuthProfile(profile: SourceAuthProfile | undefined): { | ||
| 81 | type: SourceAuthType; | ||
| 82 | mode?: SourceAuthMode; | ||
| 83 | header?: string; | ||
| 84 | inferred?: boolean; | ||
| 85 | } { | ||
| 86 | if (!profile) { | ||
| 87 | return { type: "none" }; | ||
| 88 | } | ||
| 89 | |||
| 90 | const type = profile.type === "mixed" | ||
| 91 | ? "mixed" | ||
| 92 | : profile.type === "basic" | ||
| 93 | ? "basic" | ||
| 94 | : profile.type === "apiKey" | ||
| 95 | ? "apiKey" | ||
| 96 | : profile.type === "bearer" | ||
| 97 | ? "bearer" | ||
| 98 | : "none"; | ||
| 99 | |||
| 100 | const mode = profile.mode === "actor" ? "actor" : profile.mode === "workspace" ? "workspace" : undefined; | ||
| 101 | const header = typeof profile.header === "string" && profile.header.trim().length > 0 | ||
| 102 | ? profile.header.trim() | ||
| 103 | : undefined; | ||
| 104 | |||
| 105 | return { | ||
| 106 | type, | ||
| 107 | ...(mode ? { mode } : {}), | ||
| 108 | ...(header ? { header } : {}), | ||
| 109 | inferred: Boolean(profile.inferred), | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | export function readSourceAuth( | ||
| 114 | source: ToolSourceRecord, | ||
| 115 | inferredProfile?: SourceAuthProfile, | ||
| 116 | ): { | ||
| 117 | type: SourceAuthType; | ||
| 118 | mode?: SourceAuthMode; | ||
| 119 | header?: string; | ||
| 120 | inferred?: boolean; | ||
| 121 | } { | ||
| 122 | if (source.type !== "openapi" && source.type !== "graphql") { | ||
| 123 | return { type: "none" }; | ||
| 124 | } | ||
| 125 | |||
| 126 | const inferred = normalizeSourceAuthProfile(inferredProfile); | ||
| 127 | |||
| 128 | const auth = source.config.auth as Record<string, unknown> | undefined; | ||
| 129 | const type = | ||
| 130 | auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic", "mixed"].includes(auth.type) | ||
| 131 | ? (auth.type as SourceAuthType) | ||
| 132 | : inferred.type; | ||
| 133 | |||
| 134 | const mode = | ||
| 135 | auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor") | ||
| 136 | ? (auth.mode as SourceAuthMode) | ||
| 137 | : inferred.mode; | ||
| 138 | |||
| 139 | const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0 | ||
| 140 | ? auth.header.trim() | ||
| 141 | : inferred.header; | ||
| 142 | |||
| 143 | return { | ||
| 144 | type, | ||
| 145 | ...(mode ? { mode } : {}), | ||
| 146 | ...(header ? { header } : {}), | ||
| 147 | inferred: auth?.type === undefined ? inferred.inferred : false, | ||
| 148 | }; | ||
| 149 | } | ||
| 150 | |||
| 151 | export function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null { | ||
| 152 | const auth = readSourceAuth(source, inferredProfile); | ||
| 153 | if (auth.type === "none") return null; | ||
| 154 | if (auth.type === "mixed") return "Mixed auth"; | ||
| 155 | const mode = auth.mode ?? "workspace"; | ||
| 156 | const authLabel = | ||
| 157 | auth.type === "apiKey" | ||
| 158 | ? "API Key" | ||
| 159 | : auth.type === "bearer" | ||
| 160 | ? "Bearer" | ||
| 161 | : auth.type === "basic" | ||
| 162 | ? "Basic" | ||
| 163 | : "Auth"; | ||
| 164 | return `${authLabel} · ${mode === "actor" ? "user" : "workspace"}`; | ||
| 165 | } | ||
| 166 | |||
| 167 | export function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): { | ||
| 168 | workspaceCount: number; | ||
| 169 | actorCount: number; | ||
| 170 | } { | ||
| 171 | const sourceKey = sourceKeyForSource(source); | ||
| 172 | if (!sourceKey) { | ||
| 173 | return { workspaceCount: 0, actorCount: 0 }; | ||
| 174 | } | ||
| 175 | |||
| 176 | let workspaceCount = 0; | ||
| 177 | let actorCount = 0; | ||
| 178 | for (const credential of credentials) { | ||
| 179 | if (credential.sourceKey !== sourceKey) continue; | ||
| 180 | if (credential.scope === "workspace") workspaceCount += 1; | ||
| 181 | if (credential.scope === "actor") actorCount += 1; | ||
| 182 | } | ||
| 183 | |||
| 184 | return { workspaceCount, actorCount }; | ||
| 185 | } | ||
| 186 | |||
| 187 | export function formatQualityPercent(value: number): string { | ||
| 188 | return `${Math.round(value * 100)}%`; | ||
| 189 | } | ||
| 190 | |||
| 191 | export function qualityToneClass(quality: OpenApiSourceQuality): string { | ||
| 192 | if (quality.overallQuality >= 0.95) { | ||
| 193 | return "text-terminal-green"; | ||
| 194 | } | ||
| 195 | if (quality.overallQuality >= 0.85) { | ||
| 196 | return "text-terminal-amber"; | ||
| 197 | } | ||
| 198 | return "text-terminal-red"; | ||
| 199 | } | ||
| 200 | |||
| 201 | export function qualitySummaryLabel(quality: OpenApiSourceQuality): string { | ||
| 202 | if (quality.overallQuality >= 0.95) { | ||
| 203 | return "strong typing"; | ||
| 204 | } | ||
| 205 | if (quality.overallQuality >= 0.85) { | ||
| 206 | return "mostly typed"; | ||
| 207 | } | ||
| 208 | return "needs type cleanup"; | ||
| 209 | } | ||
| 210 | |||
| 211 | export function compactEndpointLabel(source: ToolSourceRecord): string { | ||
| 212 | const endpoint = sourceEndpointLabel(source); | ||
| 213 | if (endpoint.startsWith("catalog:")) return endpoint; | ||
| 214 | try { | ||
| 215 | const parsed = new URL(endpoint); | ||
| 216 | const path = parsed.pathname === "/" ? "" : parsed.pathname; | ||
| 217 | return `${parsed.hostname}${path}`; | ||
| 218 | } catch { | ||
| 219 | return endpoint; | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | export function inferNameFromUrl(url: string): string { | ||
| 224 | try { | ||
| 225 | const u = new URL(url); | ||
| 226 | const parsed = parseDomain(url); | ||
| 227 | |||
| 228 | if (RAW_HOSTS.has(u.hostname)) { | ||
| 229 | const segments = u.pathname.split("/").filter(Boolean); | ||
| 230 | if (segments.length > 0) return segments[0].toLowerCase(); | ||
| 231 | } | ||
| 232 | |||
| 233 | if (parsed.domainWithoutSuffix) { | ||
| 234 | return parsed.domainWithoutSuffix; | ||
| 235 | } | ||
| 236 | |||
| 237 | if (parsed.domain) { | ||
| 238 | return parsed.domain.split(".")[0]; | ||
| 239 | } | ||
| 240 | |||
| 241 | return u.hostname.replace(/\./g, "-"); | ||
| 242 | } catch { | ||
| 243 | return ""; | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 247 | export function sanitizeSourceName(value: string): string { | ||
| 248 | const slug = value | ||
| 249 | .toLowerCase() | ||
| 250 | .replace(/[^a-z0-9]+/g, "-") | ||
| 251 | .replace(/-+/g, "-") | ||
| 252 | .replace(/^-|-$/g, "") | ||
| 253 | .slice(0, 48); | ||
| 254 | return slug || "source"; | ||
| 255 | } | ||
| 256 | |||
| 257 | export function withUniqueSourceName(baseName: string, takenNames: Set<string>): string { | ||
| 258 | const loweredTaken = new Set([...takenNames].map((name) => name.toLowerCase())); | ||
| 259 | const candidate = sanitizeSourceName(baseName); | ||
| 260 | if (!loweredTaken.has(candidate.toLowerCase())) { | ||
| 261 | return candidate; | ||
| 262 | } | ||
| 263 | |||
| 264 | let suffix = 2; | ||
| 265 | while (true) { | ||
| 266 | const next = `${candidate}-${suffix}`; | ||
| 267 | if (!loweredTaken.has(next.toLowerCase())) { | ||
| 268 | return next; | ||
| 269 | } | ||
| 270 | suffix += 1; | ||
| 271 | } | ||
| 272 | } | ||
| 273 | |||
| 274 | export function catalogSourceName(item: { providerName: string; name: string }): string { | ||
| 275 | const owner = sanitizeSourceName(item.providerName || "catalog"); | ||
| 276 | const title = sanitizeSourceName(item.name); | ||
| 277 | return sanitizeSourceName(`${owner}-${title}`); | ||
| 278 | } | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | import type { AnonymousContext } from "@/lib/types"; | ||
| 2 | |||
| 3 | export function workspaceQueryArgs(context: AnonymousContext | null | undefined) { | ||
| 4 | return context | ||
| 5 | ? { workspaceId: context.workspaceId, sessionId: context.sessionId } | ||
| 6 | : "skip"; | ||
| 7 | } | ||
| @@ -0,0 +1,48 @@ | |||
| 1 | import { getAccountByWorkosId } from "./auth-db-queries"; | ||
| 2 | import type { DbCtx } from "./auth-types"; | ||
| 3 | |||
| 4 | type UpsertWorkosAccountArgs = { | ||
| 5 | workosUserId: string; | ||
| 6 | email: string; | ||
| 7 | fullName: string; | ||
| 8 | firstName?: string; | ||
| 9 | lastName?: string; | ||
| 10 | avatarUrl?: string; | ||
| 11 | now: number; | ||
| 12 | includeLastLoginAt: boolean; | ||
| 13 | }; | ||
| 14 | |||
| 15 | export async function upsertWorkosAccount(ctx: DbCtx, args: UpsertWorkosAccountArgs) { | ||
| 16 | let account = await getAccountByWorkosId(ctx, args.workosUserId); | ||
| 17 | const patchData = { | ||
| 18 | email: args.email, | ||
| 19 | name: args.fullName, | ||
| 20 | firstName: args.firstName, | ||
| 21 | lastName: args.lastName, | ||
| 22 | avatarUrl: args.avatarUrl, | ||
| 23 | status: "active" as const, | ||
| 24 | updatedAt: args.now, | ||
| 25 | ...(args.includeLastLoginAt ? { lastLoginAt: args.now } : null), | ||
| 26 | }; | ||
| 27 | |||
| 28 | if (account) { | ||
| 29 | await ctx.db.patch(account._id, patchData); | ||
| 30 | return await ctx.db.get(account._id); | ||
| 31 | } | ||
| 32 | |||
| 33 | const accountId = await ctx.db.insert("accounts", { | ||
| 34 | provider: "workos", | ||
| 35 | providerAccountId: args.workosUserId, | ||
| 36 | email: args.email, | ||
| 37 | name: args.fullName, | ||
| 38 | firstName: args.firstName, | ||
| 39 | lastName: args.lastName, | ||
| 40 | avatarUrl: args.avatarUrl, | ||
| 41 | status: "active", | ||
| 42 | createdAt: args.now, | ||
| 43 | updatedAt: args.now, | ||
| 44 | ...(args.includeLastLoginAt ? { lastLoginAt: args.now } : null), | ||
| 45 | }); | ||
| 46 | |||
| 47 | return await ctx.db.get(accountId); | ||
| 48 | } | ||
| @@ -0,0 +1,148 @@ | |||
| 1 | import type { MutationCtx } from "./_generated/server"; | ||
| 2 | import { upsertWorkosAccount } from "./auth-accounts"; | ||
| 3 | import { getOrganizationByWorkosOrgId } from "./auth-db-queries"; | ||
| 4 | import { getAuthKitUserProfile, resolveIdentityProfile } from "./auth-identity"; | ||
| 5 | import { | ||
| 6 | ensureWorkspaceMembership, | ||
| 7 | mapOrganizationRoleToWorkspaceRole, | ||
| 8 | markPendingInvitesAcceptedByEmail, | ||
| 9 | upsertOrganizationMembership, | ||
| 10 | } from "./auth-memberships"; | ||
| 11 | import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./auth-personal-workspace"; | ||
| 12 | import type { AccountId } from "./auth-types"; | ||
| 13 | |||
| 14 | async function seedHintedOrganizationMembership( | ||
| 15 | ctx: MutationCtx, | ||
| 16 | args: { | ||
| 17 | accountId: AccountId; | ||
| 18 | hintedWorkosOrgId?: string; | ||
| 19 | email: string; | ||
| 20 | now: number; | ||
| 21 | }, | ||
| 22 | ) { | ||
| 23 | const activeOrgMembership = await ctx.db | ||
| 24 | .query("organizationMembers") | ||
| 25 | .withIndex("by_account", (q) => q.eq("accountId", args.accountId)) | ||
| 26 | .filter((q) => q.eq(q.field("status"), "active")) | ||
| 27 | .first(); | ||
| 28 | |||
| 29 | if (activeOrgMembership || !args.hintedWorkosOrgId) { | ||
| 30 | return; | ||
| 31 | } | ||
| 32 | |||
| 33 | const hintedOrganization = await getOrganizationByWorkosOrgId(ctx, args.hintedWorkosOrgId); | ||
| 34 | if (!hintedOrganization) { | ||
| 35 | return; | ||
| 36 | } | ||
| 37 | |||
| 38 | await upsertOrganizationMembership(ctx, { | ||
| 39 | organizationId: hintedOrganization._id, | ||
| 40 | accountId: args.accountId, | ||
| 41 | role: "member", | ||
| 42 | status: "active", | ||
| 43 | billable: true, | ||
| 44 | now: args.now, | ||
| 45 | }); | ||
| 46 | |||
| 47 | await markPendingInvitesAcceptedByEmail(ctx, { | ||
| 48 | organizationId: hintedOrganization._id, | ||
| 49 | email: args.email, | ||
| 50 | acceptedAt: args.now, | ||
| 51 | }); | ||
| 52 | } | ||
| 53 | |||
| 54 | async function syncWorkspaceMembershipsForOrganizations(ctx: MutationCtx, args: { accountId: AccountId; now: number }) { | ||
| 55 | const activeOrganizationMemberships = await ctx.db | ||
| 56 | .query("organizationMembers") | ||
| 57 | .withIndex("by_account", (q) => q.eq("accountId", args.accountId)) | ||
| 58 | .filter((q) => q.eq(q.field("status"), "active")) | ||
| 59 | .collect(); | ||
| 60 | |||
| 61 | for (const membership of activeOrganizationMemberships) { | ||
| 62 | const orgWorkspaces = await ctx.db | ||
| 63 | .query("workspaces") | ||
| 64 | .withIndex("by_organization_created", (q) => q.eq("organizationId", membership.organizationId)) | ||
| 65 | .collect(); | ||
| 66 | |||
| 67 | const workspaceRole = mapOrganizationRoleToWorkspaceRole(membership.role); | ||
| 68 | for (const workspace of orgWorkspaces) { | ||
| 69 | await ensureWorkspaceMembership(ctx, { | ||
| 70 | workspaceId: workspace._id, | ||
| 71 | accountId: args.accountId, | ||
| 72 | role: workspaceRole, | ||
| 73 | now: args.now, | ||
| 74 | }); | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { accountId: AccountId }) { | ||
| 80 | const activeWorkspaceMembership = await ctx.db | ||
| 81 | .query("workspaceMembers") | ||
| 82 | .withIndex("by_account", (q) => q.eq("accountId", args.accountId)) | ||
| 83 | .filter((q) => q.eq(q.field("status"), "active")) | ||
| 84 | .first(); | ||
| 85 | |||
| 86 | return Boolean(activeWorkspaceMembership); | ||
| 87 | } | ||
| 88 | |||
| 89 | export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) { | ||
| 90 | const identity = await ctx.auth.getUserIdentity(); | ||
| 91 | if (!identity) return null; | ||
| 92 | |||
| 93 | const now = Date.now(); | ||
| 94 | const subject = identity.subject; | ||
| 95 | const authKitProfile = await getAuthKitUserProfile(ctx, subject); | ||
| 96 | const identityProfile = resolveIdentityProfile({ | ||
| 97 | identity: identity as Record<string, unknown> & { subject: string }, | ||
| 98 | authKitProfile, | ||
| 99 | }); | ||
| 100 | |||
| 101 | const account = await upsertWorkosAccount(ctx, { | ||
| 102 | workosUserId: subject, | ||
| 103 | email: identityProfile.email, | ||
| 104 | fullName: identityProfile.fullName, | ||
| 105 | firstName: identityProfile.firstName, | ||
| 106 | lastName: identityProfile.lastName, | ||
| 107 | avatarUrl: identityProfile.avatarUrl, | ||
| 108 | now, | ||
| 109 | includeLastLoginAt: true, | ||
| 110 | }); | ||
| 111 | if (!account) return null; | ||
| 112 | |||
| 113 | await refreshGeneratedPersonalWorkspaceNames(ctx, account._id, { | ||
| 114 | email: identityProfile.email, | ||
| 115 | firstName: identityProfile.firstName, | ||
| 116 | fullName: identityProfile.fullName, | ||
| 117 | workosUserId: subject, | ||
| 118 | now, | ||
| 119 | }); | ||
| 120 | |||
| 121 | await seedHintedOrganizationMembership(ctx, { | ||
| 122 | accountId: account._id, | ||
| 123 | hintedWorkosOrgId: identityProfile.hintedWorkosOrgId, | ||
| 124 | email: identityProfile.email, | ||
| 125 | now, | ||
| 126 | }); | ||
| 127 | |||
| 128 | await syncWorkspaceMembershipsForOrganizations(ctx, { | ||
| 129 | accountId: account._id, | ||
| 130 | now, | ||
| 131 | }); | ||
| 132 | |||
| 133 | const hasActiveWorkspaceMembership = await ensureAtLeastOneWorkspaceMembership(ctx, { | ||
| 134 | accountId: account._id, | ||
| 135 | }); | ||
| 136 | |||
| 137 | if (!hasActiveWorkspaceMembership) { | ||
| 138 | await ensurePersonalWorkspace(ctx, account._id, { | ||
| 139 | email: identityProfile.email, | ||
| 140 | firstName: identityProfile.firstName, | ||
| 141 | fullName: identityProfile.fullName, | ||
| 142 | workosUserId: subject, | ||
| 143 | now, | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | |||
| 147 | return account; | ||
| 148 | } | ||
| @@ -0,0 +1,41 @@ | |||
| 1 | import type { Doc } from "./_generated/dataModel"; | ||
| 2 | import type { AccountId, DbCtx, OrganizationId } from "./auth-types"; | ||
| 3 | |||
| 4 | export async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) { | ||
| 5 | return await ctx.db | ||
| 6 | .query("accounts") | ||
| 7 | .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", workosUserId)) | ||
| 8 | .unique(); | ||
| 9 | } | ||
| 10 | |||
| 11 | export async function getWorkspaceByWorkosOrgId(ctx: DbCtx, workosOrgId: string) { | ||
| 12 | return await ctx.db | ||
| 13 | .query("workspaces") | ||
| 14 | .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId)) | ||
| 15 | .unique(); | ||
| 16 | } | ||
| 17 | |||
| 18 | export async function getOrganizationByWorkosOrgId(ctx: DbCtx, workosOrgId: string) { | ||
| 19 | return await ctx.db | ||
| 20 | .query("organizations") | ||
| 21 | .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId)) | ||
| 22 | .unique(); | ||
| 23 | } | ||
| 24 | |||
| 25 | export async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: OrganizationId) { | ||
| 26 | return await ctx.db | ||
| 27 | .query("workspaces") | ||
| 28 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) | ||
| 29 | .first(); | ||
| 30 | } | ||
| 31 | |||
| 32 | export async function getWorkspaceMembershipByWorkspaceAndAccount( | ||
| 33 | ctx: DbCtx, | ||
| 34 | workspaceId: Doc<"workspaces">["_id"], | ||
| 35 | accountId: AccountId, | ||
| 36 | ) { | ||
| 37 | return await ctx.db | ||
| 38 | .query("workspaceMembers") | ||
| 39 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId)) | ||
| 40 | .unique(); | ||
| 41 | } | ||
| @@ -0,0 +1,335 @@ | |||
| 1 | import { type AuthKit } from "@convex-dev/workos-authkit"; | ||
| 2 | import type { DataModel, Doc } from "./_generated/dataModel"; | ||
| 3 | import type { MutationCtx } from "./_generated/server"; | ||
| 4 | import { upsertWorkosAccount } from "./auth-accounts"; | ||
| 5 | import { | ||
| 6 | getAccountByWorkosId, | ||
| 7 | getFirstWorkspaceByOrganizationId, | ||
| 8 | getOrganizationByWorkosOrgId, | ||
| 9 | getWorkspaceByWorkosOrgId, | ||
| 10 | } from "./auth-db-queries"; | ||
| 11 | import { | ||
| 12 | deriveOrganizationMembershipState, | ||
| 13 | deriveWorkspaceMembershipRole, | ||
| 14 | markPendingInvitesAcceptedByEmail, | ||
| 15 | upsertOrganizationMembership, | ||
| 16 | } from "./auth-memberships"; | ||
| 17 | import { ensureUniqueOrganizationSlug } from "./auth-naming"; | ||
| 18 | |||
| 19 | type WorkosMembershipEventData = { | ||
| 20 | id: string; | ||
| 21 | user_id?: string; | ||
| 22 | userId?: string; | ||
| 23 | organization_id?: string; | ||
| 24 | organizationId?: string; | ||
| 25 | role?: { slug?: string }; | ||
| 26 | status?: string; | ||
| 27 | }; | ||
| 28 | |||
| 29 | async function resolveMembershipRecord( | ||
| 30 | ctx: Pick<MutationCtx, "db">, | ||
| 31 | data: WorkosMembershipEventData, | ||
| 32 | ) { | ||
| 33 | let membership = await ctx.db | ||
| 34 | .query("workspaceMembers") | ||
| 35 | .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id)) | ||
| 36 | .unique(); | ||
| 37 | |||
| 38 | let account: Doc<"accounts"> | null = null; | ||
| 39 | let workspace: Doc<"workspaces"> | null = null; | ||
| 40 | |||
| 41 | if (!membership) { | ||
| 42 | const workosUserId = data.user_id ?? data.userId; | ||
| 43 | const workosOrgId = data.organization_id ?? data.organizationId; | ||
| 44 | if (!workosUserId || !workosOrgId) { | ||
| 45 | return { membership: null, account: null, workspace: null }; | ||
| 46 | } | ||
| 47 | |||
| 48 | [account, workspace] = await Promise.all([ | ||
| 49 | getAccountByWorkosId(ctx, workosUserId), | ||
| 50 | getWorkspaceByWorkosOrgId(ctx, workosOrgId), | ||
| 51 | ]); | ||
| 52 | if (!account || !workspace) { | ||
| 53 | return { membership: null, account: null, workspace: null }; | ||
| 54 | } | ||
| 55 | |||
| 56 | const workspaceId = workspace._id; | ||
| 57 | const accountId = account._id; | ||
| 58 | |||
| 59 | membership = await ctx.db | ||
| 60 | .query("workspaceMembers") | ||
| 61 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId)) | ||
| 62 | .unique(); | ||
| 63 | if (!membership) { | ||
| 64 | return { membership: null, account: null, workspace: null }; | ||
| 65 | } | ||
| 66 | } else { | ||
| 67 | account = await ctx.db.get(membership.accountId); | ||
| 68 | workspace = await ctx.db.get(membership.workspaceId); | ||
| 69 | } | ||
| 70 | |||
| 71 | if (!account || !workspace) { | ||
| 72 | return { membership: null, account: null, workspace: null }; | ||
| 73 | } | ||
| 74 | |||
| 75 | return { membership, account, workspace }; | ||
| 76 | } | ||
| 77 | |||
| 78 | export const workosEventHandlers = { | ||
| 79 | "user.created": async (ctx, event) => { | ||
| 80 | const now = Date.now(); | ||
| 81 | const data = event.data; | ||
| 82 | const fullName = [data.firstName, data.lastName].filter(Boolean).join(" ") || data.email; | ||
| 83 | |||
| 84 | await upsertWorkosAccount(ctx, { | ||
| 85 | workosUserId: data.id, | ||
| 86 | email: data.email, | ||
| 87 | fullName, | ||
| 88 | firstName: data.firstName ?? undefined, | ||
| 89 | lastName: data.lastName ?? undefined, | ||
| 90 | avatarUrl: data.profilePictureUrl ?? undefined, | ||
| 91 | now, | ||
| 92 | includeLastLoginAt: true, | ||
| 93 | }); | ||
| 94 | }, | ||
| 95 | |||
| 96 | "user.updated": async (ctx, event) => { | ||
| 97 | const account = await getAccountByWorkosId(ctx, event.data.id); | ||
| 98 | if (!account) return; | ||
| 99 | |||
| 100 | const fullName = [event.data.firstName, event.data.lastName].filter(Boolean).join(" ") || event.data.email; | ||
| 101 | await ctx.db.patch(account._id, { | ||
| 102 | email: event.data.email, | ||
| 103 | name: fullName, | ||
| 104 | firstName: event.data.firstName ?? undefined, | ||
| 105 | lastName: event.data.lastName ?? undefined, | ||
| 106 | avatarUrl: event.data.profilePictureUrl ?? undefined, | ||
| 107 | status: "active", | ||
| 108 | updatedAt: Date.now(), | ||
| 109 | }); | ||
| 110 | }, | ||
| 111 | |||
| 112 | "user.deleted": async (ctx, event) => { | ||
| 113 | const account = await getAccountByWorkosId(ctx, event.data.id); | ||
| 114 | if (!account) return; | ||
| 115 | |||
| 116 | const memberships = await ctx.db | ||
| 117 | .query("workspaceMembers") | ||
| 118 | .withIndex("by_account", (q) => q.eq("accountId", account._id)) | ||
| 119 | .collect(); | ||
| 120 | for (const membership of memberships) { | ||
| 121 | await ctx.db.delete(membership._id); | ||
| 122 | } | ||
| 123 | |||
| 124 | await ctx.db.delete(account._id); | ||
| 125 | }, | ||
| 126 | |||
| 127 | "organization.created": async (ctx, event) => { | ||
| 128 | const now = Date.now(); | ||
| 129 | let organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | ||
| 130 | if (organization) { | ||
| 131 | await ctx.db.patch(organization._id, { | ||
| 132 | name: event.data.name, | ||
| 133 | status: "active", | ||
| 134 | updatedAt: now, | ||
| 135 | }); | ||
| 136 | organization = await ctx.db.get(organization._id); | ||
| 137 | } else { | ||
| 138 | const slug = await ensureUniqueOrganizationSlug(ctx, event.data.name); | ||
| 139 | const organizationId = await ctx.db.insert("organizations", { | ||
| 140 | workosOrgId: event.data.id, | ||
| 141 | slug, | ||
| 142 | name: event.data.name, | ||
| 143 | status: "active", | ||
| 144 | createdAt: now, | ||
| 145 | updatedAt: now, | ||
| 146 | }); | ||
| 147 | organization = await ctx.db.get(organizationId); | ||
| 148 | } | ||
| 149 | |||
| 150 | if (!organization) { | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | |||
| 154 | const existingWorkspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id); | ||
| 155 | if (existingWorkspace) { | ||
| 156 | await ctx.db.patch(existingWorkspace._id, { | ||
| 157 | organizationId: organization._id, | ||
| 158 | workosOrgId: event.data.id, | ||
| 159 | updatedAt: now, | ||
| 160 | }); | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | |||
| 164 | const organizationWorkspace = await getFirstWorkspaceByOrganizationId(ctx, organization._id); | ||
| 165 | if (organizationWorkspace) { | ||
| 166 | await ctx.db.patch(organizationWorkspace._id, { | ||
| 167 | workosOrgId: event.data.id, | ||
| 168 | updatedAt: now, | ||
| 169 | }); | ||
| 170 | return; | ||
| 171 | } | ||
| 172 | |||
| 173 | await ctx.db.insert("workspaces", { | ||
| 174 | workosOrgId: event.data.id, | ||
| 175 | organizationId: organization._id, | ||
| 176 | slug: "default", | ||
| 177 | name: "Default Workspace", | ||
| 178 | createdByAccountId: organization.createdByAccountId, | ||
| 179 | createdAt: now, | ||
| 180 | updatedAt: now, | ||
| 181 | }); | ||
| 182 | }, | ||
| 183 | |||
| 184 | "organization.updated": async (ctx, event) => { | ||
| 185 | const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | ||
| 186 | if (organization) { | ||
| 187 | await ctx.db.patch(organization._id, { | ||
| 188 | name: event.data.name, | ||
| 189 | updatedAt: Date.now(), | ||
| 190 | }); | ||
| 191 | } | ||
| 192 | }, | ||
| 193 | |||
| 194 | "organization.deleted": async (ctx, event) => { | ||
| 195 | const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | ||
| 196 | if (organization) { | ||
| 197 | await ctx.db.patch(organization._id, { | ||
| 198 | status: "deleted", | ||
| 199 | updatedAt: Date.now(), | ||
| 200 | }); | ||
| 201 | } | ||
| 202 | |||
| 203 | const workspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id); | ||
| 204 | if (!workspace) return; | ||
| 205 | |||
| 206 | const members = await ctx.db | ||
| 207 | .query("workspaceMembers") | ||
| 208 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id)) | ||
| 209 | .collect(); | ||
| 210 | for (const member of members) { | ||
| 211 | await ctx.db.delete(member._id); | ||
| 212 | } | ||
| 213 | |||
| 214 | await ctx.db.delete(workspace._id); | ||
| 215 | }, | ||
| 216 | |||
| 217 | "organization_membership.created": async (ctx, event) => { | ||
| 218 | const now = Date.now(); | ||
| 219 | const data = event.data as WorkosMembershipEventData; | ||
| 220 | const workosUserId = data.user_id ?? data.userId; | ||
| 221 | const workosOrgId = data.organization_id ?? data.organizationId; | ||
| 222 | if (!workosUserId || !workosOrgId) return; | ||
| 223 | |||
| 224 | const [account, workspace] = await Promise.all([ | ||
| 225 | getAccountByWorkosId(ctx, workosUserId), | ||
| 226 | getWorkspaceByWorkosOrgId(ctx, workosOrgId), | ||
| 227 | ]); | ||
| 228 | if (!account || !workspace) return; | ||
| 229 | |||
| 230 | const existing = await ctx.db | ||
| 231 | .query("workspaceMembers") | ||
| 232 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) | ||
| 233 | .unique(); | ||
| 234 | |||
| 235 | const role = deriveWorkspaceMembershipRole(data.role?.slug); | ||
| 236 | const status = deriveOrganizationMembershipState(data.status); | ||
| 237 | |||
| 238 | await upsertOrganizationMembership(ctx, { | ||
| 239 | organizationId: workspace.organizationId, | ||
| 240 | accountId: account._id, | ||
| 241 | role, | ||
| 242 | status, | ||
| 243 | billable: status === "active", | ||
| 244 | now, | ||
| 245 | }); | ||
| 246 | |||
| 247 | if (status === "active") { | ||
| 248 | await markPendingInvitesAcceptedByEmail(ctx, { | ||
| 249 | organizationId: workspace.organizationId, | ||
| 250 | email: account.email, | ||
| 251 | acceptedAt: now, | ||
| 252 | }); | ||
| 253 | } | ||
| 254 | |||
| 255 | if (existing) { | ||
| 256 | await ctx.db.patch(existing._id, { | ||
| 257 | workosOrgMembershipId: event.data.id, | ||
| 258 | role, | ||
| 259 | status, | ||
| 260 | updatedAt: now, | ||
| 261 | }); | ||
| 262 | return; | ||
| 263 | } | ||
| 264 | |||
| 265 | await ctx.db.insert("workspaceMembers", { | ||
| 266 | workspaceId: workspace._id, | ||
| 267 | accountId: account._id, | ||
| 268 | workosOrgMembershipId: event.data.id, | ||
| 269 | role, | ||
| 270 | status, | ||
| 271 | createdAt: now, | ||
| 272 | updatedAt: now, | ||
| 273 | }); | ||
| 274 | }, | ||
| 275 | |||
| 276 | "organization_membership.updated": async (ctx, event) => { | ||
| 277 | const now = Date.now(); | ||
| 278 | const data = event.data as WorkosMembershipEventData; | ||
| 279 | const { membership, account, workspace } = await resolveMembershipRecord(ctx, data); | ||
| 280 | |||
| 281 | if (!membership || !account || !workspace) { | ||
| 282 | return; | ||
| 283 | } | ||
| 284 | |||
| 285 | const role = deriveWorkspaceMembershipRole(data.role?.slug); | ||
| 286 | const status = deriveOrganizationMembershipState(data.status); | ||
| 287 | |||
| 288 | await upsertOrganizationMembership(ctx, { | ||
| 289 | organizationId: workspace.organizationId, | ||
| 290 | accountId: account._id, | ||
| 291 | role, | ||
| 292 | status, | ||
| 293 | billable: status === "active", | ||
| 294 | now, | ||
| 295 | }); | ||
| 296 | |||
| 297 | if (status === "active") { | ||
| 298 | await markPendingInvitesAcceptedByEmail(ctx, { | ||
| 299 | organizationId: workspace.organizationId, | ||
| 300 | email: account.email, | ||
| 301 | acceptedAt: now, | ||
| 302 | }); | ||
| 303 | } | ||
| 304 | |||
| 305 | await ctx.db.patch(membership._id, { | ||
| 306 | workosOrgMembershipId: data.id, | ||
| 307 | role, | ||
| 308 | status, | ||
| 309 | updatedAt: now, | ||
| 310 | }); | ||
| 311 | }, | ||
| 312 | |||
| 313 | "organization_membership.deleted": async (ctx, event) => { | ||
| 314 | const now = Date.now(); | ||
| 315 | const membership = await ctx.db | ||
| 316 | .query("workspaceMembers") | ||
| 317 | .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id)) | ||
| 318 | .unique(); | ||
| 319 | if (!membership) return; | ||
| 320 | |||
| 321 | const workspace = await ctx.db.get(membership.workspaceId); | ||
| 322 | if (workspace?.organizationId) { | ||
| 323 | await upsertOrganizationMembership(ctx, { | ||
| 324 | organizationId: workspace.organizationId, | ||
| 325 | accountId: membership.accountId, | ||
| 326 | role: membership.role, | ||
| 327 | status: "removed", | ||
| 328 | billable: false, | ||
| 329 | now, | ||
| 330 | }); | ||
| 331 | } | ||
| 332 | |||
| 333 | await ctx.db.delete(membership._id); | ||
| 334 | }, | ||
| 335 | } satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>; | ||
| @@ -0,0 +1,70 @@ | |||
| 1 | import { components } from "./_generated/api"; | ||
| 2 | import type { RunQueryCtx } from "./auth-types"; | ||
| 3 | |||
| 4 | type WorkosProfile = { | ||
| 5 | email?: string; | ||
| 6 | firstName?: string | null; | ||
| 7 | lastName?: string | null; | ||
| 8 | profilePictureUrl?: string | null; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { | ||
| 12 | for (const key of keys) { | ||
| 13 | const value = identity[key]; | ||
| 14 | if (typeof value === "string" && value.trim().length > 0) { | ||
| 15 | return value.trim(); | ||
| 16 | } | ||
| 17 | } | ||
| 18 | return undefined; | ||
| 19 | } | ||
| 20 | |||
| 21 | export async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { | ||
| 22 | try { | ||
| 23 | return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, { | ||
| 24 | id: workosUserId, | ||
| 25 | }); | ||
| 26 | } catch { | ||
| 27 | return null; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | export function resolveIdentityProfile(args: { | ||
| 32 | identity: Record<string, unknown> & { subject: string }; | ||
| 33 | authKitProfile: WorkosProfile | null; | ||
| 34 | }) { | ||
| 35 | const { identity, authKitProfile } = args; | ||
| 36 | |||
| 37 | const email = | ||
| 38 | authKitProfile?.email | ||
| 39 | ?? getIdentityString(identity, ["email", "https://workos.com/email", "upn"]) | ||
| 40 | ?? `${identity.subject}@workos.executor.local`; | ||
| 41 | |||
| 42 | const firstName = authKitProfile?.firstName | ||
| 43 | ?? getIdentityString(identity, ["given_name", "first_name", "https://workos.com/first_name"]); | ||
| 44 | |||
| 45 | const lastName = authKitProfile?.lastName | ||
| 46 | ?? getIdentityString(identity, ["family_name", "last_name", "https://workos.com/last_name"]); | ||
| 47 | |||
| 48 | const fullName = | ||
| 49 | (getIdentityString(identity, ["name", "https://workos.com/name"]) ?? [firstName, lastName].filter(Boolean).join(" ")) | ||
| 50 | || email; | ||
| 51 | |||
| 52 | const avatarUrl = | ||
| 53 | (authKitProfile?.profilePictureUrl ?? undefined) | ||
| 54 | ?? getIdentityString(identity, ["picture", "avatar_url", "https://workos.com/profile_picture_url"]); | ||
| 55 | |||
| 56 | const hintedWorkosOrgId = getIdentityString(identity, [ | ||
| 57 | "org_id", | ||
| 58 | "organization_id", | ||
| 59 | "https://workos.com/organization_id", | ||
| 60 | ]); | ||
| 61 | |||
| 62 | return { | ||
| 63 | email, | ||
| 64 | firstName, | ||
| 65 | lastName, | ||
| 66 | fullName, | ||
| 67 | avatarUrl, | ||
| 68 | hintedWorkosOrgId, | ||
| 69 | }; | ||
| 70 | } | ||
| @@ -0,0 +1,133 @@ | |||
| 1 | import type { Doc } from "./_generated/dataModel"; | ||
| 2 | import type { | ||
| 3 | AccountId, | ||
| 4 | DbCtx, | ||
| 5 | OrganizationId, | ||
| 6 | OrganizationMemberStatus, | ||
| 7 | OrganizationRole, | ||
| 8 | WorkspaceId, | ||
| 9 | WorkspaceMemberRole, | ||
| 10 | } from "./auth-types"; | ||
| 11 | |||
| 12 | export function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): WorkspaceMemberRole { | ||
| 13 | if (role === "owner") { | ||
| 14 | return "owner"; | ||
| 15 | } | ||
| 16 | if (role === "admin") { | ||
| 17 | return "admin"; | ||
| 18 | } | ||
| 19 | return "member"; | ||
| 20 | } | ||
| 21 | |||
| 22 | export async function upsertOrganizationMembership( | ||
| 23 | ctx: DbCtx, | ||
| 24 | args: { | ||
| 25 | organizationId: OrganizationId; | ||
| 26 | accountId: AccountId; | ||
| 27 | role: OrganizationRole; | ||
| 28 | status: OrganizationMemberStatus; | ||
| 29 | billable: boolean; | ||
| 30 | invitedByAccountId?: AccountId; | ||
| 31 | now: number; | ||
| 32 | }, | ||
| 33 | ) { | ||
| 34 | const existing = await ctx.db | ||
| 35 | .query("organizationMembers") | ||
| 36 | .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId)) | ||
| 37 | .unique(); | ||
| 38 | |||
| 39 | if (existing) { | ||
| 40 | await ctx.db.patch(existing._id, { | ||
| 41 | role: args.role, | ||
| 42 | status: args.status, | ||
| 43 | billable: args.billable, | ||
| 44 | invitedByAccountId: args.invitedByAccountId, | ||
| 45 | joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt, | ||
| 46 | updatedAt: args.now, | ||
| 47 | }); | ||
| 48 | return; | ||
| 49 | } | ||
| 50 | |||
| 51 | await ctx.db.insert("organizationMembers", { | ||
| 52 | organizationId: args.organizationId, | ||
| 53 | accountId: args.accountId, | ||
| 54 | role: args.role, | ||
| 55 | status: args.status, | ||
| 56 | billable: args.billable, | ||
| 57 | invitedByAccountId: args.invitedByAccountId, | ||
| 58 | joinedAt: args.status === "active" ? args.now : undefined, | ||
| 59 | createdAt: args.now, | ||
| 60 | updatedAt: args.now, | ||
| 61 | }); | ||
| 62 | } | ||
| 63 | |||
| 64 | export async function ensureWorkspaceMembership( | ||
| 65 | ctx: DbCtx, | ||
| 66 | args: { | ||
| 67 | workspaceId: WorkspaceId; | ||
| 68 | accountId: AccountId; | ||
| 69 | role: WorkspaceMemberRole; | ||
| 70 | now: number; | ||
| 71 | }, | ||
| 72 | ) { | ||
| 73 | const existing = await ctx.db | ||
| 74 | .query("workspaceMembers") | ||
| 75 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId)) | ||
| 76 | .unique(); | ||
| 77 | |||
| 78 | if (existing) { | ||
| 79 | if (existing.status === "active") { | ||
| 80 | await ctx.db.patch(existing._id, { | ||
| 81 | role: args.role, | ||
| 82 | updatedAt: args.now, | ||
| 83 | }); | ||
| 84 | } | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | |||
| 88 | await ctx.db.insert("workspaceMembers", { | ||
| 89 | workspaceId: args.workspaceId, | ||
| 90 | accountId: args.accountId, | ||
| 91 | role: args.role, | ||
| 92 | status: "active", | ||
| 93 | createdAt: args.now, | ||
| 94 | updatedAt: args.now, | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | |||
| 98 | export async function markPendingInvitesAcceptedByEmail( | ||
| 99 | ctx: DbCtx, | ||
| 100 | args: { | ||
| 101 | organizationId: OrganizationId; | ||
| 102 | email?: string; | ||
| 103 | acceptedAt: number; | ||
| 104 | }, | ||
| 105 | ) { | ||
| 106 | if (!args.email) { | ||
| 107 | return; | ||
| 108 | } | ||
| 109 | |||
| 110 | const normalizedEmail = args.email.toLowerCase(); | ||
| 111 | const pendingInvites = await ctx.db | ||
| 112 | .query("invites") | ||
| 113 | .withIndex("by_org_email_status", (q) => | ||
| 114 | q.eq("organizationId", args.organizationId).eq("email", normalizedEmail).eq("status", "pending"), | ||
| 115 | ) | ||
| 116 | .collect(); | ||
| 117 | |||
| 118 | for (const invite of pendingInvites) { | ||
| 119 | await ctx.db.patch(invite._id, { | ||
| 120 | status: "accepted", | ||
| 121 | acceptedAt: args.acceptedAt, | ||
| 122 | updatedAt: args.acceptedAt, | ||
| 123 | }); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | export function deriveWorkspaceMembershipRole(workosRoleSlug?: string): Doc<"workspaceMembers">["role"] { | ||
| 128 | return workosRoleSlug === "admin" ? "admin" : "member"; | ||
| 129 | } | ||
| 130 | |||
| 131 | export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus { | ||
| 132 | return status === "active" ? "active" : "pending"; | ||
| 133 | } | ||
| @@ -0,0 +1,89 @@ | |||
| 1 | import { slugify } from "../core/src/identity"; | ||
| 2 | import { ensureUniqueSlug } from "../core/src/slug"; | ||
| 3 | import type { DbCtx } from "./auth-types"; | ||
| 4 | |||
| 5 | function titleCaseWords(input: string): string { | ||
| 6 | return input | ||
| 7 | .split(" ") | ||
| 8 | .filter((segment) => segment.length > 0) | ||
| 9 | .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()) | ||
| 10 | .join(" "); | ||
| 11 | } | ||
| 12 | |||
| 13 | function escapeRegex(value: string): string { | ||
| 14 | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| 15 | } | ||
| 16 | |||
| 17 | export function isGeneratedPersonalOrganizationName(name: string, workosUserId: string): boolean { | ||
| 18 | if (/workspace$/i.test(name)) { | ||
| 19 | return true; | ||
| 20 | } | ||
| 21 | |||
| 22 | if (/^user(?:[\s_][a-z0-9]+)?'s organization$/i.test(name)) { | ||
| 23 | return true; | ||
| 24 | } | ||
| 25 | |||
| 26 | return new RegExp(`^${escapeRegex(workosUserId)}'s organization$`, "i").test(name); | ||
| 27 | } | ||
| 28 | |||
| 29 | export function isGeneratedPersonalWorkspaceName(name: string, workosUserId: string): boolean { | ||
| 30 | if (/^my'?s workspace$/i.test(name)) { | ||
| 31 | return true; | ||
| 32 | } | ||
| 33 | |||
| 34 | if (/^user(?:[\s_][a-z0-9]+)?'s workspace$/i.test(name)) { | ||
| 35 | return true; | ||
| 36 | } | ||
| 37 | |||
| 38 | return new RegExp(`^${escapeRegex(workosUserId)}'s workspace$`, "i").test(name); | ||
| 39 | } | ||
| 40 | |||
| 41 | function deriveOwnerLabel(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }): string { | ||
| 42 | const firstName = args.firstName?.trim(); | ||
| 43 | if (firstName && !/^my$/i.test(firstName)) { | ||
| 44 | return firstName; | ||
| 45 | } | ||
| 46 | |||
| 47 | const fullName = args.fullName?.trim(); | ||
| 48 | if (fullName && !fullName.includes("@")) { | ||
| 49 | return fullName; | ||
| 50 | } | ||
| 51 | |||
| 52 | const emailLocalPart = args.email.split("@")[0]?.trim(); | ||
| 53 | if (emailLocalPart) { | ||
| 54 | const normalized = emailLocalPart | ||
| 55 | .replace(/[._-]+/g, " ") | ||
| 56 | .replace(/[^a-zA-Z0-9 ]+/g, " ") | ||
| 57 | .replace(/\s+/g, " ") | ||
| 58 | .trim(); | ||
| 59 | if (normalized.length > 0) { | ||
| 60 | return titleCaseWords(normalized); | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | return `User ${args.workosUserId.slice(-6)}`; | ||
| 65 | } | ||
| 66 | |||
| 67 | export function derivePersonalNames(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }) { | ||
| 68 | const ownerLabel = deriveOwnerLabel(args); | ||
| 69 | return { | ||
| 70 | organizationName: `${ownerLabel}'s Organization`, | ||
| 71 | workspaceName: `${ownerLabel}'s Workspace`, | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | export function buildPersonalWorkspaceSlugSeed(email: string, workosUserId: string): string { | ||
| 76 | const baseSlug = slugify(email.split("@")[0] ?? workosUserId, "workspace"); | ||
| 77 | return `${baseSlug}-${workosUserId.slice(-6)}`; | ||
| 78 | } | ||
| 79 | |||
| 80 | export async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> { | ||
| 81 | const baseSlug = slugify(baseName, "workspace"); | ||
| 82 | return await ensureUniqueSlug(baseSlug, async (candidate) => { | ||
| 83 | const collision = await ctx.db | ||
| 84 | .query("organizations") | ||
| 85 | .withIndex("by_slug", (q) => q.eq("slug", candidate)) | ||
| 86 | .unique(); | ||
| 87 | return collision !== null; | ||
| 88 | }); | ||
| 89 | } | ||
| @@ -0,0 +1,180 @@ | |||
| 1 | import type { Id } from "./_generated/dataModel"; | ||
| 2 | import { upsertOrganizationMembership } from "./auth-memberships"; | ||
| 3 | import { | ||
| 4 | buildPersonalWorkspaceSlugSeed, | ||
| 5 | derivePersonalNames, | ||
| 6 | ensureUniqueOrganizationSlug, | ||
| 7 | isGeneratedPersonalOrganizationName, | ||
| 8 | isGeneratedPersonalWorkspaceName, | ||
| 9 | } from "./auth-naming"; | ||
| 10 | import type { AccountId, DbCtx } from "./auth-types"; | ||
| 11 | |||
| 12 | type PersonalWorkspaceOptions = { | ||
| 13 | email: string; | ||
| 14 | firstName?: string; | ||
| 15 | fullName?: string; | ||
| 16 | workosUserId: string; | ||
| 17 | now: number; | ||
| 18 | }; | ||
| 19 | |||
| 20 | async function renameGeneratedPersonalWorkspaceArtifacts( | ||
| 21 | ctx: DbCtx, | ||
| 22 | args: { | ||
| 23 | accountId: AccountId; | ||
| 24 | workspaceId: Id<"workspaces">; | ||
| 25 | organizationId: Id<"organizations">; | ||
| 26 | workspaceName: string; | ||
| 27 | organizationName: string; | ||
| 28 | workosUserId: string; | ||
| 29 | now: number; | ||
| 30 | }, | ||
| 31 | ) { | ||
| 32 | const workspace = await ctx.db.get(args.workspaceId); | ||
| 33 | if (!workspace || workspace.createdByAccountId !== args.accountId) { | ||
| 34 | return; | ||
| 35 | } | ||
| 36 | |||
| 37 | const organization = await ctx.db.get(args.organizationId); | ||
| 38 | if (!organization || organization.createdByAccountId !== args.accountId) { | ||
| 39 | return; | ||
| 40 | } | ||
| 41 | |||
| 42 | if ( | ||
| 43 | isGeneratedPersonalOrganizationName(organization.name, args.workosUserId) | ||
| 44 | && organization.name !== args.organizationName | ||
| 45 | ) { | ||
| 46 | await ctx.db.patch(organization._id, { | ||
| 47 | name: args.organizationName, | ||
| 48 | updatedAt: args.now, | ||
| 49 | }); | ||
| 50 | } | ||
| 51 | |||
| 52 | if (isGeneratedPersonalWorkspaceName(workspace.name, args.workosUserId) && workspace.name !== args.workspaceName) { | ||
| 53 | await ctx.db.patch(workspace._id, { | ||
| 54 | name: args.workspaceName, | ||
| 55 | updatedAt: args.now, | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | export async function ensurePersonalWorkspace(ctx: DbCtx, accountId: AccountId, opts: PersonalWorkspaceOptions) { | ||
| 61 | const personalNames = derivePersonalNames({ | ||
| 62 | firstName: opts.firstName, | ||
| 63 | fullName: opts.fullName, | ||
| 64 | email: opts.email, | ||
| 65 | workosUserId: opts.workosUserId, | ||
| 66 | }); | ||
| 67 | |||
| 68 | const memberships = await ctx.db | ||
| 69 | .query("workspaceMembers") | ||
| 70 | .withIndex("by_account", (q) => q.eq("accountId", accountId)) | ||
| 71 | .collect(); | ||
| 72 | |||
| 73 | for (const membership of memberships) { | ||
| 74 | const workspace = await ctx.db.get(membership.workspaceId); | ||
| 75 | if (!workspace || workspace.createdByAccountId !== accountId) { | ||
| 76 | continue; | ||
| 77 | } | ||
| 78 | |||
| 79 | await renameGeneratedPersonalWorkspaceArtifacts(ctx, { | ||
| 80 | accountId, | ||
| 81 | workspaceId: workspace._id, | ||
| 82 | organizationId: workspace.organizationId, | ||
| 83 | workspaceName: personalNames.workspaceName, | ||
| 84 | organizationName: personalNames.organizationName, | ||
| 85 | workosUserId: opts.workosUserId, | ||
| 86 | now: opts.now, | ||
| 87 | }); | ||
| 88 | |||
| 89 | await upsertOrganizationMembership(ctx, { | ||
| 90 | organizationId: workspace.organizationId, | ||
| 91 | accountId, | ||
| 92 | role: "owner", | ||
| 93 | status: "active", | ||
| 94 | billable: true, | ||
| 95 | now: opts.now, | ||
| 96 | }); | ||
| 97 | |||
| 98 | return { | ||
| 99 | workspace: await ctx.db.get(workspace._id), | ||
| 100 | membership, | ||
| 101 | }; | ||
| 102 | } | ||
| 103 | |||
| 104 | const organizationSlug = await ensureUniqueOrganizationSlug(ctx, personalNames.organizationName); | ||
| 105 | const organizationId = await ctx.db.insert("organizations", { | ||
| 106 | slug: organizationSlug, | ||
| 107 | name: personalNames.organizationName, | ||
| 108 | status: "active", | ||
| 109 | createdByAccountId: accountId, | ||
| 110 | createdAt: opts.now, | ||
| 111 | updatedAt: opts.now, | ||
| 112 | }); | ||
| 113 | |||
| 114 | const workspaceId = await ctx.db.insert("workspaces", { | ||
| 115 | organizationId, | ||
| 116 | slug: buildPersonalWorkspaceSlugSeed(opts.email, opts.workosUserId), | ||
| 117 | name: personalNames.workspaceName, | ||
| 118 | createdByAccountId: accountId, | ||
| 119 | createdAt: opts.now, | ||
| 120 | updatedAt: opts.now, | ||
| 121 | }); | ||
| 122 | |||
| 123 | await upsertOrganizationMembership(ctx, { | ||
| 124 | organizationId, | ||
| 125 | accountId, | ||
| 126 | role: "owner", | ||
| 127 | status: "active", | ||
| 128 | billable: true, | ||
| 129 | now: opts.now, | ||
| 130 | }); | ||
| 131 | |||
| 132 | const membershipId = await ctx.db.insert("workspaceMembers", { | ||
| 133 | workspaceId, | ||
| 134 | accountId, | ||
| 135 | role: "owner", | ||
| 136 | status: "active", | ||
| 137 | createdAt: opts.now, | ||
| 138 | updatedAt: opts.now, | ||
| 139 | }); | ||
| 140 | |||
| 141 | return { | ||
| 142 | workspace: await ctx.db.get(workspaceId), | ||
| 143 | membership: await ctx.db.get(membershipId), | ||
| 144 | }; | ||
| 145 | } | ||
| 146 | |||
| 147 | export async function refreshGeneratedPersonalWorkspaceNames( | ||
| 148 | ctx: DbCtx, | ||
| 149 | accountId: AccountId, | ||
| 150 | opts: PersonalWorkspaceOptions, | ||
| 151 | ) { | ||
| 152 | const personalNames = derivePersonalNames({ | ||
| 153 | firstName: opts.firstName, | ||
| 154 | fullName: opts.fullName, | ||
| 155 | email: opts.email, | ||
| 156 | workosUserId: opts.workosUserId, | ||
| 157 | }); | ||
| 158 | |||
| 159 | const memberships = await ctx.db | ||
| 160 | .query("workspaceMembers") | ||
| 161 | .withIndex("by_account", (q) => q.eq("accountId", accountId)) | ||
| 162 | .collect(); | ||
| 163 | |||
| 164 | for (const membership of memberships) { | ||
| 165 | const workspace = await ctx.db.get(membership.workspaceId); | ||
| 166 | if (!workspace) { | ||
| 167 | continue; | ||
| 168 | } | ||
| 169 | |||
| 170 | await renameGeneratedPersonalWorkspaceArtifacts(ctx, { | ||
| 171 | accountId, | ||
| 172 | workspaceId: workspace._id, | ||
| 173 | organizationId: workspace.organizationId, | ||
| 174 | workspaceName: personalNames.workspaceName, | ||
| 175 | organizationName: personalNames.organizationName, | ||
| 176 | workosUserId: opts.workosUserId, | ||
| 177 | now: opts.now, | ||
| 178 | }); | ||
| 179 | } | ||
| 180 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | import type { Doc, Id } from "./_generated/dataModel"; | ||
| 2 | import type { MutationCtx } from "./_generated/server"; | ||
| 3 | |||
| 4 | export type DbCtx = Pick<MutationCtx, "db">; | ||
| 5 | export type RunQueryCtx = Pick<MutationCtx, "runQuery">; | ||
| 6 | |||
| 7 | export type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | ||
| 8 | export type OrganizationMemberStatus = "active" | "pending" | "removed"; | ||
| 9 | export type WorkspaceMemberRole = Doc<"workspaceMembers">["role"]; | ||
| 10 | |||
| 11 | export type OrganizationId = Id<"organizations">; | ||
| 12 | export type WorkspaceId = Id<"workspaces">; | ||
| 13 | export type AccountId = Id<"accounts">; | ||
| @@ -1,26 +1,9 @@ | |||
| 1 | import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit"; | 1 | import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit"; |
| 2 | import { components, internal } from "./_generated/api"; | 2 | import { components, internal } from "./_generated/api"; |
| 3 | import type { DataModel, Doc, Id } from "./_generated/dataModel"; | 3 | import type { DataModel } from "./_generated/dataModel"; |
| 4 | import type { MutationCtx } from "./_generated/server"; | ||
| 5 | import { internalMutation, mutation } from "./_generated/server"; | 4 | import { internalMutation, mutation } from "./_generated/server"; |
| 6 | import { slugify } from "../core/src/identity"; | 5 | import { bootstrapCurrentWorkosAccountImpl } from "./auth-bootstrap"; |
| 7 | import { ensureUniqueSlug } from "../core/src/slug"; | 6 | import { workosEventHandlers } from "./auth-event-handlers"; |
| 8 | |||
| 9 | type DbCtx = Pick<MutationCtx, "db">; | ||
| 10 | type RunQueryCtx = Pick<MutationCtx, "runQuery">; | ||
| 11 | type WorkosEventCtx = Pick<MutationCtx, "db" | "runQuery">; | ||
| 12 | type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | ||
| 13 | type OrganizationMemberStatus = "active" | "pending" | "removed"; | ||
| 14 | |||
| 15 | function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): Doc<"workspaceMembers">["role"] { | ||
| 16 | if (role === "owner") { | ||
| 17 | return "owner"; | ||
| 18 | } | ||
| 19 | if (role === "admin") { | ||
| 20 | return "admin"; | ||
| 21 | } | ||
| 22 | return "member"; | ||
| 23 | } | ||
| 24 | 7 | ||
| 25 | const workosEnabled = Boolean( | 8 | const 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 | ||
| 52 | function titleCaseWords(input: string): string { | 35 | const 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 | |||
| 60 | function escapeRegex(value: string): string { | ||
| 61 | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| 62 | } | ||
| 63 | |||
| 64 | function isGeneratedPersonalOrganizationName(name: string, workosUserId: string): boolean { | ||
| 65 | if (/workspace$/i.test(name)) { | ||
| 66 | return true; | ||
| 67 | } | ||
| 68 | |||
| 69 | if (/^user(?:[\s_][a-z0-9]+)?'s organization$/i.test(name)) { | ||
| 70 | return true; | ||
| 71 | } | ||
| 72 | |||
| 73 | return new RegExp(`^${escapeRegex(workosUserId)}'s organization$`, "i").test(name); | ||
| 74 | } | ||
| 75 | |||
| 76 | function isGeneratedPersonalWorkspaceName(name: string, workosUserId: string): boolean { | ||
| 77 | if (/^my'?s workspace$/i.test(name)) { | ||
| 78 | return true; | ||
| 79 | } | ||
| 80 | |||
| 81 | if (/^user(?:[\s_][a-z0-9]+)?'s workspace$/i.test(name)) { | ||
| 82 | return true; | ||
| 83 | } | ||
| 84 | |||
| 85 | return new RegExp(`^${escapeRegex(workosUserId)}'s workspace$`, "i").test(name); | ||
| 86 | } | ||
| 87 | |||
| 88 | function deriveOwnerLabel(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }): string { | ||
| 89 | const firstName = args.firstName?.trim(); | ||
| 90 | if (firstName && !/^my$/i.test(firstName)) { | ||
| 91 | return firstName; | ||
| 92 | } | ||
| 93 | |||
| 94 | const fullName = args.fullName?.trim(); | ||
| 95 | if (fullName && !fullName.includes("@")) { | ||
| 96 | return fullName; | ||
| 97 | } | ||
| 98 | |||
| 99 | const emailLocalPart = args.email.split("@")[0]?.trim(); | ||
| 100 | if (emailLocalPart) { | ||
| 101 | const normalized = emailLocalPart | ||
| 102 | .replace(/[._-]+/g, " ") | ||
| 103 | .replace(/[^a-zA-Z0-9 ]+/g, " ") | ||
| 104 | .replace(/\s+/g, " ") | ||
| 105 | .trim(); | ||
| 106 | if (normalized.length > 0) { | ||
| 107 | return titleCaseWords(normalized); | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | return `User ${args.workosUserId.slice(-6)}`; | ||
| 112 | } | ||
| 113 | |||
| 114 | function derivePersonalNames(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }) { | ||
| 115 | const ownerLabel = deriveOwnerLabel(args); | ||
| 116 | return { | ||
| 117 | organizationName: `${ownerLabel}'s Organization`, | ||
| 118 | workspaceName: `${ownerLabel}'s Workspace`, | ||
| 119 | }; | ||
| 120 | } | ||
| 121 | |||
| 122 | async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) { | ||
| 123 | return await ctx.db | ||
| 124 | .query("accounts") | ||
| 125 | .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", workosUserId)) | ||
| 126 | .unique(); | ||
| 127 | } | ||
| 128 | |||
| 129 | async function getWorkspaceByWorkosOrgId(ctx: DbCtx, workosOrgId: string) { | ||
| 130 | return await ctx.db | ||
| 131 | .query("workspaces") | ||
| 132 | .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId)) | ||
| 133 | .unique(); | ||
| 134 | } | ||
| 135 | |||
| 136 | async function getOrganizationByWorkosOrgId(ctx: DbCtx, workosOrgId: string) { | ||
| 137 | return await ctx.db | ||
| 138 | .query("organizations") | ||
| 139 | .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId)) | ||
| 140 | .unique(); | ||
| 141 | } | ||
| 142 | |||
| 143 | async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: Id<"organizations">) { | ||
| 144 | return await ctx.db | ||
| 145 | .query("workspaces") | ||
| 146 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) | ||
| 147 | .first(); | ||
| 148 | } | ||
| 149 | |||
| 150 | async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> { | ||
| 151 | const baseSlug = slugify(baseName, "workspace"); | ||
| 152 | return await ensureUniqueSlug(baseSlug, async (candidate) => { | ||
| 153 | const collision = await ctx.db | ||
| 154 | .query("organizations") | ||
| 155 | .withIndex("by_slug", (q) => q.eq("slug", candidate)) | ||
| 156 | .unique(); | ||
| 157 | return collision !== null; | ||
| 158 | }); | ||
| 159 | } | ||
| 160 | |||
| 161 | async function upsertOrganizationMembership( | ||
| 162 | ctx: DbCtx, | ||
| 163 | args: { | ||
| 164 | organizationId: Id<"organizations">; | ||
| 165 | accountId: Id<"accounts">; | ||
| 166 | role: OrganizationRole; | ||
| 167 | status: OrganizationMemberStatus; | ||
| 168 | billable: boolean; | ||
| 169 | invitedByAccountId?: Id<"accounts">; | ||
| 170 | now: number; | ||
| 171 | }, | ||
| 172 | ) { | ||
| 173 | const existing = await ctx.db | ||
| 174 | .query("organizationMembers") | ||
| 175 | .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId)) | ||
| 176 | .unique(); | ||
| 177 | |||
| 178 | if (existing) { | ||
| 179 | await ctx.db.patch(existing._id, { | ||
| 180 | role: args.role, | ||
| 181 | status: args.status, | ||
| 182 | billable: args.billable, | ||
| 183 | invitedByAccountId: args.invitedByAccountId, | ||
| 184 | joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt, | ||
| 185 | updatedAt: args.now, | ||
| 186 | }); | ||
| 187 | return; | ||
| 188 | } | ||
| 189 | |||
| 190 | await ctx.db.insert("organizationMembers", { | ||
| 191 | organizationId: args.organizationId, | ||
| 192 | accountId: args.accountId, | ||
| 193 | role: args.role, | ||
| 194 | status: args.status, | ||
| 195 | billable: args.billable, | ||
| 196 | invitedByAccountId: args.invitedByAccountId, | ||
| 197 | joinedAt: args.status === "active" ? args.now : undefined, | ||
| 198 | createdAt: args.now, | ||
| 199 | updatedAt: args.now, | ||
| 200 | }); | ||
| 201 | } | ||
| 202 | |||
| 203 | async function ensureWorkspaceMembership( | ||
| 204 | ctx: DbCtx, | ||
| 205 | args: { | ||
| 206 | workspaceId: Id<"workspaces">; | ||
| 207 | accountId: Id<"accounts">; | ||
| 208 | role: Doc<"workspaceMembers">["role"]; | ||
| 209 | now: number; | ||
| 210 | }, | ||
| 211 | ) { | ||
| 212 | const existing = await ctx.db | ||
| 213 | .query("workspaceMembers") | ||
| 214 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId)) | ||
| 215 | .unique(); | ||
| 216 | |||
| 217 | if (existing) { | ||
| 218 | if (existing.status === "active") { | ||
| 219 | await ctx.db.patch(existing._id, { | ||
| 220 | role: args.role, | ||
| 221 | updatedAt: args.now, | ||
| 222 | }); | ||
| 223 | } | ||
| 224 | return; | ||
| 225 | } | ||
| 226 | |||
| 227 | await ctx.db.insert("workspaceMembers", { | ||
| 228 | workspaceId: args.workspaceId, | ||
| 229 | accountId: args.accountId, | ||
| 230 | role: args.role, | ||
| 231 | status: "active", | ||
| 232 | createdAt: args.now, | ||
| 233 | updatedAt: args.now, | ||
| 234 | }); | ||
| 235 | } | ||
| 236 | |||
| 237 | async function markPendingInvitesAcceptedByEmail( | ||
| 238 | ctx: DbCtx, | ||
| 239 | args: { | ||
| 240 | organizationId: Id<"organizations">; | ||
| 241 | email?: string; | ||
| 242 | acceptedAt: number; | ||
| 243 | }, | ||
| 244 | ) { | ||
| 245 | if (!args.email) { | ||
| 246 | return; | ||
| 247 | } | ||
| 248 | |||
| 249 | const normalizedEmail = args.email.toLowerCase(); | ||
| 250 | const pendingInvites = await ctx.db | ||
| 251 | .query("invites") | ||
| 252 | .withIndex("by_org_email_status", (q) => | ||
| 253 | q.eq("organizationId", args.organizationId).eq("email", normalizedEmail).eq("status", "pending"), | ||
| 254 | ) | ||
| 255 | .collect(); | ||
| 256 | |||
| 257 | for (const invite of pendingInvites) { | ||
| 258 | await ctx.db.patch(invite._id, { | ||
| 259 | status: "accepted", | ||
| 260 | acceptedAt: args.acceptedAt, | ||
| 261 | updatedAt: args.acceptedAt, | ||
| 262 | }); | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | async function ensurePersonalWorkspace( | ||
| 267 | ctx: DbCtx, | ||
| 268 | accountId: Id<"accounts">, | ||
| 269 | opts: { email: string; firstName?: string; fullName?: string; workosUserId: string; now: number }, | ||
| 270 | ) { | ||
| 271 | const personalNames = derivePersonalNames({ | ||
| 272 | firstName: opts.firstName, | ||
| 273 | fullName: opts.fullName, | ||
| 274 | email: opts.email, | ||
| 275 | workosUserId: opts.workosUserId, | ||
| 276 | }); | ||
| 277 | |||
| 278 | const memberships = await ctx.db | ||
| 279 | .query("workspaceMembers") | ||
| 280 | .withIndex("by_account", (q) => q.eq("accountId", accountId)) | ||
| 281 | .collect(); | ||
| 282 | |||
| 283 | for (const membership of memberships) { | ||
| 284 | const workspace = await ctx.db.get(membership.workspaceId); | ||
| 285 | if (workspace && workspace.createdByAccountId === accountId) { | ||
| 286 | const organization = await ctx.db.get(workspace.organizationId); | ||
| 287 | |||
| 288 | if (organization && organization.createdByAccountId === accountId) { | ||
| 289 | const shouldRenameOrganization = isGeneratedPersonalOrganizationName(organization.name, opts.workosUserId); | ||
| 290 | if (shouldRenameOrganization && organization.name !== personalNames.organizationName) { | ||
| 291 | await ctx.db.patch(organization._id, { | ||
| 292 | name: personalNames.organizationName, | ||
| 293 | updatedAt: opts.now, | ||
| 294 | }); | ||
| 295 | } | ||
| 296 | } | ||
| 297 | |||
| 298 | const shouldRenameWorkspace = isGeneratedPersonalWorkspaceName(workspace.name, opts.workosUserId); | ||
| 299 | if (shouldRenameWorkspace && workspace.name !== personalNames.workspaceName) { | ||
| 300 | await ctx.db.patch(workspace._id, { | ||
| 301 | name: personalNames.workspaceName, | ||
| 302 | updatedAt: opts.now, | ||
| 303 | }); | ||
| 304 | } | ||
| 305 | |||
| 306 | await upsertOrganizationMembership(ctx, { | ||
| 307 | organizationId: workspace.organizationId, | ||
| 308 | accountId, | ||
| 309 | role: "owner", | ||
| 310 | status: "active", | ||
| 311 | billable: true, | ||
| 312 | now: opts.now, | ||
| 313 | }); | ||
| 314 | const refreshedWorkspace = await ctx.db.get(workspace._id); | ||
| 315 | return { workspace: refreshedWorkspace, membership }; | ||
| 316 | } | ||
| 317 | } | ||
| 318 | |||
| 319 | const organizationSlug = await ensureUniqueOrganizationSlug(ctx, personalNames.organizationName); | ||
| 320 | const organizationId = await ctx.db.insert("organizations", { | ||
| 321 | slug: organizationSlug, | ||
| 322 | name: personalNames.organizationName, | ||
| 323 | status: "active", | ||
| 324 | createdByAccountId: accountId, | ||
| 325 | createdAt: opts.now, | ||
| 326 | updatedAt: opts.now, | ||
| 327 | }); | ||
| 328 | |||
| 329 | const baseSlug = slugify(opts.email.split("@")[0] ?? opts.workosUserId, "workspace"); | ||
| 330 | const workspaceId = await ctx.db.insert("workspaces", { | ||
| 331 | organizationId, | ||
| 332 | slug: `${baseSlug}-${opts.workosUserId.slice(-6)}`, | ||
| 333 | name: personalNames.workspaceName, | ||
| 334 | createdByAccountId: accountId, | ||
| 335 | createdAt: opts.now, | ||
| 336 | updatedAt: opts.now, | ||
| 337 | }); | ||
| 338 | |||
| 339 | await upsertOrganizationMembership(ctx, { | ||
| 340 | organizationId, | ||
| 341 | accountId, | ||
| 342 | role: "owner", | ||
| 343 | status: "active", | ||
| 344 | billable: true, | ||
| 345 | now: opts.now, | ||
| 346 | }); | ||
| 347 | |||
| 348 | const userId = await ctx.db.insert("workspaceMembers", { | ||
| 349 | workspaceId, | ||
| 350 | accountId, | ||
| 351 | role: "owner", | ||
| 352 | status: "active", | ||
| 353 | createdAt: opts.now, | ||
| 354 | updatedAt: opts.now, | ||
| 355 | }); | ||
| 356 | |||
| 357 | return { | ||
| 358 | workspace: await ctx.db.get(workspaceId), | ||
| 359 | membership: await ctx.db.get(userId), | ||
| 360 | }; | ||
| 361 | } | ||
| 362 | |||
| 363 | async function refreshGeneratedPersonalWorkspaceNames( | ||
| 364 | ctx: DbCtx, | ||
| 365 | accountId: Id<"accounts">, | ||
| 366 | opts: { email: string; firstName?: string; fullName?: string; workosUserId: string; now: number }, | ||
| 367 | ) { | ||
| 368 | const personalNames = derivePersonalNames({ | ||
| 369 | firstName: opts.firstName, | ||
| 370 | fullName: opts.fullName, | ||
| 371 | email: opts.email, | ||
| 372 | workosUserId: opts.workosUserId, | ||
| 373 | }); | ||
| 374 | |||
| 375 | const memberships = await ctx.db | ||
| 376 | .query("workspaceMembers") | ||
| 377 | .withIndex("by_account", (q) => q.eq("accountId", accountId)) | ||
| 378 | .collect(); | ||
| 379 | |||
| 380 | for (const membership of memberships) { | ||
| 381 | const workspace = await ctx.db.get(membership.workspaceId); | ||
| 382 | if (!workspace || workspace.createdByAccountId !== accountId) { | ||
| 383 | continue; | ||
| 384 | } | ||
| 385 | |||
| 386 | const organization = await ctx.db.get(workspace.organizationId); | ||
| 387 | if (!organization || organization.createdByAccountId !== accountId) { | ||
| 388 | continue; | ||
| 389 | } | ||
| 390 | |||
| 391 | if ( | ||
| 392 | isGeneratedPersonalOrganizationName(organization.name, opts.workosUserId) | ||
| 393 | && organization.name !== personalNames.organizationName | ||
| 394 | ) { | ||
| 395 | await ctx.db.patch(organization._id, { | ||
| 396 | name: personalNames.organizationName, | ||
| 397 | updatedAt: opts.now, | ||
| 398 | }); | ||
| 399 | } | ||
| 400 | |||
| 401 | if ( | ||
| 402 | isGeneratedPersonalWorkspaceName(workspace.name, opts.workosUserId) | ||
| 403 | && workspace.name !== personalNames.workspaceName | ||
| 404 | ) { | ||
| 405 | await ctx.db.patch(workspace._id, { | ||
| 406 | name: personalNames.workspaceName, | ||
| 407 | updatedAt: opts.now, | ||
| 408 | }); | ||
| 409 | } | ||
| 410 | } | ||
| 411 | } | ||
| 412 | |||
| 413 | function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { | ||
| 414 | for (const key of keys) { | ||
| 415 | const value = identity[key]; | ||
| 416 | if (typeof value === "string" && value.trim().length > 0) { | ||
| 417 | return value.trim(); | ||
| 418 | } | ||
| 419 | } | ||
| 420 | return undefined; | ||
| 421 | } | ||
| 422 | |||
| 423 | async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { | ||
| 424 | try { | ||
| 425 | return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, { | ||
| 426 | id: workosUserId, | ||
| 427 | }); | ||
| 428 | } catch { | ||
| 429 | return null; | ||
| 430 | } | ||
| 431 | } | ||
| 432 | |||
| 433 | const workosEventHandlers = { | ||
| 434 | "user.created": async (ctx, event) => { | ||
| 435 | const now = Date.now(); | ||
| 436 | const data = event.data; | ||
| 437 | const fullName = [data.firstName, data.lastName].filter(Boolean).join(" ") || data.email; | ||
| 438 | |||
| 439 | let account = await getAccountByWorkosId(ctx, data.id); | ||
| 440 | if (account) { | ||
| 441 | await ctx.db.patch(account._id, { | ||
| 442 | email: data.email, | ||
| 443 | name: fullName, | ||
| 444 | firstName: data.firstName ?? undefined, | ||
| 445 | lastName: data.lastName ?? undefined, | ||
| 446 | avatarUrl: data.profilePictureUrl ?? undefined, | ||
| 447 | status: "active", | ||
| 448 | updatedAt: now, | ||
| 449 | lastLoginAt: now, | ||
| 450 | }); | ||
| 451 | account = await ctx.db.get(account._id); | ||
| 452 | } else { | ||
| 453 | const accountId = await ctx.db.insert("accounts", { | ||
| 454 | provider: "workos", | ||
| 455 | providerAccountId: data.id, | ||
| 456 | email: data.email, | ||
| 457 | name: fullName, | ||
| 458 | firstName: data.firstName ?? undefined, | ||
| 459 | lastName: data.lastName ?? undefined, | ||
| 460 | avatarUrl: data.profilePictureUrl ?? undefined, | ||
| 461 | status: "active", | ||
| 462 | createdAt: now, | ||
| 463 | updatedAt: now, | ||
| 464 | lastLoginAt: now, | ||
| 465 | }); | ||
| 466 | account = await ctx.db.get(accountId); | ||
| 467 | } | ||
| 468 | |||
| 469 | if (!account) return; | ||
| 470 | }, | ||
| 471 | |||
| 472 | "user.updated": async (ctx, event) => { | ||
| 473 | const account = await getAccountByWorkosId(ctx, event.data.id); | ||
| 474 | if (!account) return; | ||
| 475 | |||
| 476 | const fullName = [event.data.firstName, event.data.lastName].filter(Boolean).join(" ") || event.data.email; | ||
| 477 | await ctx.db.patch(account._id, { | ||
| 478 | email: event.data.email, | ||
| 479 | name: fullName, | ||
| 480 | firstName: event.data.firstName ?? undefined, | ||
| 481 | lastName: event.data.lastName ?? undefined, | ||
| 482 | avatarUrl: event.data.profilePictureUrl ?? undefined, | ||
| 483 | status: "active", | ||
| 484 | updatedAt: Date.now(), | ||
| 485 | }); | ||
| 486 | }, | ||
| 487 | |||
| 488 | "user.deleted": async (ctx, event) => { | ||
| 489 | const account = await getAccountByWorkosId(ctx, event.data.id); | ||
| 490 | if (!account) return; | ||
| 491 | |||
| 492 | const memberships = await ctx.db | ||
| 493 | .query("workspaceMembers") | ||
| 494 | .withIndex("by_account", (q) => q.eq("accountId", account._id)) | ||
| 495 | .collect(); | ||
| 496 | for (const membership of memberships) { | ||
| 497 | await ctx.db.delete(membership._id); | ||
| 498 | } | ||
| 499 | |||
| 500 | await ctx.db.delete(account._id); | ||
| 501 | }, | ||
| 502 | |||
| 503 | "organization.created": async (ctx, event) => { | ||
| 504 | const now = Date.now(); | ||
| 505 | let organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | ||
| 506 | if (organization) { | ||
| 507 | await ctx.db.patch(organization._id, { | ||
| 508 | name: event.data.name, | ||
| 509 | status: "active", | ||
| 510 | updatedAt: now, | ||
| 511 | }); | ||
| 512 | organization = await ctx.db.get(organization._id); | ||
| 513 | } else { | ||
| 514 | const slug = await ensureUniqueOrganizationSlug(ctx, event.data.name); | ||
| 515 | const organizationId = await ctx.db.insert("organizations", { | ||
| 516 | workosOrgId: event.data.id, | ||
| 517 | slug, | ||
| 518 | name: event.data.name, | ||
| 519 | status: "active", | ||
| 520 | createdAt: now, | ||
| 521 | updatedAt: now, | ||
| 522 | }); | ||
| 523 | organization = await ctx.db.get(organizationId); | ||
| 524 | } | ||
| 525 | |||
| 526 | if (!organization) { | ||
| 527 | return; | ||
| 528 | } | ||
| 529 | |||
| 530 | const existingWorkspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id); | ||
| 531 | if (existingWorkspace) { | ||
| 532 | await ctx.db.patch(existingWorkspace._id, { | ||
| 533 | organizationId: organization._id, | ||
| 534 | workosOrgId: event.data.id, | ||
| 535 | updatedAt: now, | ||
| 536 | }); | ||
| 537 | return; | ||
| 538 | } | ||
| 539 | |||
| 540 | const organizationWorkspace = await getFirstWorkspaceByOrganizationId(ctx, organization._id); | ||
| 541 | if (organizationWorkspace) { | ||
| 542 | await ctx.db.patch(organizationWorkspace._id, { | ||
| 543 | workosOrgId: event.data.id, | ||
| 544 | updatedAt: now, | ||
| 545 | }); | ||
| 546 | return; | ||
| 547 | } | ||
| 548 | |||
| 549 | await ctx.db.insert("workspaces", { | ||
| 550 | workosOrgId: event.data.id, | ||
| 551 | organizationId: organization._id, | ||
| 552 | slug: "default", | ||
| 553 | name: "Default Workspace", | ||
| 554 | createdByAccountId: organization.createdByAccountId, | ||
| 555 | createdAt: now, | ||
| 556 | updatedAt: now, | ||
| 557 | }); | ||
| 558 | }, | ||
| 559 | |||
| 560 | "organization.updated": async (ctx, event) => { | ||
| 561 | const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | ||
| 562 | if (organization) { | ||
| 563 | await ctx.db.patch(organization._id, { | ||
| 564 | name: event.data.name, | ||
| 565 | updatedAt: Date.now(), | ||
| 566 | }); | ||
| 567 | } | ||
| 568 | }, | ||
| 569 | |||
| 570 | "organization.deleted": async (ctx, event) => { | ||
| 571 | const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | ||
| 572 | if (organization) { | ||
| 573 | await ctx.db.patch(organization._id, { | ||
| 574 | status: "deleted", | ||
| 575 | updatedAt: Date.now(), | ||
| 576 | }); | ||
| 577 | } | ||
| 578 | |||
| 579 | const workspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id); | ||
| 580 | if (!workspace) return; | ||
| 581 | |||
| 582 | const members = await ctx.db | ||
| 583 | .query("workspaceMembers") | ||
| 584 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id)) | ||
| 585 | .collect(); | ||
| 586 | for (const member of members) { | ||
| 587 | await ctx.db.delete(member._id); | ||
| 588 | } | ||
| 589 | |||
| 590 | await ctx.db.delete(workspace._id); | ||
| 591 | }, | ||
| 592 | |||
| 593 | "organization_membership.created": async (ctx, event) => { | ||
| 594 | const now = Date.now(); | ||
| 595 | const data = event.data as { | ||
| 596 | id: string; | ||
| 597 | user_id?: string; | ||
| 598 | userId?: string; | ||
| 599 | organization_id?: string; | ||
| 600 | organizationId?: string; | ||
| 601 | role?: { slug?: string }; | ||
| 602 | status?: string; | ||
| 603 | }; | ||
| 604 | const workosUserId = data.user_id ?? data.userId; | ||
| 605 | const workosOrgId = data.organization_id ?? data.organizationId; | ||
| 606 | if (!workosUserId || !workosOrgId) return; | ||
| 607 | |||
| 608 | const [account, workspace] = await Promise.all([ | ||
| 609 | getAccountByWorkosId(ctx, workosUserId), | ||
| 610 | getWorkspaceByWorkosOrgId(ctx, workosOrgId), | ||
| 611 | ]); | ||
| 612 | if (!account || !workspace) return; | ||
| 613 | |||
| 614 | const organizationId = workspace.organizationId; | ||
| 615 | |||
| 616 | const existing = await ctx.db | ||
| 617 | .query("workspaceMembers") | ||
| 618 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) | ||
| 619 | .unique(); | ||
| 620 | |||
| 621 | const workosRole = data.role?.slug ?? "member"; | ||
| 622 | const role = workosRole === "admin" ? "admin" : "member"; | ||
| 623 | const status = data.status === "active" ? "active" : "pending"; | ||
| 624 | |||
| 625 | await upsertOrganizationMembership(ctx, { | ||
| 626 | organizationId, | ||
| 627 | accountId: account._id, | ||
| 628 | role, | ||
| 629 | status, | ||
| 630 | billable: status === "active", | ||
| 631 | now, | ||
| 632 | }); | ||
| 633 | |||
| 634 | if (status === "active") { | ||
| 635 | await markPendingInvitesAcceptedByEmail(ctx, { | ||
| 636 | organizationId, | ||
| 637 | email: account.email, | ||
| 638 | acceptedAt: now, | ||
| 639 | }); | ||
| 640 | } | ||
| 641 | |||
| 642 | if (existing) { | ||
| 643 | await ctx.db.patch(existing._id, { | ||
| 644 | workosOrgMembershipId: event.data.id, | ||
| 645 | role, | ||
| 646 | status, | ||
| 647 | updatedAt: now, | ||
| 648 | }); | ||
| 649 | return; | ||
| 650 | } | ||
| 651 | |||
| 652 | await ctx.db.insert("workspaceMembers", { | ||
| 653 | workspaceId: workspace._id, | ||
| 654 | accountId: account._id, | ||
| 655 | workosOrgMembershipId: event.data.id, | ||
| 656 | role, | ||
| 657 | status, | ||
| 658 | createdAt: now, | ||
| 659 | updatedAt: now, | ||
| 660 | }); | ||
| 661 | }, | ||
| 662 | |||
| 663 | "organization_membership.updated": async (ctx, event) => { | ||
| 664 | const now = Date.now(); | ||
| 665 | const data = event.data as { | ||
| 666 | id: string; | ||
| 667 | user_id?: string; | ||
| 668 | userId?: string; | ||
| 669 | organization_id?: string; | ||
| 670 | organizationId?: string; | ||
| 671 | role?: { slug?: string }; | ||
| 672 | status?: string; | ||
| 673 | }; | ||
| 674 | |||
| 675 | let membership = await ctx.db | ||
| 676 | .query("workspaceMembers") | ||
| 677 | .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id)) | ||
| 678 | .unique(); | ||
| 679 | |||
| 680 | let account: Doc<"accounts"> | null = null; | ||
| 681 | let workspace: Doc<"workspaces"> | null = null; | ||
| 682 | |||
| 683 | if (!membership) { | ||
| 684 | const workosUserId = data.user_id ?? data.userId; | ||
| 685 | const workosOrgId = data.organization_id ?? data.organizationId; | ||
| 686 | if (!workosUserId || !workosOrgId) return; | ||
| 687 | [account, workspace] = await Promise.all([ | ||
| 688 | getAccountByWorkosId(ctx, workosUserId), | ||
| 689 | getWorkspaceByWorkosOrgId(ctx, workosOrgId), | ||
| 690 | ]); | ||
| 691 | if (!account || !workspace) return; | ||
| 692 | const workspaceId = workspace._id; | ||
| 693 | const accountId = account._id; | ||
| 694 | membership = await ctx.db | ||
| 695 | .query("workspaceMembers") | ||
| 696 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId)) | ||
| 697 | .unique(); | ||
| 698 | if (!membership) return; | ||
| 699 | } else { | ||
| 700 | account = await ctx.db.get(membership.accountId); | ||
| 701 | workspace = await ctx.db.get(membership.workspaceId); | ||
| 702 | } | ||
| 703 | |||
| 704 | if (!account || !workspace) { | ||
| 705 | return; | ||
| 706 | } | ||
| 707 | |||
| 708 | const organizationId = workspace.organizationId; | ||
| 709 | |||
| 710 | const workosRole = data.role?.slug ?? "member"; | ||
| 711 | const status = data.status === "active" ? "active" : "pending"; | ||
| 712 | await upsertOrganizationMembership(ctx, { | ||
| 713 | organizationId, | ||
| 714 | accountId: account._id, | ||
| 715 | role: workosRole === "admin" ? "admin" : "member", | ||
| 716 | status, | ||
| 717 | billable: status === "active", | ||
| 718 | now, | ||
| 719 | }); | ||
| 720 | |||
| 721 | if (status === "active") { | ||
| 722 | await markPendingInvitesAcceptedByEmail(ctx, { | ||
| 723 | organizationId, | ||
| 724 | email: account.email, | ||
| 725 | acceptedAt: now, | ||
| 726 | }); | ||
| 727 | } | ||
| 728 | |||
| 729 | await ctx.db.patch(membership._id, { | ||
| 730 | workosOrgMembershipId: data.id, | ||
| 731 | role: workosRole === "admin" ? "admin" : "member", | ||
| 732 | status, | ||
| 733 | updatedAt: now, | ||
| 734 | }); | ||
| 735 | }, | ||
| 736 | |||
| 737 | "organization_membership.deleted": async (ctx, event) => { | ||
| 738 | const now = Date.now(); | ||
| 739 | const membership = await ctx.db | ||
| 740 | .query("workspaceMembers") | ||
| 741 | .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id)) | ||
| 742 | .unique(); | ||
| 743 | if (!membership) return; | ||
| 744 | |||
| 745 | const workspace = await ctx.db.get(membership.workspaceId); | ||
| 746 | if (workspace?.organizationId) { | ||
| 747 | await upsertOrganizationMembership(ctx, { | ||
| 748 | organizationId: workspace.organizationId, | ||
| 749 | accountId: membership.accountId, | ||
| 750 | role: membership.role, | ||
| 751 | status: "removed", | ||
| 752 | billable: false, | ||
| 753 | now, | ||
| 754 | }); | ||
| 755 | } | ||
| 756 | |||
| 757 | await ctx.db.delete(membership._id); | ||
| 758 | }, | ||
| 759 | } satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>; | ||
| 760 | |||
| 761 | const authKitEvents = workosEnabled && authKitInstance | ||
| 762 | ? authKitInstance.events(workosEventHandlers) | ||
| 763 | : null; | ||
| 764 | 36 | ||
| 765 | export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ | 37 | export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ |
| 766 | args: {}, | 38 | args: {}, |
| @@ -769,166 +41,5 @@ export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ · 2 unmodified lines | |||
| 769 | 41 | ||
| 770 | export const bootstrapCurrentWorkosAccount = mutation({ | 42 | export 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 | }); |
| @@ -4,19 +4,13 @@ import { WorkOS } from "@workos-inc/node"; · 3 unmodified lines | |||
| 4 | import { v } from "convex/values"; | 4 | import { v } from "convex/values"; |
| 5 | import { internal } from "./_generated/api"; | 5 | import { internal } from "./_generated/api"; |
| 6 | import { action } from "./_generated/server"; | 6 | import { action } from "./_generated/server"; |
| 7 | import { asRecord } from "./lib/object"; | ||
| 7 | 8 | ||
| 8 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 9 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 9 | const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault")); | 10 | const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault")); |
| 10 | 11 | ||
| 11 | type SecretBackend = "local-convex" | "workos-vault"; | 12 | type SecretBackend = "local-convex" | "workos-vault"; |
| 12 | 13 | ||
| 13 | function asRecord(value: unknown): Record<string, unknown> { | ||
| 14 | if (value && typeof value === "object" && !Array.isArray(value)) { | ||
| 15 | return value as Record<string, unknown>; | ||
| 16 | } | ||
| 17 | return {}; | ||
| 18 | } | ||
| 19 | |||
| 20 | function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string { | 14 | function 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 ""; |
| @@ -0,0 +1,81 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation } from "./_generated/server"; | ||
| 3 | import { ensureAnonymousIdentity } from "./database-anonymous"; | ||
| 4 | import { mapAnonymousContext } from "./database-readers"; | ||
| 5 | |||
| 6 | export const bootstrapAnonymousSession = internalMutation({ | ||
| 7 | args: { sessionId: v.optional(v.string()) }, | ||
| 8 | handler: async (ctx, args) => { | ||
| 9 | const now = Date.now(); | ||
| 10 | const requestedSessionId = args.sessionId?.trim() || ""; | ||
| 11 | const allowRequestedSessionId = requestedSessionId?.startsWith("mcp_") ?? false; | ||
| 12 | |||
| 13 | if (requestedSessionId) { | ||
| 14 | const sessionId = requestedSessionId; | ||
| 15 | const existing = await ctx.db | ||
| 16 | .query("anonymousSessions") | ||
| 17 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | ||
| 18 | .unique(); | ||
| 19 | if (existing) { | ||
| 20 | const identity = await ensureAnonymousIdentity(ctx, { | ||
| 21 | sessionId, | ||
| 22 | workspaceId: existing.workspaceId, | ||
| 23 | actorId: existing.actorId, | ||
| 24 | timestamp: now, | ||
| 25 | }); | ||
| 26 | |||
| 27 | await ctx.db.patch(existing._id, { | ||
| 28 | workspaceId: identity.workspaceId, | ||
| 29 | accountId: identity.accountId, | ||
| 30 | userId: identity.userId, | ||
| 31 | lastSeenAt: now, | ||
| 32 | }); | ||
| 33 | |||
| 34 | const refreshed = await ctx.db | ||
| 35 | .query("anonymousSessions") | ||
| 36 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | ||
| 37 | .unique(); | ||
| 38 | if (!refreshed) { | ||
| 39 | throw new Error("Failed to refresh anonymous session"); | ||
| 40 | } | ||
| 41 | return mapAnonymousContext(refreshed); | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | const generatedSessionId = allowRequestedSessionId | ||
| 46 | ? `mcp_${crypto.randomUUID()}` | ||
| 47 | : `anon_session_${crypto.randomUUID()}`; | ||
| 48 | const sessionId = allowRequestedSessionId | ||
| 49 | ? requestedSessionId as string | ||
| 50 | : generatedSessionId; | ||
| 51 | const actorId = `anon_${crypto.randomUUID()}`; | ||
| 52 | const clientId = "web"; | ||
| 53 | |||
| 54 | const identity = await ensureAnonymousIdentity(ctx, { | ||
| 55 | sessionId, | ||
| 56 | actorId, | ||
| 57 | timestamp: now, | ||
| 58 | }); | ||
| 59 | |||
| 60 | await ctx.db.insert("anonymousSessions", { | ||
| 61 | sessionId, | ||
| 62 | workspaceId: identity.workspaceId, | ||
| 63 | actorId, | ||
| 64 | clientId, | ||
| 65 | accountId: identity.accountId, | ||
| 66 | userId: identity.userId, | ||
| 67 | createdAt: now, | ||
| 68 | lastSeenAt: now, | ||
| 69 | }); | ||
| 70 | |||
| 71 | const created = await ctx.db | ||
| 72 | .query("anonymousSessions") | ||
| 73 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | ||
| 74 | .unique(); | ||
| 75 | if (!created) { | ||
| 76 | throw new Error("Failed to create anonymous session"); | ||
| 77 | } | ||
| 78 | |||
| 79 | return mapAnonymousContext(created); | ||
| 80 | }, | ||
| 81 | }); | ||
| @@ -0,0 +1,163 @@ | |||
| 1 | import type { Doc } from "./_generated/dataModel"; | ||
| 2 | import type { MutationCtx } from "./_generated/server"; | ||
| 3 | import { slugify } from "../core/src/identity"; | ||
| 4 | import { ensureUniqueSlug } from "../core/src/slug"; | ||
| 5 | |||
| 6 | type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | ||
| 7 | type OrganizationMemberStatus = "active" | "pending" | "removed"; | ||
| 8 | |||
| 9 | async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> { | ||
| 10 | const baseSlug = slugify(baseName, "workspace"); | ||
| 11 | return await ensureUniqueSlug(baseSlug, async (candidate) => { | ||
| 12 | const collision = await ctx.db | ||
| 13 | .query("organizations") | ||
| 14 | .withIndex("by_slug", (q) => q.eq("slug", candidate)) | ||
| 15 | .unique(); | ||
| 16 | return collision !== null; | ||
| 17 | }); | ||
| 18 | } | ||
| 19 | |||
| 20 | async function upsertOrganizationMembership( | ||
| 21 | ctx: Pick<MutationCtx, "db">, | ||
| 22 | args: { | ||
| 23 | organizationId: Doc<"organizations">["_id"]; | ||
| 24 | accountId: Doc<"accounts">["_id"]; | ||
| 25 | role: OrganizationRole; | ||
| 26 | status: OrganizationMemberStatus; | ||
| 27 | billable: boolean; | ||
| 28 | now: number; | ||
| 29 | }, | ||
| 30 | ) { | ||
| 31 | const existing = await ctx.db | ||
| 32 | .query("organizationMembers") | ||
| 33 | .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId)) | ||
| 34 | .unique(); | ||
| 35 | |||
| 36 | if (existing) { | ||
| 37 | await ctx.db.patch(existing._id, { | ||
| 38 | role: args.role, | ||
| 39 | status: args.status, | ||
| 40 | billable: args.billable, | ||
| 41 | joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt, | ||
| 42 | updatedAt: args.now, | ||
| 43 | }); | ||
| 44 | return; | ||
| 45 | } | ||
| 46 | |||
| 47 | await ctx.db.insert("organizationMembers", { | ||
| 48 | organizationId: args.organizationId, | ||
| 49 | accountId: args.accountId, | ||
| 50 | role: args.role, | ||
| 51 | status: args.status, | ||
| 52 | billable: args.billable, | ||
| 53 | joinedAt: args.status === "active" ? args.now : undefined, | ||
| 54 | createdAt: args.now, | ||
| 55 | updatedAt: args.now, | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | |||
| 59 | export async function ensureAnonymousIdentity( | ||
| 60 | ctx: MutationCtx, | ||
| 61 | params: { | ||
| 62 | sessionId: string; | ||
| 63 | workspaceId?: Doc<"workspaces">["_id"]; | ||
| 64 | actorId: string; | ||
| 65 | timestamp: number; | ||
| 66 | }, | ||
| 67 | ) { | ||
| 68 | const anonymousOrganizationName = "Anonymous Organization"; | ||
| 69 | const anonymousWorkspaceName = "Anonymous Workspace"; | ||
| 70 | const now = params.timestamp; | ||
| 71 | |||
| 72 | let account = await ctx.db | ||
| 73 | .query("accounts") | ||
| 74 | .withIndex("by_provider", (q) => q.eq("provider", "anonymous").eq("providerAccountId", params.actorId)) | ||
| 75 | .unique(); | ||
| 76 | |||
| 77 | if (!account) { | ||
| 78 | const accountId = await ctx.db.insert("accounts", { | ||
| 79 | provider: "anonymous", | ||
| 80 | providerAccountId: params.actorId, | ||
| 81 | email: `${params.actorId}@guest.executor.local`, | ||
| 82 | name: "Guest User", | ||
| 83 | status: "active", | ||
| 84 | createdAt: now, | ||
| 85 | updatedAt: now, | ||
| 86 | lastLoginAt: now, | ||
| 87 | }); | ||
| 88 | account = await ctx.db.get(accountId); | ||
| 89 | if (!account) { | ||
| 90 | throw new Error("Failed to create anonymous account"); | ||
| 91 | } | ||
| 92 | } else { | ||
| 93 | await ctx.db.patch(account._id, { updatedAt: now, lastLoginAt: now }); | ||
| 94 | } | ||
| 95 | |||
| 96 | let workspace = params.workspaceId ? await ctx.db.get(params.workspaceId) : null; | ||
| 97 | |||
| 98 | let organizationId: Doc<"organizations">["_id"]; | ||
| 99 | |||
| 100 | if (!workspace) { | ||
| 101 | const organizationSlug = await ensureUniqueOrganizationSlug(ctx, anonymousOrganizationName); | ||
| 102 | organizationId = await ctx.db.insert("organizations", { | ||
| 103 | slug: organizationSlug, | ||
| 104 | name: anonymousOrganizationName, | ||
| 105 | status: "active", | ||
| 106 | createdByAccountId: account._id, | ||
| 107 | createdAt: now, | ||
| 108 | updatedAt: now, | ||
| 109 | }); | ||
| 110 | |||
| 111 | const workspaceId = await ctx.db.insert("workspaces", { | ||
| 112 | organizationId, | ||
| 113 | slug: `anonymous-${crypto.randomUUID().slice(0, 8)}`, | ||
| 114 | name: anonymousWorkspaceName, | ||
| 115 | createdByAccountId: account._id, | ||
| 116 | createdAt: now, | ||
| 117 | updatedAt: now, | ||
| 118 | }); | ||
| 119 | workspace = await ctx.db.get(workspaceId); | ||
| 120 | if (!workspace) { | ||
| 121 | throw new Error("Failed to create anonymous workspace"); | ||
| 122 | } | ||
| 123 | } else { | ||
| 124 | organizationId = workspace.organizationId; | ||
| 125 | } | ||
| 126 | |||
| 127 | await upsertOrganizationMembership(ctx, { | ||
| 128 | organizationId, | ||
| 129 | accountId: account._id, | ||
| 130 | role: "owner", | ||
| 131 | status: "active", | ||
| 132 | billable: true, | ||
| 133 | now, | ||
| 134 | }); | ||
| 135 | |||
| 136 | let user = await ctx.db | ||
| 137 | .query("workspaceMembers") | ||
| 138 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) | ||
| 139 | .unique(); | ||
| 140 | |||
| 141 | if (!user) { | ||
| 142 | const userId = await ctx.db.insert("workspaceMembers", { | ||
| 143 | workspaceId: workspace._id, | ||
| 144 | accountId: account._id, | ||
| 145 | role: "owner", | ||
| 146 | status: "active", | ||
| 147 | createdAt: now, | ||
| 148 | updatedAt: now, | ||
| 149 | }); | ||
| 150 | user = await ctx.db.get(userId); | ||
| 151 | if (!user) { | ||
| 152 | throw new Error("Failed to create anonymous user membership"); | ||
| 153 | } | ||
| 154 | } else { | ||
| 155 | await ctx.db.patch(user._id, { updatedAt: now }); | ||
| 156 | } | ||
| 157 | |||
| 158 | return { | ||
| 159 | accountId: account._id, | ||
| 160 | workspaceId: workspace._id, | ||
| 161 | userId: user._id, | ||
| 162 | }; | ||
| 163 | } | ||
| @@ -0,0 +1,156 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | import { mapApproval } from "./database-mappers"; | ||
| 4 | import { getApprovalDoc, getTaskDoc } from "./database-readers"; | ||
| 5 | import { approvalStatusValidator } from "./database-validators"; | ||
| 6 | import type { TaskStatus } from "../core/src/types"; | ||
| 7 | |||
| 8 | export const createApproval = internalMutation({ | ||
| 9 | args: { | ||
| 10 | id: v.string(), | ||
| 11 | taskId: v.string(), | ||
| 12 | toolPath: v.string(), | ||
| 13 | input: v.optional(v.any()), | ||
| 14 | }, | ||
| 15 | handler: async (ctx, args) => { | ||
| 16 | const existing = await getApprovalDoc(ctx, args.id); | ||
| 17 | if (existing) { | ||
| 18 | throw new Error(`Approval already exists: ${args.id}`); | ||
| 19 | } | ||
| 20 | |||
| 21 | const task = await getTaskDoc(ctx, args.taskId); | ||
| 22 | if (!task) { | ||
| 23 | throw new Error(`Task not found for approval: ${args.taskId}`); | ||
| 24 | } | ||
| 25 | |||
| 26 | const now = Date.now(); | ||
| 27 | await ctx.db.insert("approvals", { | ||
| 28 | approvalId: args.id, | ||
| 29 | taskId: args.taskId, | ||
| 30 | workspaceId: task.workspaceId, | ||
| 31 | toolPath: args.toolPath, | ||
| 32 | input: args.input ?? {}, | ||
| 33 | status: "pending", | ||
| 34 | createdAt: now, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const created = await getApprovalDoc(ctx, args.id); | ||
| 38 | if (!created) { | ||
| 39 | throw new Error(`Failed to fetch approval ${args.id}`); | ||
| 40 | } | ||
| 41 | return mapApproval(created); | ||
| 42 | }, | ||
| 43 | }); | ||
| 44 | |||
| 45 | export const getApproval = internalQuery({ | ||
| 46 | args: { approvalId: v.string() }, | ||
| 47 | handler: async (ctx, args) => { | ||
| 48 | const doc = await getApprovalDoc(ctx, args.approvalId); | ||
| 49 | return doc ? mapApproval(doc) : null; | ||
| 50 | }, | ||
| 51 | }); | ||
| 52 | |||
| 53 | export const listApprovals = internalQuery({ | ||
| 54 | args: { | ||
| 55 | workspaceId: v.id("workspaces"), | ||
| 56 | status: v.optional(approvalStatusValidator), | ||
| 57 | }, | ||
| 58 | handler: async (ctx, args) => { | ||
| 59 | if (args.status) { | ||
| 60 | const status = args.status; | ||
| 61 | const docs = await ctx.db | ||
| 62 | .query("approvals") | ||
| 63 | .withIndex("by_workspace_status_created", (q) => | ||
| 64 | q.eq("workspaceId", args.workspaceId).eq("status", status), | ||
| 65 | ) | ||
| 66 | .order("desc") | ||
| 67 | .collect(); | ||
| 68 | return docs.map(mapApproval); | ||
| 69 | } | ||
| 70 | |||
| 71 | const docs = await ctx.db | ||
| 72 | .query("approvals") | ||
| 73 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 74 | .order("desc") | ||
| 75 | .take(500); | ||
| 76 | return docs.map(mapApproval); | ||
| 77 | }, | ||
| 78 | }); | ||
| 79 | |||
| 80 | export const listPendingApprovals = internalQuery({ | ||
| 81 | args: { workspaceId: v.id("workspaces") }, | ||
| 82 | handler: async (ctx, args) => { | ||
| 83 | const docs = await ctx.db | ||
| 84 | .query("approvals") | ||
| 85 | .withIndex("by_workspace_status_created", (q) => | ||
| 86 | q.eq("workspaceId", args.workspaceId).eq("status", "pending"), | ||
| 87 | ) | ||
| 88 | .order("asc") | ||
| 89 | .collect(); | ||
| 90 | |||
| 91 | const tasks = await Promise.all(docs.map((approval) => getTaskDoc(ctx, approval.taskId))); | ||
| 92 | |||
| 93 | const results: Array< | ||
| 94 | ReturnType<typeof mapApproval> & { | ||
| 95 | task: { id: string; status: TaskStatus; runtimeId: string; timeoutMs: number; createdAt: number }; | ||
| 96 | } | ||
| 97 | > = []; | ||
| 98 | for (let i = 0; i < docs.length; i++) { | ||
| 99 | const approval = docs[i]!; | ||
| 100 | const task = tasks[i]; | ||
| 101 | if (!task) { | ||
| 102 | continue; | ||
| 103 | } | ||
| 104 | |||
| 105 | results.push({ | ||
| 106 | ...mapApproval(approval), | ||
| 107 | task: { | ||
| 108 | id: task.taskId, | ||
| 109 | status: task.status, | ||
| 110 | runtimeId: task.runtimeId, | ||
| 111 | timeoutMs: task.timeoutMs, | ||
| 112 | createdAt: task.createdAt, | ||
| 113 | }, | ||
| 114 | }); | ||
| 115 | } | ||
| 116 | |||
| 117 | return results; | ||
| 118 | }, | ||
| 119 | }); | ||
| 120 | |||
| 121 | export const resolveApproval = internalMutation({ | ||
| 122 | args: { | ||
| 123 | approvalId: v.string(), | ||
| 124 | decision: v.union(v.literal("approved"), v.literal("denied")), | ||
| 125 | reviewerId: v.optional(v.string()), | ||
| 126 | reason: v.optional(v.string()), | ||
| 127 | }, | ||
| 128 | handler: async (ctx, args) => { | ||
| 129 | const doc = await getApprovalDoc(ctx, args.approvalId); | ||
| 130 | if (!doc || doc.status !== "pending") { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | |||
| 134 | const now = Date.now(); | ||
| 135 | await ctx.db.patch(doc._id, { | ||
| 136 | status: args.decision, | ||
| 137 | reason: args.reason, | ||
| 138 | reviewerId: args.reviewerId, | ||
| 139 | resolvedAt: now, | ||
| 140 | }); | ||
| 141 | |||
| 142 | const updated = await getApprovalDoc(ctx, args.approvalId); | ||
| 143 | return updated ? mapApproval(updated) : null; | ||
| 144 | }, | ||
| 145 | }); | ||
| 146 | |||
| 147 | export const getApprovalInWorkspace = internalQuery({ | ||
| 148 | args: { approvalId: v.string(), workspaceId: v.id("workspaces") }, | ||
| 149 | handler: async (ctx, args) => { | ||
| 150 | const doc = await getApprovalDoc(ctx, args.approvalId); | ||
| 151 | if (!doc || doc.workspaceId !== args.workspaceId) { | ||
| 152 | return null; | ||
| 153 | } | ||
| 154 | return mapApproval(doc); | ||
| 155 | }, | ||
| 156 | }); | ||
| @@ -0,0 +1,201 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | import { mapCredential } from "./database-mappers"; | ||
| 4 | import { computeBoundAuthFingerprint } from "./database-readers"; | ||
| 5 | import { | ||
| 6 | credentialProviderValidator, | ||
| 7 | credentialScopeValidator, | ||
| 8 | } from "./database-validators"; | ||
| 9 | import { asRecord } from "./lib/object"; | ||
| 10 | |||
| 11 | export const upsertCredential = internalMutation({ | ||
| 12 | args: { | ||
| 13 | id: v.optional(v.string()), | ||
| 14 | workspaceId: v.id("workspaces"), | ||
| 15 | sourceKey: v.string(), | ||
| 16 | scope: credentialScopeValidator, | ||
| 17 | actorId: v.optional(v.string()), | ||
| 18 | provider: v.optional(credentialProviderValidator), | ||
| 19 | secretJson: v.any(), | ||
| 20 | overridesJson: v.optional(v.any()), | ||
| 21 | }, | ||
| 22 | handler: async (ctx, args) => { | ||
| 23 | const now = Date.now(); | ||
| 24 | const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : ""; | ||
| 25 | const submittedSecret = asRecord(args.secretJson); | ||
| 26 | const hasSubmittedSecret = Object.keys(submittedSecret).length > 0; | ||
| 27 | |||
| 28 | const existing = await ctx.db | ||
| 29 | .query("sourceCredentials") | ||
| 30 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 31 | q | ||
| 32 | .eq("workspaceId", args.workspaceId) | ||
| 33 | .eq("sourceKey", args.sourceKey) | ||
| 34 | .eq("scope", args.scope) | ||
| 35 | .eq("actorId", actorId), | ||
| 36 | ) | ||
| 37 | .unique(); | ||
| 38 | |||
| 39 | let requestedId = args.id?.trim() || ""; | ||
| 40 | if (requestedId.startsWith("bind_")) { | ||
| 41 | const binding = await ctx.db | ||
| 42 | .query("sourceCredentials") | ||
| 43 | .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId)) | ||
| 44 | .unique(); | ||
| 45 | if (binding && binding.workspaceId === args.workspaceId) { | ||
| 46 | requestedId = binding.credentialId; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`; | ||
| 51 | |||
| 52 | const linkedRows = await ctx.db | ||
| 53 | .query("sourceCredentials") | ||
| 54 | .withIndex("by_workspace_credential", (q) => | ||
| 55 | q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId), | ||
| 56 | ) | ||
| 57 | .collect(); | ||
| 58 | const exemplar = linkedRows[0] ?? existing ?? null; | ||
| 59 | |||
| 60 | const provider = args.provider ?? exemplar?.provider ?? "local-convex"; | ||
| 61 | const fallbackSecret = asRecord(exemplar?.secretJson); | ||
| 62 | const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; | ||
| 63 | if (Object.keys(finalSecret).length === 0) { | ||
| 64 | throw new Error("Credential values are required"); | ||
| 65 | } | ||
| 66 | |||
| 67 | const overridesJson = args.overridesJson === undefined | ||
| 68 | ? asRecord(existing?.overridesJson) | ||
| 69 | : asRecord(args.overridesJson); | ||
| 70 | |||
| 71 | const boundAuthFingerprint = await computeBoundAuthFingerprint( | ||
| 72 | ctx, | ||
| 73 | args.workspaceId, | ||
| 74 | args.sourceKey, | ||
| 75 | ); | ||
| 76 | |||
| 77 | if (linkedRows.length > 0 && (hasSubmittedSecret || args.provider)) { | ||
| 78 | await Promise.all(linkedRows.map(async (row) => { | ||
| 79 | await ctx.db.patch(row._id, { | ||
| 80 | provider, | ||
| 81 | secretJson: finalSecret, | ||
| 82 | updatedAt: now, | ||
| 83 | }); | ||
| 84 | })); | ||
| 85 | } | ||
| 86 | |||
| 87 | if (existing) { | ||
| 88 | await ctx.db.patch(existing._id, { | ||
| 89 | credentialId: connectionId, | ||
| 90 | provider, | ||
| 91 | secretJson: finalSecret, | ||
| 92 | overridesJson, | ||
| 93 | boundAuthFingerprint, | ||
| 94 | updatedAt: now, | ||
| 95 | }); | ||
| 96 | } else { | ||
| 97 | await ctx.db.insert("sourceCredentials", { | ||
| 98 | bindingId: `bind_${crypto.randomUUID()}`, | ||
| 99 | credentialId: connectionId, | ||
| 100 | workspaceId: args.workspaceId, | ||
| 101 | sourceKey: args.sourceKey, | ||
| 102 | scope: args.scope, | ||
| 103 | actorId, | ||
| 104 | provider, | ||
| 105 | secretJson: finalSecret, | ||
| 106 | overridesJson, | ||
| 107 | boundAuthFingerprint, | ||
| 108 | createdAt: now, | ||
| 109 | updatedAt: now, | ||
| 110 | }); | ||
| 111 | } | ||
| 112 | |||
| 113 | const updated = await ctx.db | ||
| 114 | .query("sourceCredentials") | ||
| 115 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 116 | q | ||
| 117 | .eq("workspaceId", args.workspaceId) | ||
| 118 | .eq("sourceKey", args.sourceKey) | ||
| 119 | .eq("scope", args.scope) | ||
| 120 | .eq("actorId", actorId), | ||
| 121 | ) | ||
| 122 | .unique(); | ||
| 123 | |||
| 124 | if (!updated) { | ||
| 125 | throw new Error("Failed to read upserted credential"); | ||
| 126 | } | ||
| 127 | |||
| 128 | return mapCredential(updated); | ||
| 129 | }, | ||
| 130 | }); | ||
| 131 | |||
| 132 | export const listCredentials = internalQuery({ | ||
| 133 | args: { workspaceId: v.id("workspaces") }, | ||
| 134 | handler: async (ctx, args) => { | ||
| 135 | const docs = await ctx.db | ||
| 136 | .query("sourceCredentials") | ||
| 137 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 138 | .order("desc") | ||
| 139 | .collect(); | ||
| 140 | return docs.map(mapCredential); | ||
| 141 | }, | ||
| 142 | }); | ||
| 143 | |||
| 144 | export const listCredentialProviders = internalQuery({ | ||
| 145 | args: {}, | ||
| 146 | handler: async () => { | ||
| 147 | const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim()); | ||
| 148 | return [ | ||
| 149 | { | ||
| 150 | id: workosEnabled ? "workos-vault" : "local-convex", | ||
| 151 | label: workosEnabled ? "Encrypted" : "Local", | ||
| 152 | description: workosEnabled | ||
| 153 | ? "Secrets are stored in WorkOS Vault." | ||
| 154 | : "Secrets are stored locally in Convex on this machine.", | ||
| 155 | }, | ||
| 156 | ] as const; | ||
| 157 | }, | ||
| 158 | }); | ||
| 159 | |||
| 160 | export const resolveCredential = internalQuery({ | ||
| 161 | args: { | ||
| 162 | workspaceId: v.id("workspaces"), | ||
| 163 | sourceKey: v.string(), | ||
| 164 | scope: credentialScopeValidator, | ||
| 165 | actorId: v.optional(v.string()), | ||
| 166 | }, | ||
| 167 | handler: async (ctx, args) => { | ||
| 168 | if (args.scope === "actor") { | ||
| 169 | const actorId = args.actorId?.trim() || ""; | ||
| 170 | if (!actorId) { | ||
| 171 | return null; | ||
| 172 | } | ||
| 173 | |||
| 174 | const actorDoc = await ctx.db | ||
| 175 | .query("sourceCredentials") | ||
| 176 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 177 | q | ||
| 178 | .eq("workspaceId", args.workspaceId) | ||
| 179 | .eq("sourceKey", args.sourceKey) | ||
| 180 | .eq("scope", "actor") | ||
| 181 | .eq("actorId", actorId), | ||
| 182 | ) | ||
| 183 | .unique(); | ||
| 184 | |||
| 185 | return actorDoc ? mapCredential(actorDoc) : null; | ||
| 186 | } | ||
| 187 | |||
| 188 | const workspaceDoc = await ctx.db | ||
| 189 | .query("sourceCredentials") | ||
| 190 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 191 | q | ||
| 192 | .eq("workspaceId", args.workspaceId) | ||
| 193 | .eq("sourceKey", args.sourceKey) | ||
| 194 | .eq("scope", "workspace") | ||
| 195 | .eq("actorId", ""), | ||
| 196 | ) | ||
| 197 | .unique(); | ||
| 198 | |||
| 199 | return workspaceDoc ? mapCredential(workspaceDoc) : null; | ||
| 200 | }, | ||
| 201 | }); | ||
| @@ -0,0 +1,139 @@ | |||
| 1 | import type { Doc } from "./_generated/dataModel"; | ||
| 2 | import { asRecord } from "./lib/object"; | ||
| 3 | import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants"; | ||
| 4 | |||
| 5 | export function stableStringify(value: unknown): string { | ||
| 6 | if (Array.isArray(value)) { | ||
| 7 | return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; | ||
| 8 | } | ||
| 9 | if (value && typeof value === "object") { | ||
| 10 | const record = value as Record<string, unknown>; | ||
| 11 | const keys = Object.keys(record).sort(); | ||
| 12 | return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`; | ||
| 13 | } | ||
| 14 | return JSON.stringify(value); | ||
| 15 | } | ||
| 16 | |||
| 17 | export function normalizeSourceAuthFingerprint(value: unknown): string { | ||
| 18 | const auth = asRecord(value); | ||
| 19 | const type = typeof auth.type === "string" ? auth.type.trim() : "none"; | ||
| 20 | const mode = auth.mode === "actor" ? "actor" : "workspace"; | ||
| 21 | const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : ""; | ||
| 22 | return stableStringify({ | ||
| 23 | type: type || "none", | ||
| 24 | mode, | ||
| 25 | ...(header ? { header } : {}), | ||
| 26 | }); | ||
| 27 | } | ||
| 28 | |||
| 29 | export function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", config: Record<string, unknown>): string { | ||
| 30 | if (type === "openapi") { | ||
| 31 | const spec = config.spec; | ||
| 32 | if (typeof spec === "string") { | ||
| 33 | return `openapi:${spec.trim()}`; | ||
| 34 | } | ||
| 35 | return `openapi:${stableStringify(spec)}`; | ||
| 36 | } | ||
| 37 | if (type === "graphql") { | ||
| 38 | const endpoint = typeof config.endpoint === "string" ? config.endpoint.trim() : ""; | ||
| 39 | return `graphql:${endpoint}`; | ||
| 40 | } | ||
| 41 | const url = typeof config.url === "string" ? config.url.trim() : ""; | ||
| 42 | return `mcp:${url}`; | ||
| 43 | } | ||
| 44 | |||
| 45 | export function mapTask(doc: Doc<"tasks">) { | ||
| 46 | return { | ||
| 47 | id: doc.taskId, | ||
| 48 | code: doc.code, | ||
| 49 | runtimeId: doc.runtimeId, | ||
| 50 | status: doc.status, | ||
| 51 | timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TASK_TIMEOUT_MS, | ||
| 52 | metadata: asRecord(doc.metadata), | ||
| 53 | workspaceId: doc.workspaceId, | ||
| 54 | actorId: doc.actorId, | ||
| 55 | clientId: doc.clientId, | ||
| 56 | createdAt: doc.createdAt, | ||
| 57 | updatedAt: doc.updatedAt, | ||
| 58 | startedAt: doc.startedAt, | ||
| 59 | completedAt: doc.completedAt, | ||
| 60 | error: doc.error, | ||
| 61 | result: doc.result, | ||
| 62 | exitCode: doc.exitCode, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | export function mapApproval(doc: Doc<"approvals">) { | ||
| 67 | return { | ||
| 68 | id: doc.approvalId, | ||
| 69 | taskId: doc.taskId, | ||
| 70 | toolPath: doc.toolPath, | ||
| 71 | input: doc.input, | ||
| 72 | status: doc.status, | ||
| 73 | reason: doc.reason, | ||
| 74 | reviewerId: doc.reviewerId, | ||
| 75 | createdAt: doc.createdAt, | ||
| 76 | resolvedAt: doc.resolvedAt, | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | |||
| 80 | export function mapToolCall(doc: Doc<"toolCalls">) { | ||
| 81 | return { | ||
| 82 | taskId: doc.taskId, | ||
| 83 | callId: doc.callId, | ||
| 84 | workspaceId: doc.workspaceId, | ||
| 85 | toolPath: doc.toolPath, | ||
| 86 | status: doc.status, | ||
| 87 | approvalId: doc.approvalId, | ||
| 88 | error: doc.error, | ||
| 89 | createdAt: doc.createdAt, | ||
| 90 | updatedAt: doc.updatedAt, | ||
| 91 | completedAt: doc.completedAt, | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | export function mapPolicy(doc: Doc<"accessPolicies">) { | ||
| 96 | return { | ||
| 97 | id: doc.policyId, | ||
| 98 | workspaceId: doc.workspaceId, | ||
| 99 | actorId: doc.actorId, | ||
| 100 | clientId: doc.clientId, | ||
| 101 | toolPathPattern: doc.toolPathPattern, | ||
| 102 | decision: doc.decision, | ||
| 103 | priority: doc.priority, | ||
| 104 | createdAt: doc.createdAt, | ||
| 105 | updatedAt: doc.updatedAt, | ||
| 106 | }; | ||
| 107 | } | ||
| 108 | |||
| 109 | export function mapCredential(doc: Doc<"sourceCredentials">) { | ||
| 110 | return { | ||
| 111 | id: doc.credentialId, | ||
| 112 | bindingId: doc.bindingId, | ||
| 113 | workspaceId: doc.workspaceId, | ||
| 114 | sourceKey: doc.sourceKey, | ||
| 115 | scope: doc.scope, | ||
| 116 | actorId: doc.actorId || undefined, | ||
| 117 | provider: doc.provider, | ||
| 118 | secretJson: asRecord(doc.secretJson), | ||
| 119 | overridesJson: asRecord(doc.overridesJson), | ||
| 120 | boundAuthFingerprint: doc.boundAuthFingerprint, | ||
| 121 | createdAt: doc.createdAt, | ||
| 122 | updatedAt: doc.updatedAt, | ||
| 123 | }; | ||
| 124 | } | ||
| 125 | |||
| 126 | export function mapSource(doc: Doc<"toolSources">) { | ||
| 127 | return { | ||
| 128 | id: doc.sourceId, | ||
| 129 | workspaceId: doc.workspaceId, | ||
| 130 | name: doc.name, | ||
| 131 | type: doc.type, | ||
| 132 | config: asRecord(doc.config), | ||
| 133 | specHash: doc.specHash, | ||
| 134 | authFingerprint: doc.authFingerprint, | ||
| 135 | enabled: Boolean(doc.enabled), | ||
| 136 | createdAt: doc.createdAt, | ||
| 137 | updatedAt: doc.updatedAt, | ||
| 138 | }; | ||
| 139 | } | ||
| @@ -0,0 +1,182 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | |||
| 4 | export const getActiveAnonymousOauthSigningKey = internalQuery({ | ||
| 5 | args: {}, | ||
| 6 | handler: async (ctx) => { | ||
| 7 | const doc = await ctx.db | ||
| 8 | .query("anonymousOauthSigningKeys") | ||
| 9 | .withIndex("by_status", (q) => q.eq("status", "active")) | ||
| 10 | .first(); | ||
| 11 | |||
| 12 | if (!doc) { | ||
| 13 | return null; | ||
| 14 | } | ||
| 15 | |||
| 16 | return { | ||
| 17 | keyId: doc.keyId, | ||
| 18 | algorithm: doc.algorithm, | ||
| 19 | privateKeyJwk: doc.privateKeyJwk, | ||
| 20 | publicKeyJwk: doc.publicKeyJwk, | ||
| 21 | createdAt: doc.createdAt, | ||
| 22 | }; | ||
| 23 | }, | ||
| 24 | }); | ||
| 25 | |||
| 26 | export const storeAnonymousOauthSigningKey = internalMutation({ | ||
| 27 | args: { | ||
| 28 | keyId: v.string(), | ||
| 29 | algorithm: v.string(), | ||
| 30 | privateKeyJwk: v.any(), | ||
| 31 | publicKeyJwk: v.any(), | ||
| 32 | }, | ||
| 33 | handler: async (ctx, args) => { | ||
| 34 | const now = Date.now(); | ||
| 35 | |||
| 36 | const activeKeys = await ctx.db | ||
| 37 | .query("anonymousOauthSigningKeys") | ||
| 38 | .withIndex("by_status", (q) => q.eq("status", "active")) | ||
| 39 | .collect(); | ||
| 40 | |||
| 41 | for (const key of activeKeys) { | ||
| 42 | await ctx.db.patch(key._id, { status: "rotated", rotatedAt: now }); | ||
| 43 | } | ||
| 44 | |||
| 45 | await ctx.db.insert("anonymousOauthSigningKeys", { | ||
| 46 | keyId: args.keyId, | ||
| 47 | algorithm: args.algorithm, | ||
| 48 | privateKeyJwk: args.privateKeyJwk, | ||
| 49 | publicKeyJwk: args.publicKeyJwk, | ||
| 50 | status: "active", | ||
| 51 | createdAt: now, | ||
| 52 | }); | ||
| 53 | }, | ||
| 54 | }); | ||
| 55 | |||
| 56 | export const registerAnonymousOauthClient = internalMutation({ | ||
| 57 | args: { | ||
| 58 | clientId: v.string(), | ||
| 59 | clientName: v.optional(v.string()), | ||
| 60 | redirectUris: v.array(v.string()), | ||
| 61 | }, | ||
| 62 | handler: async (ctx, args) => { | ||
| 63 | const now = Date.now(); | ||
| 64 | await ctx.db.insert("anonymousOauthClients", { | ||
| 65 | clientId: args.clientId, | ||
| 66 | clientName: args.clientName, | ||
| 67 | redirectUris: args.redirectUris, | ||
| 68 | createdAt: now, | ||
| 69 | }); | ||
| 70 | |||
| 71 | return { | ||
| 72 | client_id: args.clientId, | ||
| 73 | client_name: args.clientName, | ||
| 74 | redirect_uris: args.redirectUris, | ||
| 75 | created_at: now, | ||
| 76 | }; | ||
| 77 | }, | ||
| 78 | }); | ||
| 79 | |||
| 80 | export const getAnonymousOauthClient = internalQuery({ | ||
| 81 | args: { clientId: v.string() }, | ||
| 82 | handler: async (ctx, args) => { | ||
| 83 | const doc = await ctx.db | ||
| 84 | .query("anonymousOauthClients") | ||
| 85 | .withIndex("by_client_id", (q) => q.eq("clientId", args.clientId)) | ||
| 86 | .unique(); | ||
| 87 | |||
| 88 | if (!doc) { | ||
| 89 | return null; | ||
| 90 | } | ||
| 91 | |||
| 92 | return { | ||
| 93 | client_id: doc.clientId, | ||
| 94 | client_name: doc.clientName, | ||
| 95 | redirect_uris: doc.redirectUris, | ||
| 96 | created_at: doc.createdAt, | ||
| 97 | }; | ||
| 98 | }, | ||
| 99 | }); | ||
| 100 | |||
| 101 | export const storeAnonymousOauthAuthorizationCode = internalMutation({ | ||
| 102 | args: { | ||
| 103 | code: v.string(), | ||
| 104 | clientId: v.string(), | ||
| 105 | redirectUri: v.string(), | ||
| 106 | codeChallenge: v.string(), | ||
| 107 | codeChallengeMethod: v.string(), | ||
| 108 | actorId: v.string(), | ||
| 109 | tokenClaims: v.optional(v.any()), | ||
| 110 | expiresAt: v.number(), | ||
| 111 | createdAt: v.number(), | ||
| 112 | }, | ||
| 113 | handler: async (ctx, args) => { | ||
| 114 | await ctx.db.insert("anonymousOauthCodes", { | ||
| 115 | code: args.code, | ||
| 116 | clientId: args.clientId, | ||
| 117 | redirectUri: args.redirectUri, | ||
| 118 | codeChallenge: args.codeChallenge, | ||
| 119 | codeChallengeMethod: args.codeChallengeMethod, | ||
| 120 | actorId: args.actorId, | ||
| 121 | tokenClaims: args.tokenClaims, | ||
| 122 | expiresAt: args.expiresAt, | ||
| 123 | createdAt: args.createdAt, | ||
| 124 | }); | ||
| 125 | }, | ||
| 126 | }); | ||
| 127 | |||
| 128 | export const consumeAnonymousOauthAuthorizationCode = internalMutation({ | ||
| 129 | args: { | ||
| 130 | code: v.string(), | ||
| 131 | }, | ||
| 132 | handler: async (ctx, args) => { | ||
| 133 | const doc = await ctx.db | ||
| 134 | .query("anonymousOauthCodes") | ||
| 135 | .withIndex("by_code", (q) => q.eq("code", args.code)) | ||
| 136 | .unique(); | ||
| 137 | |||
| 138 | if (!doc) { | ||
| 139 | return null; | ||
| 140 | } | ||
| 141 | |||
| 142 | await ctx.db.delete(doc._id); | ||
| 143 | |||
| 144 | return { | ||
| 145 | code: doc.code, | ||
| 146 | clientId: doc.clientId, | ||
| 147 | redirectUri: doc.redirectUri, | ||
| 148 | codeChallenge: doc.codeChallenge, | ||
| 149 | codeChallengeMethod: doc.codeChallengeMethod, | ||
| 150 | actorId: doc.actorId, | ||
| 151 | tokenClaims: doc.tokenClaims, | ||
| 152 | expiresAt: doc.expiresAt, | ||
| 153 | createdAt: doc.createdAt, | ||
| 154 | }; | ||
| 155 | }, | ||
| 156 | }); | ||
| 157 | |||
| 158 | export const purgeExpiredAnonymousOauthAuthorizationCodes = internalMutation({ | ||
| 159 | args: { | ||
| 160 | now: v.number(), | ||
| 161 | }, | ||
| 162 | handler: async (ctx, args) => { | ||
| 163 | const expired = await ctx.db | ||
| 164 | .query("anonymousOauthCodes") | ||
| 165 | .withIndex("by_expires_at", (q) => q.lt("expiresAt", args.now)) | ||
| 166 | .collect(); | ||
| 167 | |||
| 168 | for (const doc of expired) { | ||
| 169 | await ctx.db.delete(doc._id); | ||
| 170 | } | ||
| 171 | |||
| 172 | return { purged: expired.length }; | ||
| 173 | }, | ||
| 174 | }); | ||
| 175 | |||
| 176 | export const countAnonymousOauthAuthorizationCodes = internalQuery({ | ||
| 177 | args: {}, | ||
| 178 | handler: async (ctx) => { | ||
| 179 | const docs = await ctx.db.query("anonymousOauthCodes").collect(); | ||
| 180 | return { count: docs.length }; | ||
| 181 | }, | ||
| 182 | }); | ||
| @@ -0,0 +1,94 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | import { mapPolicy } from "./database-mappers"; | ||
| 4 | import { policyDecisionValidator } from "./database-validators"; | ||
| 5 | |||
| 6 | export const listRuntimeTargets = internalQuery({ | ||
| 7 | args: {}, | ||
| 8 | handler: async () => { | ||
| 9 | return [ | ||
| 10 | { | ||
| 11 | id: "local-bun", | ||
| 12 | label: "Local JS Runtime", | ||
| 13 | description: "Runs generated code in-process using Bun", | ||
| 14 | }, | ||
| 15 | { | ||
| 16 | id: "cloudflare-worker-loader", | ||
| 17 | label: "Cloudflare Worker Loader", | ||
| 18 | description: "Runs generated code in a Cloudflare Worker", | ||
| 19 | }, | ||
| 20 | ]; | ||
| 21 | }, | ||
| 22 | }); | ||
| 23 | |||
| 24 | export const upsertAccessPolicy = internalMutation({ | ||
| 25 | args: { | ||
| 26 | id: v.optional(v.string()), | ||
| 27 | workspaceId: v.id("workspaces"), | ||
| 28 | actorId: v.optional(v.string()), | ||
| 29 | clientId: v.optional(v.string()), | ||
| 30 | toolPathPattern: v.string(), | ||
| 31 | decision: policyDecisionValidator, | ||
| 32 | priority: v.optional(v.number()), | ||
| 33 | }, | ||
| 34 | handler: async (ctx, args) => { | ||
| 35 | const now = Date.now(); | ||
| 36 | const policyId = args.id ?? `policy_${crypto.randomUUID()}`; | ||
| 37 | const existing = await ctx.db | ||
| 38 | .query("accessPolicies") | ||
| 39 | .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) | ||
| 40 | .unique(); | ||
| 41 | |||
| 42 | if (existing) { | ||
| 43 | await ctx.db.patch(existing._id, { | ||
| 44 | workspaceId: args.workspaceId, | ||
| 45 | actorId: args.actorId?.trim() || undefined, | ||
| 46 | clientId: args.clientId?.trim() || undefined, | ||
| 47 | toolPathPattern: args.toolPathPattern, | ||
| 48 | decision: args.decision, | ||
| 49 | priority: args.priority ?? 100, | ||
| 50 | updatedAt: now, | ||
| 51 | }); | ||
| 52 | } else { | ||
| 53 | await ctx.db.insert("accessPolicies", { | ||
| 54 | policyId, | ||
| 55 | workspaceId: args.workspaceId, | ||
| 56 | actorId: args.actorId?.trim() || undefined, | ||
| 57 | clientId: args.clientId?.trim() || undefined, | ||
| 58 | toolPathPattern: args.toolPathPattern, | ||
| 59 | decision: args.decision, | ||
| 60 | priority: args.priority ?? 100, | ||
| 61 | createdAt: now, | ||
| 62 | updatedAt: now, | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | |||
| 66 | const updated = await ctx.db | ||
| 67 | .query("accessPolicies") | ||
| 68 | .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) | ||
| 69 | .unique(); | ||
| 70 | if (!updated) { | ||
| 71 | throw new Error(`Failed to read policy ${policyId}`); | ||
| 72 | } | ||
| 73 | return mapPolicy(updated); | ||
| 74 | }, | ||
| 75 | }); | ||
| 76 | |||
| 77 | export const listAccessPolicies = internalQuery({ | ||
| 78 | args: { workspaceId: v.id("workspaces") }, | ||
| 79 | handler: async (ctx, args) => { | ||
| 80 | const docs = await ctx.db | ||
| 81 | .query("accessPolicies") | ||
| 82 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 83 | .collect(); | ||
| 84 | |||
| 85 | return docs | ||
| 86 | .sort((a, b) => { | ||
| 87 | if (a.priority !== b.priority) { | ||
| 88 | return b.priority - a.priority; | ||
| 89 | } | ||
| 90 | return a.createdAt - b.createdAt; | ||
| 91 | }) | ||
| 92 | .map(mapPolicy); | ||
| 93 | }, | ||
| 94 | }); | ||
| @@ -0,0 +1,77 @@ | |||
| 1 | import type { Doc } from "./_generated/dataModel"; | ||
| 2 | import type { QueryCtx } from "./_generated/server"; | ||
| 3 | import { normalizeSourceAuthFingerprint } from "./database-mappers"; | ||
| 4 | import { asRecord } from "./lib/object"; | ||
| 5 | |||
| 6 | export function mapAnonymousContext(doc: Doc<"anonymousSessions">) { | ||
| 7 | return { | ||
| 8 | sessionId: doc.sessionId, | ||
| 9 | workspaceId: doc.workspaceId, | ||
| 10 | actorId: doc.actorId, | ||
| 11 | clientId: doc.clientId, | ||
| 12 | accountId: doc.accountId, | ||
| 13 | userId: doc.userId, | ||
| 14 | createdAt: doc.createdAt, | ||
| 15 | lastSeenAt: doc.lastSeenAt, | ||
| 16 | }; | ||
| 17 | } | ||
| 18 | |||
| 19 | export function mapTaskEvent(doc: Doc<"taskEvents">) { | ||
| 20 | return { | ||
| 21 | id: doc.sequence, | ||
| 22 | taskId: doc.taskId, | ||
| 23 | eventName: doc.eventName, | ||
| 24 | type: doc.type, | ||
| 25 | payload: doc.payload, | ||
| 26 | createdAt: doc.createdAt, | ||
| 27 | }; | ||
| 28 | } | ||
| 29 | |||
| 30 | export async function getTaskDoc(ctx: { db: QueryCtx["db"] }, taskId: string) { | ||
| 31 | return await ctx.db.query("tasks").withIndex("by_task_id", (q) => q.eq("taskId", taskId)).unique(); | ||
| 32 | } | ||
| 33 | |||
| 34 | export async function getApprovalDoc(ctx: { db: QueryCtx["db"] }, approvalId: string) { | ||
| 35 | return await ctx.db | ||
| 36 | .query("approvals") | ||
| 37 | .withIndex("by_approval_id", (q) => q.eq("approvalId", approvalId)) | ||
| 38 | .unique(); | ||
| 39 | } | ||
| 40 | |||
| 41 | export async function getToolCallDoc( | ||
| 42 | ctx: { db: QueryCtx["db"] }, | ||
| 43 | taskId: string, | ||
| 44 | callId: string, | ||
| 45 | ) { | ||
| 46 | return await ctx.db | ||
| 47 | .query("toolCalls") | ||
| 48 | .withIndex("by_task_call", (q) => q.eq("taskId", taskId).eq("callId", callId)) | ||
| 49 | .unique(); | ||
| 50 | } | ||
| 51 | |||
| 52 | export async function computeBoundAuthFingerprint( | ||
| 53 | ctx: Pick<QueryCtx, "db">, | ||
| 54 | workspaceId: Doc<"workspaces">["_id"], | ||
| 55 | sourceKey: string, | ||
| 56 | ): Promise<string> { | ||
| 57 | const prefix = "source:"; | ||
| 58 | if (!sourceKey.startsWith(prefix)) { | ||
| 59 | return normalizeSourceAuthFingerprint({ type: "none" }); | ||
| 60 | } | ||
| 61 | |||
| 62 | const sourceId = sourceKey.slice(prefix.length).trim(); | ||
| 63 | if (!sourceId) { | ||
| 64 | return normalizeSourceAuthFingerprint({ type: "none" }); | ||
| 65 | } | ||
| 66 | |||
| 67 | const source = await ctx.db | ||
| 68 | .query("toolSources") | ||
| 69 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | ||
| 70 | .unique(); | ||
| 71 | |||
| 72 | if (!source || source.workspaceId !== workspaceId) { | ||
| 73 | return normalizeSourceAuthFingerprint({ type: "none" }); | ||
| 74 | } | ||
| 75 | |||
| 76 | return normalizeSourceAuthFingerprint(asRecord(source.config).auth); | ||
| 77 | } | ||
| @@ -0,0 +1,61 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | import { getTaskDoc, mapTaskEvent } from "./database-readers"; | ||
| 4 | import { asRecord } from "./lib/object"; | ||
| 5 | |||
| 6 | export const createTaskEvent = internalMutation({ | ||
| 7 | args: { | ||
| 8 | taskId: v.string(), | ||
| 9 | eventName: v.string(), | ||
| 10 | type: v.string(), | ||
| 11 | payload: v.any(), | ||
| 12 | }, | ||
| 13 | handler: async (ctx, args) => { | ||
| 14 | const task = await getTaskDoc(ctx, args.taskId); | ||
| 15 | if (!task) { | ||
| 16 | throw new Error(`Task not found for event: ${args.taskId}`); | ||
| 17 | } | ||
| 18 | |||
| 19 | const latest = await ctx.db | ||
| 20 | .query("taskEvents") | ||
| 21 | .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId)) | ||
| 22 | .order("desc") | ||
| 23 | .first(); | ||
| 24 | |||
| 25 | const sequence = latest ? latest.sequence + 1 : 1; | ||
| 26 | const createdAt = Date.now(); | ||
| 27 | |||
| 28 | await ctx.db.insert("taskEvents", { | ||
| 29 | sequence, | ||
| 30 | taskId: args.taskId, | ||
| 31 | eventName: args.eventName, | ||
| 32 | type: args.type, | ||
| 33 | payload: asRecord(args.payload), | ||
| 34 | createdAt, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const created = await ctx.db | ||
| 38 | .query("taskEvents") | ||
| 39 | .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId).eq("sequence", sequence)) | ||
| 40 | .unique(); | ||
| 41 | |||
| 42 | if (!created) { | ||
| 43 | throw new Error("Failed to read inserted task event"); | ||
| 44 | } | ||
| 45 | |||
| 46 | return mapTaskEvent(created); | ||
| 47 | }, | ||
| 48 | }); | ||
| 49 | |||
| 50 | export const listTaskEvents = internalQuery({ | ||
| 51 | args: { taskId: v.string() }, | ||
| 52 | handler: async (ctx, args) => { | ||
| 53 | const docs = await ctx.db | ||
| 54 | .query("taskEvents") | ||
| 55 | .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId)) | ||
| 56 | .order("asc") | ||
| 57 | .collect(); | ||
| 58 | |||
| 59 | return docs.map(mapTaskEvent); | ||
| 60 | }, | ||
| 61 | }); | ||
| @@ -0,0 +1,145 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | import { mapTask } from "./database-mappers"; | ||
| 4 | import { getTaskDoc } from "./database-readers"; | ||
| 5 | import { completedTaskStatusValidator } from "./database-validators"; | ||
| 6 | import { asRecord } from "./lib/object"; | ||
| 7 | import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants"; | ||
| 8 | import { isTerminalTaskStatus } from "./task-status"; | ||
| 9 | |||
| 10 | export const createTask = internalMutation({ | ||
| 11 | args: { | ||
| 12 | id: v.string(), | ||
| 13 | code: v.string(), | ||
| 14 | runtimeId: v.string(), | ||
| 15 | timeoutMs: v.optional(v.number()), | ||
| 16 | metadata: v.optional(v.any()), | ||
| 17 | workspaceId: v.id("workspaces"), | ||
| 18 | actorId: v.string(), | ||
| 19 | clientId: v.optional(v.string()), | ||
| 20 | }, | ||
| 21 | handler: async (ctx, args) => { | ||
| 22 | const existing = await getTaskDoc(ctx, args.id); | ||
| 23 | if (existing) { | ||
| 24 | throw new Error(`Task already exists: ${args.id}`); | ||
| 25 | } | ||
| 26 | |||
| 27 | const now = Date.now(); | ||
| 28 | await ctx.db.insert("tasks", { | ||
| 29 | taskId: args.id, | ||
| 30 | code: args.code, | ||
| 31 | runtimeId: args.runtimeId, | ||
| 32 | workspaceId: args.workspaceId, | ||
| 33 | actorId: args.actorId?.trim() || undefined, | ||
| 34 | clientId: args.clientId?.trim() || undefined, | ||
| 35 | status: "queued", | ||
| 36 | timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS, | ||
| 37 | metadata: asRecord(args.metadata), | ||
| 38 | createdAt: now, | ||
| 39 | updatedAt: now, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const created = await getTaskDoc(ctx, args.id); | ||
| 43 | if (!created) { | ||
| 44 | throw new Error(`Failed to fetch created task ${args.id}`); | ||
| 45 | } | ||
| 46 | return mapTask(created); | ||
| 47 | }, | ||
| 48 | }); | ||
| 49 | |||
| 50 | export const getTask = internalQuery({ | ||
| 51 | args: { taskId: v.string() }, | ||
| 52 | handler: async (ctx, args) => { | ||
| 53 | const doc = await getTaskDoc(ctx, args.taskId); | ||
| 54 | return doc ? mapTask(doc) : null; | ||
| 55 | }, | ||
| 56 | }); | ||
| 57 | |||
| 58 | export const listTasks = internalQuery({ | ||
| 59 | args: { workspaceId: v.id("workspaces") }, | ||
| 60 | handler: async (ctx, args) => { | ||
| 61 | const docs = await ctx.db | ||
| 62 | .query("tasks") | ||
| 63 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 64 | .order("desc") | ||
| 65 | .take(500); | ||
| 66 | return docs.map(mapTask); | ||
| 67 | }, | ||
| 68 | }); | ||
| 69 | |||
| 70 | export const listQueuedTaskIds = internalQuery({ | ||
| 71 | args: { limit: v.optional(v.number()) }, | ||
| 72 | handler: async (ctx, args) => { | ||
| 73 | const docs = await ctx.db | ||
| 74 | .query("tasks") | ||
| 75 | .withIndex("by_status_created", (q) => q.eq("status", "queued")) | ||
| 76 | .order("asc") | ||
| 77 | .take(args.limit ?? 20); | ||
| 78 | |||
| 79 | return docs.map((doc) => doc.taskId); | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | |||
| 83 | export const getTaskInWorkspace = internalQuery({ | ||
| 84 | args: { taskId: v.string(), workspaceId: v.id("workspaces") }, | ||
| 85 | handler: async (ctx, args) => { | ||
| 86 | const doc = await getTaskDoc(ctx, args.taskId); | ||
| 87 | if (!doc || doc.workspaceId !== args.workspaceId) { | ||
| 88 | return null; | ||
| 89 | } | ||
| 90 | return mapTask(doc); | ||
| 91 | }, | ||
| 92 | }); | ||
| 93 | |||
| 94 | export const markTaskRunning = internalMutation({ | ||
| 95 | args: { taskId: v.string() }, | ||
| 96 | handler: async (ctx, args) => { | ||
| 97 | const doc = await getTaskDoc(ctx, args.taskId); | ||
| 98 | if (!doc || doc.status !== "queued") { | ||
| 99 | return null; | ||
| 100 | } | ||
| 101 | |||
| 102 | const now = Date.now(); | ||
| 103 | await ctx.db.patch(doc._id, { | ||
| 104 | status: "running", | ||
| 105 | startedAt: doc.startedAt ?? now, | ||
| 106 | updatedAt: now, | ||
| 107 | }); | ||
| 108 | |||
| 109 | const updated = await getTaskDoc(ctx, args.taskId); | ||
| 110 | return updated ? mapTask(updated) : null; | ||
| 111 | }, | ||
| 112 | }); | ||
| 113 | |||
| 114 | export const markTaskFinished = internalMutation({ | ||
| 115 | args: { | ||
| 116 | taskId: v.string(), | ||
| 117 | status: completedTaskStatusValidator, | ||
| 118 | result: v.optional(v.any()), | ||
| 119 | exitCode: v.optional(v.number()), | ||
| 120 | error: v.optional(v.string()), | ||
| 121 | }, | ||
| 122 | handler: async (ctx, args) => { | ||
| 123 | const doc = await getTaskDoc(ctx, args.taskId); | ||
| 124 | if (!doc) { | ||
| 125 | return null; | ||
| 126 | } | ||
| 127 | |||
| 128 | if (isTerminalTaskStatus(doc.status)) { | ||
| 129 | return mapTask(doc); | ||
| 130 | } | ||
| 131 | |||
| 132 | const now = Date.now(); | ||
| 133 | await ctx.db.patch(doc._id, { | ||
| 134 | status: args.status, | ||
| 135 | result: args.result, | ||
| 136 | exitCode: args.exitCode, | ||
| 137 | error: args.error, | ||
| 138 | completedAt: now, | ||
| 139 | updatedAt: now, | ||
| 140 | }); | ||
| 141 | |||
| 142 | const updated = await getTaskDoc(ctx, args.taskId); | ||
| 143 | return updated ? mapTask(updated) : null; | ||
| 144 | }, | ||
| 145 | }); | ||
| @@ -0,0 +1,127 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | import { mapToolCall } from "./database-mappers"; | ||
| 4 | import { getToolCallDoc } from "./database-readers"; | ||
| 5 | import { terminalToolCallStatusValidator } from "./database-validators"; | ||
| 6 | |||
| 7 | export const upsertToolCallRequested = internalMutation({ | ||
| 8 | args: { | ||
| 9 | taskId: v.string(), | ||
| 10 | callId: v.string(), | ||
| 11 | workspaceId: v.id("workspaces"), | ||
| 12 | toolPath: v.string(), | ||
| 13 | }, | ||
| 14 | handler: async (ctx, args) => { | ||
| 15 | const existing = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 16 | if (existing) { | ||
| 17 | return mapToolCall(existing); | ||
| 18 | } | ||
| 19 | |||
| 20 | const now = Date.now(); | ||
| 21 | await ctx.db.insert("toolCalls", { | ||
| 22 | taskId: args.taskId, | ||
| 23 | callId: args.callId, | ||
| 24 | workspaceId: args.workspaceId, | ||
| 25 | toolPath: args.toolPath, | ||
| 26 | status: "requested", | ||
| 27 | createdAt: now, | ||
| 28 | updatedAt: now, | ||
| 29 | }); | ||
| 30 | |||
| 31 | const created = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 32 | if (!created) { | ||
| 33 | throw new Error(`Failed to create tool call ${args.taskId}/${args.callId}`); | ||
| 34 | } | ||
| 35 | return mapToolCall(created); | ||
| 36 | }, | ||
| 37 | }); | ||
| 38 | |||
| 39 | export const getToolCall = internalQuery({ | ||
| 40 | args: { | ||
| 41 | taskId: v.string(), | ||
| 42 | callId: v.string(), | ||
| 43 | }, | ||
| 44 | handler: async (ctx, args) => { | ||
| 45 | const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 46 | return doc ? mapToolCall(doc) : null; | ||
| 47 | }, | ||
| 48 | }); | ||
| 49 | |||
| 50 | export const setToolCallPendingApproval = internalMutation({ | ||
| 51 | args: { | ||
| 52 | taskId: v.string(), | ||
| 53 | callId: v.string(), | ||
| 54 | approvalId: v.string(), | ||
| 55 | }, | ||
| 56 | handler: async (ctx, args) => { | ||
| 57 | const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 58 | if (!doc) { | ||
| 59 | throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); | ||
| 60 | } | ||
| 61 | |||
| 62 | if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { | ||
| 63 | return mapToolCall(doc); | ||
| 64 | } | ||
| 65 | |||
| 66 | const now = Date.now(); | ||
| 67 | await ctx.db.patch(doc._id, { | ||
| 68 | status: "pending_approval", | ||
| 69 | approvalId: args.approvalId, | ||
| 70 | updatedAt: now, | ||
| 71 | }); | ||
| 72 | |||
| 73 | const updated = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 74 | if (!updated) { | ||
| 75 | throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); | ||
| 76 | } | ||
| 77 | return mapToolCall(updated); | ||
| 78 | }, | ||
| 79 | }); | ||
| 80 | |||
| 81 | export const finishToolCall = internalMutation({ | ||
| 82 | args: { | ||
| 83 | taskId: v.string(), | ||
| 84 | callId: v.string(), | ||
| 85 | status: terminalToolCallStatusValidator, | ||
| 86 | error: v.optional(v.string()), | ||
| 87 | }, | ||
| 88 | handler: async (ctx, args) => { | ||
| 89 | const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 90 | if (!doc) { | ||
| 91 | throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); | ||
| 92 | } | ||
| 93 | |||
| 94 | if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { | ||
| 95 | return mapToolCall(doc); | ||
| 96 | } | ||
| 97 | |||
| 98 | const now = Date.now(); | ||
| 99 | await ctx.db.patch(doc._id, { | ||
| 100 | status: args.status, | ||
| 101 | error: args.error, | ||
| 102 | updatedAt: now, | ||
| 103 | completedAt: now, | ||
| 104 | }); | ||
| 105 | |||
| 106 | const updated = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 107 | if (!updated) { | ||
| 108 | throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); | ||
| 109 | } | ||
| 110 | return mapToolCall(updated); | ||
| 111 | }, | ||
| 112 | }); | ||
| 113 | |||
| 114 | export const listToolCalls = internalQuery({ | ||
| 115 | args: { | ||
| 116 | taskId: v.string(), | ||
| 117 | }, | ||
| 118 | handler: async (ctx, args) => { | ||
| 119 | const docs = await ctx.db | ||
| 120 | .query("toolCalls") | ||
| 121 | .withIndex("by_task_created", (q) => q.eq("taskId", args.taskId)) | ||
| 122 | .order("asc") | ||
| 123 | .collect(); | ||
| 124 | |||
| 125 | return docs.map(mapToolCall); | ||
| 126 | }, | ||
| 127 | }); | ||
| @@ -0,0 +1,117 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | import { | ||
| 4 | computeSourceSpecHash, | ||
| 5 | mapSource, | ||
| 6 | normalizeSourceAuthFingerprint, | ||
| 7 | } from "./database-mappers"; | ||
| 8 | import { toolSourceTypeValidator } from "./database-validators"; | ||
| 9 | import { asRecord } from "./lib/object"; | ||
| 10 | |||
| 11 | export const upsertToolSource = internalMutation({ | ||
| 12 | args: { | ||
| 13 | id: v.optional(v.string()), | ||
| 14 | workspaceId: v.id("workspaces"), | ||
| 15 | name: v.string(), | ||
| 16 | type: toolSourceTypeValidator, | ||
| 17 | config: v.any(), | ||
| 18 | enabled: v.optional(v.boolean()), | ||
| 19 | }, | ||
| 20 | handler: async (ctx, args) => { | ||
| 21 | const now = Date.now(); | ||
| 22 | const sourceId = args.id ?? `src_${crypto.randomUUID()}`; | ||
| 23 | const config = asRecord(args.config); | ||
| 24 | const specHash = computeSourceSpecHash(args.type, config); | ||
| 25 | const authFingerprint = normalizeSourceAuthFingerprint(config.auth); | ||
| 26 | const [existing, conflict] = await Promise.all([ | ||
| 27 | ctx.db | ||
| 28 | .query("toolSources") | ||
| 29 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | ||
| 30 | .unique(), | ||
| 31 | ctx.db | ||
| 32 | .query("toolSources") | ||
| 33 | .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name)) | ||
| 34 | .unique(), | ||
| 35 | ]); | ||
| 36 | |||
| 37 | if (conflict && conflict.sourceId !== sourceId) { | ||
| 38 | throw new Error(`Tool source name '${args.name}' already exists in workspace ${args.workspaceId}`); | ||
| 39 | } | ||
| 40 | |||
| 41 | if (existing) { | ||
| 42 | await ctx.db.patch(existing._id, { | ||
| 43 | workspaceId: args.workspaceId, | ||
| 44 | name: args.name, | ||
| 45 | type: args.type, | ||
| 46 | config, | ||
| 47 | specHash, | ||
| 48 | authFingerprint, | ||
| 49 | enabled: args.enabled !== false, | ||
| 50 | updatedAt: now, | ||
| 51 | }); | ||
| 52 | } else { | ||
| 53 | await ctx.db.insert("toolSources", { | ||
| 54 | sourceId, | ||
| 55 | workspaceId: args.workspaceId, | ||
| 56 | name: args.name, | ||
| 57 | type: args.type, | ||
| 58 | config, | ||
| 59 | specHash, | ||
| 60 | authFingerprint, | ||
| 61 | enabled: args.enabled !== false, | ||
| 62 | createdAt: now, | ||
| 63 | updatedAt: now, | ||
| 64 | }); | ||
| 65 | } | ||
| 66 | |||
| 67 | const updated = await ctx.db | ||
| 68 | .query("toolSources") | ||
| 69 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | ||
| 70 | .unique(); | ||
| 71 | if (!updated) { | ||
| 72 | throw new Error(`Failed to read tool source ${sourceId}`); | ||
| 73 | } | ||
| 74 | return mapSource(updated); | ||
| 75 | }, | ||
| 76 | }); | ||
| 77 | |||
| 78 | export const listToolSources = internalQuery({ | ||
| 79 | args: { workspaceId: v.id("workspaces") }, | ||
| 80 | handler: async (ctx, args) => { | ||
| 81 | const docs = await ctx.db | ||
| 82 | .query("toolSources") | ||
| 83 | .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 84 | .order("desc") | ||
| 85 | .collect(); | ||
| 86 | return docs.map(mapSource); | ||
| 87 | }, | ||
| 88 | }); | ||
| 89 | |||
| 90 | export const deleteToolSource = internalMutation({ | ||
| 91 | args: { workspaceId: v.id("workspaces"), sourceId: v.string() }, | ||
| 92 | handler: async (ctx, args) => { | ||
| 93 | const doc = await ctx.db | ||
| 94 | .query("toolSources") | ||
| 95 | .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId)) | ||
| 96 | .unique(); | ||
| 97 | |||
| 98 | if (!doc || doc.workspaceId !== args.workspaceId) { | ||
| 99 | return false; | ||
| 100 | } | ||
| 101 | |||
| 102 | const sourceKey = `source:${args.sourceId}`; | ||
| 103 | const bindings = await ctx.db | ||
| 104 | .query("sourceCredentials") | ||
| 105 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 106 | q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey), | ||
| 107 | ) | ||
| 108 | .collect(); | ||
| 109 | |||
| 110 | for (const binding of bindings) { | ||
| 111 | await ctx.db.delete(binding._id); | ||
| 112 | } | ||
| 113 | |||
| 114 | await ctx.db.delete(doc._id); | ||
| 115 | return true; | ||
| 116 | }, | ||
| 117 | }); | ||
| @@ -0,0 +1,27 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | |||
| 3 | export const completedTaskStatusValidator = v.union( | ||
| 4 | v.literal("completed"), | ||
| 5 | v.literal("failed"), | ||
| 6 | v.literal("timed_out"), | ||
| 7 | v.literal("denied"), | ||
| 8 | ); | ||
| 9 | |||
| 10 | export const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); | ||
| 11 | |||
| 12 | export const terminalToolCallStatusValidator = v.union( | ||
| 13 | v.literal("completed"), | ||
| 14 | v.literal("failed"), | ||
| 15 | v.literal("denied"), | ||
| 16 | ); | ||
| 17 | |||
| 18 | export const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | ||
| 19 | |||
| 20 | export const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | ||
| 21 | |||
| 22 | export const credentialProviderValidator = v.union( | ||
| 23 | v.literal("local-convex"), | ||
| 24 | v.literal("workos-vault"), | ||
| 25 | ); | ||
| 26 | |||
| 27 | export const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | ||
| @@ -1,1531 +1,52 @@ | |||
| 1 | import { v } from "convex/values"; | 1 | export { |
| 2 | import type { Doc } from "./_generated/dataModel"; | 2 | createTask, |
| 3 | import { type MutationCtx, type QueryCtx } from "./_generated/server"; | 3 | getTask, |
| 4 | import { internalMutation, internalQuery } from "./_generated/server"; | 4 | listTasks, |
| 5 | import { slugify } from "../core/src/identity"; | 5 | listQueuedTaskIds, |
| 6 | import { ensureUniqueSlug } from "../core/src/slug"; | 6 | getTaskInWorkspace, |
| 7 | import type { TaskStatus } from "../core/src/types"; | 7 | markTaskRunning, |
| 8 | 8 | markTaskFinished, | |
| 9 | const DEFAULT_TIMEOUT_MS = 300_000; | 9 | } from "./database-tasks"; |
| 10 | type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | 10 | export { |
| 11 | type OrganizationMemberStatus = "active" | "pending" | "removed"; | 11 | createApproval, |
| 12 | 12 | getApproval, | |
| 13 | const 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 | ); | 18 | export { |
| 19 | const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); | 19 | upsertToolCallRequested, |
| 20 | const 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"; |
| 25 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 25 | export { bootstrapAnonymousSession } from "./database-anonymous-session"; |
| 26 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 26 | export { |
| 27 | const credentialProviderValidator = v.union( | 27 | listRuntimeTargets, |
| 28 | v.literal("local-convex"), | 28 | upsertAccessPolicy, |
| 29 | v.literal("workos-vault"), | 29 | listAccessPolicies, |
| 30 | ); | 30 | } from "./database-policies"; |
| 31 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | 31 | export { |
| 32 | 32 | upsertCredential, | |
| 33 | async 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.db | 36 | } from "./database-credentials"; |
| 37 | .query("organizations") | 37 | export { |
| 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 | } | 42 | export { createTaskEvent, listTaskEvents } from "./database-task-events"; |
| 43 | 43 | export { | |
| 44 | async 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. | ||
| 85 | function asRecord(value: unknown): Record<string, unknown> { | ||
| 86 | if (value && typeof value === "object" && !Array.isArray(value)) { | ||
| 87 | return value as Record<string, unknown>; | ||
| 88 | } | ||
| 89 | return {}; | ||
| 90 | } | ||
| 91 | |||
| 92 | function stableStringify(value: unknown): string { | ||
| 93 | if (Array.isArray(value)) { | ||
| 94 | return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; | ||
| 95 | } | ||
| 96 | if (value && typeof value === "object") { | ||
| 97 | const record = value as Record<string, unknown>; | ||
| 98 | const keys = Object.keys(record).sort(); | ||
| 99 | return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`; | ||
| 100 | } | ||
| 101 | return JSON.stringify(value); | ||
| 102 | } | ||
| 103 | |||
| 104 | function normalizeSourceAuthFingerprint(value: unknown): string { | ||
| 105 | const auth = asRecord(value); | ||
| 106 | const type = typeof auth.type === "string" ? auth.type.trim() : "none"; | ||
| 107 | const mode = auth.mode === "actor" ? "actor" : "workspace"; | ||
| 108 | const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : ""; | ||
| 109 | return stableStringify({ | ||
| 110 | type: type || "none", | ||
| 111 | mode, | ||
| 112 | ...(header ? { header } : {}), | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | |||
| 116 | function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", config: Record<string, unknown>): string { | ||
| 117 | if (type === "openapi") { | ||
| 118 | const spec = config.spec; | ||
| 119 | if (typeof spec === "string") { | ||
| 120 | return `openapi:${spec.trim()}`; | ||
| 121 | } | ||
| 122 | return `openapi:${stableStringify(spec)}`; | ||
| 123 | } | ||
| 124 | if (type === "graphql") { | ||
| 125 | const endpoint = typeof config.endpoint === "string" ? config.endpoint.trim() : ""; | ||
| 126 | return `graphql:${endpoint}`; | ||
| 127 | } | ||
| 128 | const url = typeof config.url === "string" ? config.url.trim() : ""; | ||
| 129 | return `mcp:${url}`; | ||
| 130 | } | ||
| 131 | |||
| 132 | function mapTask(doc: Doc<"tasks">) { | ||
| 133 | return { | ||
| 134 | id: doc.taskId, | ||
| 135 | code: doc.code, | ||
| 136 | runtimeId: doc.runtimeId, | ||
| 137 | status: doc.status, | ||
| 138 | timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TIMEOUT_MS, | ||
| 139 | metadata: asRecord(doc.metadata), | ||
| 140 | workspaceId: doc.workspaceId, | ||
| 141 | actorId: doc.actorId, | ||
| 142 | clientId: doc.clientId, | ||
| 143 | createdAt: doc.createdAt, | ||
| 144 | updatedAt: doc.updatedAt, | ||
| 145 | startedAt: doc.startedAt, | ||
| 146 | completedAt: doc.completedAt, | ||
| 147 | error: doc.error, | ||
| 148 | result: doc.result, | ||
| 149 | exitCode: doc.exitCode, | ||
| 150 | }; | ||
| 151 | } | ||
| 152 | |||
| 153 | function mapApproval(doc: Doc<"approvals">) { | ||
| 154 | return { | ||
| 155 | id: doc.approvalId, | ||
| 156 | taskId: doc.taskId, | ||
| 157 | toolPath: doc.toolPath, | ||
| 158 | input: doc.input, | ||
| 159 | status: doc.status, | ||
| 160 | reason: doc.reason, | ||
| 161 | reviewerId: doc.reviewerId, | ||
| 162 | createdAt: doc.createdAt, | ||
| 163 | resolvedAt: doc.resolvedAt, | ||
| 164 | }; | ||
| 165 | } | ||
| 166 | |||
| 167 | function mapToolCall(doc: Doc<"toolCalls">) { | ||
| 168 | return { | ||
| 169 | taskId: doc.taskId, | ||
| 170 | callId: doc.callId, | ||
| 171 | workspaceId: doc.workspaceId, | ||
| 172 | toolPath: doc.toolPath, | ||
| 173 | status: doc.status, | ||
| 174 | approvalId: doc.approvalId, | ||
| 175 | error: doc.error, | ||
| 176 | createdAt: doc.createdAt, | ||
| 177 | updatedAt: doc.updatedAt, | ||
| 178 | completedAt: doc.completedAt, | ||
| 179 | }; | ||
| 180 | } | ||
| 181 | |||
| 182 | function mapPolicy(doc: Doc<"accessPolicies">) { | ||
| 183 | return { | ||
| 184 | id: doc.policyId, | ||
| 185 | workspaceId: doc.workspaceId, | ||
| 186 | actorId: doc.actorId, | ||
| 187 | clientId: doc.clientId, | ||
| 188 | toolPathPattern: doc.toolPathPattern, | ||
| 189 | decision: doc.decision, | ||
| 190 | priority: doc.priority, | ||
| 191 | createdAt: doc.createdAt, | ||
| 192 | updatedAt: doc.updatedAt, | ||
| 193 | }; | ||
| 194 | } | ||
| 195 | |||
| 196 | function mapCredential(doc: Doc<"sourceCredentials">) { | ||
| 197 | return { | ||
| 198 | id: doc.credentialId, | ||
| 199 | bindingId: doc.bindingId, | ||
| 200 | workspaceId: doc.workspaceId, | ||
| 201 | sourceKey: doc.sourceKey, | ||
| 202 | scope: doc.scope, | ||
| 203 | actorId: doc.actorId || undefined, | ||
| 204 | provider: doc.provider, | ||
| 205 | secretJson: asRecord(doc.secretJson), | ||
| 206 | overridesJson: asRecord(doc.overridesJson), | ||
| 207 | boundAuthFingerprint: doc.boundAuthFingerprint, | ||
| 208 | createdAt: doc.createdAt, | ||
| 209 | updatedAt: doc.updatedAt, | ||
| 210 | }; | ||
| 211 | } | ||
| 212 | |||
| 213 | function mapSource(doc: Doc<"toolSources">) { | ||
| 214 | return { | ||
| 215 | id: doc.sourceId, | ||
| 216 | workspaceId: doc.workspaceId, | ||
| 217 | name: doc.name, | ||
| 218 | type: doc.type, | ||
| 219 | config: asRecord(doc.config), | ||
| 220 | specHash: doc.specHash, | ||
| 221 | authFingerprint: doc.authFingerprint, | ||
| 222 | enabled: Boolean(doc.enabled), | ||
| 223 | createdAt: doc.createdAt, | ||
| 224 | updatedAt: doc.updatedAt, | ||
| 225 | }; | ||
| 226 | } | ||
| 227 | |||
| 228 | async function ensureAnonymousIdentity( | ||
| 229 | ctx: MutationCtx, | ||
| 230 | params: { | ||
| 231 | sessionId: string; | ||
| 232 | workspaceId?: Doc<"workspaces">["_id"]; | ||
| 233 | actorId: string; | ||
| 234 | timestamp: number; | ||
| 235 | }, | ||
| 236 | ) { | ||
| 237 | const anonymousOrganizationName = "Anonymous Organization"; | ||
| 238 | const anonymousWorkspaceName = "Anonymous Workspace"; | ||
| 239 | const now = params.timestamp; | ||
| 240 | |||
| 241 | let account = await ctx.db | ||
| 242 | .query("accounts") | ||
| 243 | .withIndex("by_provider", (q) => q.eq("provider", "anonymous").eq("providerAccountId", params.actorId)) | ||
| 244 | .unique(); | ||
| 245 | |||
| 246 | if (!account) { | ||
| 247 | const accountId = await ctx.db.insert("accounts", { | ||
| 248 | provider: "anonymous", | ||
| 249 | providerAccountId: params.actorId, | ||
| 250 | email: `${params.actorId}@guest.executor.local`, | ||
| 251 | name: "Guest User", | ||
| 252 | status: "active", | ||
| 253 | createdAt: now, | ||
| 254 | updatedAt: now, | ||
| 255 | lastLoginAt: now, | ||
| 256 | }); | ||
| 257 | account = await ctx.db.get(accountId); | ||
| 258 | if (!account) { | ||
| 259 | throw new Error("Failed to create anonymous account"); | ||
| 260 | } | ||
| 261 | } else { | ||
| 262 | await ctx.db.patch(account._id, { updatedAt: now, lastLoginAt: now }); | ||
| 263 | } | ||
| 264 | |||
| 265 | let workspace = params.workspaceId ? await ctx.db.get(params.workspaceId) : null; | ||
| 266 | |||
| 267 | let organizationId: Doc<"organizations">["_id"]; | ||
| 268 | |||
| 269 | if (!workspace) { | ||
| 270 | const organizationSlug = await ensureUniqueOrganizationSlug(ctx, anonymousOrganizationName); | ||
| 271 | organizationId = await ctx.db.insert("organizations", { | ||
| 272 | slug: organizationSlug, | ||
| 273 | name: anonymousOrganizationName, | ||
| 274 | status: "active", | ||
| 275 | createdByAccountId: account._id, | ||
| 276 | createdAt: now, | ||
| 277 | updatedAt: now, | ||
| 278 | }); | ||
| 279 | |||
| 280 | const workspaceId = await ctx.db.insert("workspaces", { | ||
| 281 | organizationId, | ||
| 282 | slug: `anonymous-${crypto.randomUUID().slice(0, 8)}`, | ||
| 283 | name: anonymousWorkspaceName, | ||
| 284 | createdByAccountId: account._id, | ||
| 285 | createdAt: now, | ||
| 286 | updatedAt: now, | ||
| 287 | }); | ||
| 288 | workspace = await ctx.db.get(workspaceId); | ||
| 289 | if (!workspace) { | ||
| 290 | throw new Error("Failed to create anonymous workspace"); | ||
| 291 | } | ||
| 292 | } else { | ||
| 293 | organizationId = workspace.organizationId; | ||
| 294 | } | ||
| 295 | |||
| 296 | await upsertOrganizationMembership(ctx, { | ||
| 297 | organizationId, | ||
| 298 | accountId: account._id, | ||
| 299 | role: "owner", | ||
| 300 | status: "active", | ||
| 301 | billable: true, | ||
| 302 | now, | ||
| 303 | }); | ||
| 304 | |||
| 305 | let user = await ctx.db | ||
| 306 | .query("workspaceMembers") | ||
| 307 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) | ||
| 308 | .unique(); | ||
| 309 | |||
| 310 | if (!user) { | ||
| 311 | const userId = await ctx.db.insert("workspaceMembers", { | ||
| 312 | workspaceId: workspace._id, | ||
| 313 | accountId: account._id, | ||
| 314 | role: "owner", | ||
| 315 | status: "active", | ||
| 316 | createdAt: now, | ||
| 317 | updatedAt: now, | ||
| 318 | }); | ||
| 319 | user = await ctx.db.get(userId); | ||
| 320 | if (!user) { | ||
| 321 | throw new Error("Failed to create anonymous user membership"); | ||
| 322 | } | ||
| 323 | } else { | ||
| 324 | await ctx.db.patch(user._id, { updatedAt: now }); | ||
| 325 | } | ||
| 326 | |||
| 327 | return { | ||
| 328 | accountId: account._id, | ||
| 329 | workspaceId: workspace._id, | ||
| 330 | userId: user._id, | ||
| 331 | }; | ||
| 332 | } | ||
| 333 | |||
| 334 | function mapAnonymousContext(doc: Doc<"anonymousSessions">) { | ||
| 335 | return { | ||
| 336 | sessionId: doc.sessionId, | ||
| 337 | workspaceId: doc.workspaceId, | ||
| 338 | actorId: doc.actorId, | ||
| 339 | clientId: doc.clientId, | ||
| 340 | accountId: doc.accountId, | ||
| 341 | userId: doc.userId, | ||
| 342 | createdAt: doc.createdAt, | ||
| 343 | lastSeenAt: doc.lastSeenAt, | ||
| 344 | }; | ||
| 345 | } | ||
| 346 | |||
| 347 | function mapTaskEvent(doc: Doc<"taskEvents">) { | ||
| 348 | return { | ||
| 349 | id: doc.sequence, | ||
| 350 | taskId: doc.taskId, | ||
| 351 | eventName: doc.eventName, | ||
| 352 | type: doc.type, | ||
| 353 | payload: doc.payload, | ||
| 354 | createdAt: doc.createdAt, | ||
| 355 | }; | ||
| 356 | } | ||
| 357 | |||
| 358 | async function getTaskDoc(ctx: { db: QueryCtx["db"] }, taskId: string) { | ||
| 359 | return await ctx.db.query("tasks").withIndex("by_task_id", (q) => q.eq("taskId", taskId)).unique(); | ||
| 360 | } | ||
| 361 | |||
| 362 | async function getApprovalDoc(ctx: { db: QueryCtx["db"] }, approvalId: string) { | ||
| 363 | return await ctx.db | ||
| 364 | .query("approvals") | ||
| 365 | .withIndex("by_approval_id", (q) => q.eq("approvalId", approvalId)) | ||
| 366 | .unique(); | ||
| 367 | } | ||
| 368 | |||
| 369 | async function getToolCallDoc( | ||
| 370 | ctx: { db: QueryCtx["db"] }, | ||
| 371 | taskId: string, | ||
| 372 | callId: string, | ||
| 373 | ) { | ||
| 374 | return await ctx.db | ||
| 375 | .query("toolCalls") | ||
| 376 | .withIndex("by_task_call", (q) => q.eq("taskId", taskId).eq("callId", callId)) | ||
| 377 | .unique(); | ||
| 378 | } | ||
| 379 | |||
| 380 | export const createTask = internalMutation({ | ||
| 381 | args: { | ||
| 382 | id: v.string(), | ||
| 383 | code: v.string(), | ||
| 384 | runtimeId: v.string(), | ||
| 385 | timeoutMs: v.optional(v.number()), | ||
| 386 | metadata: v.optional(v.any()), | ||
| 387 | workspaceId: v.id("workspaces"), | ||
| 388 | actorId: v.string(), | ||
| 389 | clientId: v.optional(v.string()), | ||
| 390 | }, | ||
| 391 | handler: async (ctx, args) => { | ||
| 392 | const existing = await getTaskDoc(ctx, args.id); | ||
| 393 | if (existing) { | ||
| 394 | throw new Error(`Task already exists: ${args.id}`); | ||
| 395 | } | ||
| 396 | |||
| 397 | const now = Date.now(); | ||
| 398 | await ctx.db.insert("tasks", { | ||
| 399 | taskId: args.id, | ||
| 400 | code: args.code, | ||
| 401 | runtimeId: args.runtimeId, | ||
| 402 | workspaceId: args.workspaceId, | ||
| 403 | actorId: args.actorId?.trim() || undefined, | ||
| 404 | clientId: args.clientId?.trim() || undefined, | ||
| 405 | status: "queued", | ||
| 406 | timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 407 | metadata: asRecord(args.metadata), | ||
| 408 | createdAt: now, | ||
| 409 | updatedAt: now, | ||
| 410 | }); | ||
| 411 | |||
| 412 | const created = await getTaskDoc(ctx, args.id); | ||
| 413 | if (!created) { | ||
| 414 | throw new Error(`Failed to fetch created task ${args.id}`); | ||
| 415 | } | ||
| 416 | return mapTask(created); | ||
| 417 | }, | ||
| 418 | }); | ||
| 419 | |||
| 420 | export const getTask = internalQuery({ | ||
| 421 | args: { taskId: v.string() }, | ||
| 422 | handler: async (ctx, args) => { | ||
| 423 | const doc = await getTaskDoc(ctx, args.taskId); | ||
| 424 | return doc ? mapTask(doc) : null; | ||
| 425 | }, | ||
| 426 | }); | ||
| 427 | |||
| 428 | export const listTasks = internalQuery({ | ||
| 429 | args: { workspaceId: v.id("workspaces") }, | ||
| 430 | handler: async (ctx, args) => { | ||
| 431 | const docs = await ctx.db | ||
| 432 | .query("tasks") | ||
| 433 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 434 | .order("desc") | ||
| 435 | .take(500); | ||
| 436 | return docs.map(mapTask); | ||
| 437 | }, | ||
| 438 | }); | ||
| 439 | |||
| 440 | export const listQueuedTaskIds = internalQuery({ | ||
| 441 | args: { limit: v.optional(v.number()) }, | ||
| 442 | handler: async (ctx, args) => { | ||
| 443 | const docs = await ctx.db | ||
| 444 | .query("tasks") | ||
| 445 | .withIndex("by_status_created", (q) => q.eq("status", "queued")) | ||
| 446 | .order("asc") | ||
| 447 | .take(args.limit ?? 20); | ||
| 448 | |||
| 449 | return docs.map((doc) => doc.taskId); | ||
| 450 | }, | ||
| 451 | }); | ||
| 452 | |||
| 453 | export const listRuntimeTargets = internalQuery({ | ||
| 454 | args: {}, | ||
| 455 | handler: async () => { | ||
| 456 | return [ | ||
| 457 | { | ||
| 458 | id: "local-bun", | ||
| 459 | label: "Local JS Runtime", | ||
| 460 | description: "Runs generated code in-process using Bun", | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | id: "cloudflare-worker-loader", | ||
| 464 | label: "Cloudflare Worker Loader", | ||
| 465 | description: "Runs generated code in a Cloudflare Worker", | ||
| 466 | } | ||
| 467 | ]; | ||
| 468 | }, | ||
| 469 | }); | ||
| 470 | |||
| 471 | export const getTaskInWorkspace = internalQuery({ | ||
| 472 | args: { taskId: v.string(), workspaceId: v.id("workspaces") }, | ||
| 473 | handler: async (ctx, args) => { | ||
| 474 | const doc = await getTaskDoc(ctx, args.taskId); | ||
| 475 | if (!doc || doc.workspaceId !== args.workspaceId) { | ||
| 476 | return null; | ||
| 477 | } | ||
| 478 | return mapTask(doc); | ||
| 479 | }, | ||
| 480 | }); | ||
| 481 | |||
| 482 | export const markTaskRunning = internalMutation({ | ||
| 483 | args: { taskId: v.string() }, | ||
| 484 | handler: async (ctx, args) => { | ||
| 485 | const doc = await getTaskDoc(ctx, args.taskId); | ||
| 486 | if (!doc || doc.status !== "queued") { | ||
| 487 | return null; | ||
| 488 | } | ||
| 489 | |||
| 490 | const now = Date.now(); | ||
| 491 | await ctx.db.patch(doc._id, { | ||
| 492 | status: "running", | ||
| 493 | startedAt: doc.startedAt ?? now, | ||
| 494 | updatedAt: now, | ||
| 495 | }); | ||
| 496 | |||
| 497 | const updated = await getTaskDoc(ctx, args.taskId); | ||
| 498 | return updated ? mapTask(updated) : null; | ||
| 499 | }, | ||
| 500 | }); | ||
| 501 | |||
| 502 | export const markTaskFinished = internalMutation({ | ||
| 503 | args: { | ||
| 504 | taskId: v.string(), | ||
| 505 | status: completedTaskStatusValidator, | ||
| 506 | result: v.optional(v.any()), | ||
| 507 | exitCode: v.optional(v.number()), | ||
| 508 | error: v.optional(v.string()), | ||
| 509 | }, | ||
| 510 | handler: async (ctx, args) => { | ||
| 511 | const doc = await getTaskDoc(ctx, args.taskId); | ||
| 512 | if (!doc) { | ||
| 513 | return null; | ||
| 514 | } | ||
| 515 | |||
| 516 | if (doc.status === "completed" || doc.status === "failed" || doc.status === "timed_out" || doc.status === "denied") { | ||
| 517 | return mapTask(doc); | ||
| 518 | } | ||
| 519 | |||
| 520 | const now = Date.now(); | ||
| 521 | await ctx.db.patch(doc._id, { | ||
| 522 | status: args.status, | ||
| 523 | result: args.result, | ||
| 524 | exitCode: args.exitCode, | ||
| 525 | error: args.error, | ||
| 526 | completedAt: now, | ||
| 527 | updatedAt: now, | ||
| 528 | }); | ||
| 529 | |||
| 530 | const updated = await getTaskDoc(ctx, args.taskId); | ||
| 531 | return updated ? mapTask(updated) : null; | ||
| 532 | }, | ||
| 533 | }); | ||
| 534 | |||
| 535 | export const createApproval = internalMutation({ | ||
| 536 | args: { | ||
| 537 | id: v.string(), | ||
| 538 | taskId: v.string(), | ||
| 539 | toolPath: v.string(), | ||
| 540 | input: v.optional(v.any()), | ||
| 541 | }, | ||
| 542 | handler: async (ctx, args) => { | ||
| 543 | const existing = await getApprovalDoc(ctx, args.id); | ||
| 544 | if (existing) { | ||
| 545 | throw new Error(`Approval already exists: ${args.id}`); | ||
| 546 | } | ||
| 547 | |||
| 548 | const task = await getTaskDoc(ctx, args.taskId); | ||
| 549 | if (!task) { | ||
| 550 | throw new Error(`Task not found for approval: ${args.taskId}`); | ||
| 551 | } | ||
| 552 | |||
| 553 | const now = Date.now(); | ||
| 554 | await ctx.db.insert("approvals", { | ||
| 555 | approvalId: args.id, | ||
| 556 | taskId: args.taskId, | ||
| 557 | workspaceId: task.workspaceId, | ||
| 558 | toolPath: args.toolPath, | ||
| 559 | input: args.input ?? {}, | ||
| 560 | status: "pending", | ||
| 561 | createdAt: now, | ||
| 562 | }); | ||
| 563 | |||
| 564 | const created = await getApprovalDoc(ctx, args.id); | ||
| 565 | if (!created) { | ||
| 566 | throw new Error(`Failed to fetch approval ${args.id}`); | ||
| 567 | } | ||
| 568 | return mapApproval(created); | ||
| 569 | }, | ||
| 570 | }); | ||
| 571 | |||
| 572 | export const getApproval = internalQuery({ | ||
| 573 | args: { approvalId: v.string() }, | ||
| 574 | handler: async (ctx, args) => { | ||
| 575 | const doc = await getApprovalDoc(ctx, args.approvalId); | ||
| 576 | return doc ? mapApproval(doc) : null; | ||
| 577 | }, | ||
| 578 | }); | ||
| 579 | |||
| 580 | export const listApprovals = internalQuery({ | ||
| 581 | args: { | ||
| 582 | workspaceId: v.id("workspaces"), | ||
| 583 | status: v.optional(approvalStatusValidator), | ||
| 584 | }, | ||
| 585 | handler: async (ctx, args) => { | ||
| 586 | if (args.status) { | ||
| 587 | const status = args.status; | ||
| 588 | const docs = await ctx.db | ||
| 589 | .query("approvals") | ||
| 590 | .withIndex("by_workspace_status_created", (q) => | ||
| 591 | q.eq("workspaceId", args.workspaceId).eq("status", status), | ||
| 592 | ) | ||
| 593 | .order("desc") | ||
| 594 | .collect(); | ||
| 595 | return docs.map(mapApproval); | ||
| 596 | } | ||
| 597 | |||
| 598 | const docs = await ctx.db | ||
| 599 | .query("approvals") | ||
| 600 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 601 | .order("desc") | ||
| 602 | .take(500); | ||
| 603 | return docs.map(mapApproval); | ||
| 604 | }, | ||
| 605 | }); | ||
| 606 | |||
| 607 | export const listPendingApprovals = internalQuery({ | ||
| 608 | args: { workspaceId: v.id("workspaces") }, | ||
| 609 | handler: async (ctx, args) => { | ||
| 610 | const docs = await ctx.db | ||
| 611 | .query("approvals") | ||
| 612 | .withIndex("by_workspace_status_created", (q) => | ||
| 613 | q.eq("workspaceId", args.workspaceId).eq("status", "pending"), | ||
| 614 | ) | ||
| 615 | .order("asc") | ||
| 616 | .collect(); | ||
| 617 | |||
| 618 | const tasks = await Promise.all(docs.map((approval) => getTaskDoc(ctx, approval.taskId))); | ||
| 619 | |||
| 620 | const results: Array< | ||
| 621 | ReturnType<typeof mapApproval> & { | ||
| 622 | task: { id: string; status: TaskStatus; runtimeId: string; timeoutMs: number; createdAt: number }; | ||
| 623 | } | ||
| 624 | > = []; | ||
| 625 | for (let i = 0; i < docs.length; i++) { | ||
| 626 | const approval = docs[i]!; | ||
| 627 | const task = tasks[i]; | ||
| 628 | if (!task) { | ||
| 629 | continue; | ||
| 630 | } | ||
| 631 | |||
| 632 | results.push({ | ||
| 633 | ...mapApproval(approval), | ||
| 634 | task: { | ||
| 635 | id: task.taskId, | ||
| 636 | status: task.status, | ||
| 637 | runtimeId: task.runtimeId, | ||
| 638 | timeoutMs: task.timeoutMs, | ||
| 639 | createdAt: task.createdAt, | ||
| 640 | }, | ||
| 641 | }); | ||
| 642 | } | ||
| 643 | |||
| 644 | return results; | ||
| 645 | }, | ||
| 646 | }); | ||
| 647 | |||
| 648 | export const resolveApproval = internalMutation({ | ||
| 649 | args: { | ||
| 650 | approvalId: v.string(), | ||
| 651 | decision: v.union(v.literal("approved"), v.literal("denied")), | ||
| 652 | reviewerId: v.optional(v.string()), | ||
| 653 | reason: v.optional(v.string()), | ||
| 654 | }, | ||
| 655 | handler: async (ctx, args) => { | ||
| 656 | const doc = await getApprovalDoc(ctx, args.approvalId); | ||
| 657 | if (!doc || doc.status !== "pending") { | ||
| 658 | return null; | ||
| 659 | } | ||
| 660 | |||
| 661 | const now = Date.now(); | ||
| 662 | await ctx.db.patch(doc._id, { | ||
| 663 | status: args.decision, | ||
| 664 | reason: args.reason, | ||
| 665 | reviewerId: args.reviewerId, | ||
| 666 | resolvedAt: now, | ||
| 667 | }); | ||
| 668 | |||
| 669 | const updated = await getApprovalDoc(ctx, args.approvalId); | ||
| 670 | return updated ? mapApproval(updated) : null; | ||
| 671 | }, | ||
| 672 | }); | ||
| 673 | |||
| 674 | export const getApprovalInWorkspace = internalQuery({ | ||
| 675 | args: { approvalId: v.string(), workspaceId: v.id("workspaces") }, | ||
| 676 | handler: async (ctx, args) => { | ||
| 677 | const doc = await getApprovalDoc(ctx, args.approvalId); | ||
| 678 | if (!doc || doc.workspaceId !== args.workspaceId) { | ||
| 679 | return null; | ||
| 680 | } | ||
| 681 | return mapApproval(doc); | ||
| 682 | }, | ||
| 683 | }); | ||
| 684 | |||
| 685 | export const upsertToolCallRequested = internalMutation({ | ||
| 686 | args: { | ||
| 687 | taskId: v.string(), | ||
| 688 | callId: v.string(), | ||
| 689 | workspaceId: v.id("workspaces"), | ||
| 690 | toolPath: v.string(), | ||
| 691 | }, | ||
| 692 | handler: async (ctx, args) => { | ||
| 693 | const existing = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 694 | if (existing) { | ||
| 695 | return mapToolCall(existing); | ||
| 696 | } | ||
| 697 | |||
| 698 | const now = Date.now(); | ||
| 699 | await ctx.db.insert("toolCalls", { | ||
| 700 | taskId: args.taskId, | ||
| 701 | callId: args.callId, | ||
| 702 | workspaceId: args.workspaceId, | ||
| 703 | toolPath: args.toolPath, | ||
| 704 | status: "requested", | ||
| 705 | createdAt: now, | ||
| 706 | updatedAt: now, | ||
| 707 | }); | ||
| 708 | |||
| 709 | const created = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 710 | if (!created) { | ||
| 711 | throw new Error(`Failed to create tool call ${args.taskId}/${args.callId}`); | ||
| 712 | } | ||
| 713 | return mapToolCall(created); | ||
| 714 | }, | ||
| 715 | }); | ||
| 716 | |||
| 717 | export const getToolCall = internalQuery({ | ||
| 718 | args: { | ||
| 719 | taskId: v.string(), | ||
| 720 | callId: v.string(), | ||
| 721 | }, | ||
| 722 | handler: async (ctx, args) => { | ||
| 723 | const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 724 | return doc ? mapToolCall(doc) : null; | ||
| 725 | }, | ||
| 726 | }); | ||
| 727 | |||
| 728 | export const setToolCallPendingApproval = internalMutation({ | ||
| 729 | args: { | ||
| 730 | taskId: v.string(), | ||
| 731 | callId: v.string(), | ||
| 732 | approvalId: v.string(), | ||
| 733 | }, | ||
| 734 | handler: async (ctx, args) => { | ||
| 735 | const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 736 | if (!doc) { | ||
| 737 | throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); | ||
| 738 | } | ||
| 739 | |||
| 740 | if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { | ||
| 741 | return mapToolCall(doc); | ||
| 742 | } | ||
| 743 | |||
| 744 | const now = Date.now(); | ||
| 745 | await ctx.db.patch(doc._id, { | ||
| 746 | status: "pending_approval", | ||
| 747 | approvalId: args.approvalId, | ||
| 748 | updatedAt: now, | ||
| 749 | }); | ||
| 750 | |||
| 751 | const updated = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 752 | if (!updated) { | ||
| 753 | throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); | ||
| 754 | } | ||
| 755 | return mapToolCall(updated); | ||
| 756 | }, | ||
| 757 | }); | ||
| 758 | |||
| 759 | export const finishToolCall = internalMutation({ | ||
| 760 | args: { | ||
| 761 | taskId: v.string(), | ||
| 762 | callId: v.string(), | ||
| 763 | status: terminalToolCallStatusValidator, | ||
| 764 | error: v.optional(v.string()), | ||
| 765 | }, | ||
| 766 | handler: async (ctx, args) => { | ||
| 767 | const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 768 | if (!doc) { | ||
| 769 | throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); | ||
| 770 | } | ||
| 771 | |||
| 772 | if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { | ||
| 773 | return mapToolCall(doc); | ||
| 774 | } | ||
| 775 | |||
| 776 | const now = Date.now(); | ||
| 777 | await ctx.db.patch(doc._id, { | ||
| 778 | status: args.status, | ||
| 779 | error: args.error, | ||
| 780 | updatedAt: now, | ||
| 781 | completedAt: now, | ||
| 782 | }); | ||
| 783 | |||
| 784 | const updated = await getToolCallDoc(ctx, args.taskId, args.callId); | ||
| 785 | if (!updated) { | ||
| 786 | throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); | ||
| 787 | } | ||
| 788 | return mapToolCall(updated); | ||
| 789 | }, | ||
| 790 | }); | ||
| 791 | |||
| 792 | export const listToolCalls = internalQuery({ | ||
| 793 | args: { | ||
| 794 | taskId: v.string(), | ||
| 795 | }, | ||
| 796 | handler: async (ctx, args) => { | ||
| 797 | const docs = await ctx.db | ||
| 798 | .query("toolCalls") | ||
| 799 | .withIndex("by_task_created", (q) => q.eq("taskId", args.taskId)) | ||
| 800 | .order("asc") | ||
| 801 | .collect(); | ||
| 802 | |||
| 803 | return docs.map(mapToolCall); | ||
| 804 | }, | ||
| 805 | }); | ||
| 806 | |||
| 807 | export const bootstrapAnonymousSession = internalMutation({ | ||
| 808 | args: { sessionId: v.optional(v.string()) }, | ||
| 809 | handler: async (ctx, args) => { | ||
| 810 | const now = Date.now(); | ||
| 811 | const requestedSessionId = args.sessionId?.trim() || ""; | ||
| 812 | const allowRequestedSessionId = requestedSessionId?.startsWith("mcp_") ?? false; | ||
| 813 | |||
| 814 | if (requestedSessionId) { | ||
| 815 | const sessionId = requestedSessionId; | ||
| 816 | const existing = await ctx.db | ||
| 817 | .query("anonymousSessions") | ||
| 818 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | ||
| 819 | .unique(); | ||
| 820 | if (existing) { | ||
| 821 | const identity = await ensureAnonymousIdentity(ctx, { | ||
| 822 | sessionId, | ||
| 823 | workspaceId: existing.workspaceId, | ||
| 824 | actorId: existing.actorId, | ||
| 825 | timestamp: now, | ||
| 826 | }); | ||
| 827 | |||
| 828 | await ctx.db.patch(existing._id, { | ||
| 829 | workspaceId: identity.workspaceId, | ||
| 830 | accountId: identity.accountId, | ||
| 831 | userId: identity.userId, | ||
| 832 | lastSeenAt: now, | ||
| 833 | }); | ||
| 834 | |||
| 835 | const refreshed = await ctx.db | ||
| 836 | .query("anonymousSessions") | ||
| 837 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | ||
| 838 | .unique(); | ||
| 839 | if (!refreshed) { | ||
| 840 | throw new Error("Failed to refresh anonymous session"); | ||
| 841 | } | ||
| 842 | return mapAnonymousContext(refreshed); | ||
| 843 | } | ||
| 844 | } | ||
| 845 | |||
| 846 | const generatedSessionId = allowRequestedSessionId | ||
| 847 | ? `mcp_${crypto.randomUUID()}` | ||
| 848 | : `anon_session_${crypto.randomUUID()}`; | ||
| 849 | const sessionId = allowRequestedSessionId | ||
| 850 | ? requestedSessionId as string | ||
| 851 | : generatedSessionId; | ||
| 852 | const actorId = `anon_${crypto.randomUUID()}`; | ||
| 853 | const clientId = "web"; | ||
| 854 | |||
| 855 | const identity = await ensureAnonymousIdentity(ctx, { | ||
| 856 | sessionId, | ||
| 857 | actorId, | ||
| 858 | timestamp: now, | ||
| 859 | }); | ||
| 860 | |||
| 861 | await ctx.db.insert("anonymousSessions", { | ||
| 862 | sessionId, | ||
| 863 | workspaceId: identity.workspaceId, | ||
| 864 | actorId, | ||
| 865 | clientId, | ||
| 866 | accountId: identity.accountId, | ||
| 867 | userId: identity.userId, | ||
| 868 | createdAt: now, | ||
| 869 | lastSeenAt: now, | ||
| 870 | }); | ||
| 871 | |||
| 872 | const created = await ctx.db | ||
| 873 | .query("anonymousSessions") | ||
| 874 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | ||
| 875 | .unique(); | ||
| 876 | if (!created) { | ||
| 877 | throw new Error("Failed to create anonymous session"); | ||
| 878 | } | ||
| 879 | |||
| 880 | return mapAnonymousContext(created); | ||
| 881 | }, | ||
| 882 | }); | ||
| 883 | |||
| 884 | export const upsertAccessPolicy = internalMutation({ | ||
| 885 | args: { | ||
| 886 | id: v.optional(v.string()), | ||
| 887 | workspaceId: v.id("workspaces"), | ||
| 888 | actorId: v.optional(v.string()), | ||
| 889 | clientId: v.optional(v.string()), | ||
| 890 | toolPathPattern: v.string(), | ||
| 891 | decision: policyDecisionValidator, | ||
| 892 | priority: v.optional(v.number()), | ||
| 893 | }, | ||
| 894 | handler: async (ctx, args) => { | ||
| 895 | const now = Date.now(); | ||
| 896 | const policyId = args.id ?? `policy_${crypto.randomUUID()}`; | ||
| 897 | const existing = await ctx.db | ||
| 898 | .query("accessPolicies") | ||
| 899 | .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) | ||
| 900 | .unique(); | ||
| 901 | |||
| 902 | if (existing) { | ||
| 903 | await ctx.db.patch(existing._id, { | ||
| 904 | workspaceId: args.workspaceId, | ||
| 905 | actorId: args.actorId?.trim() || undefined, | ||
| 906 | clientId: args.clientId?.trim() || undefined, | ||
| 907 | toolPathPattern: args.toolPathPattern, | ||
| 908 | decision: args.decision, | ||
| 909 | priority: args.priority ?? 100, | ||
| 910 | updatedAt: now, | ||
| 911 | }); | ||
| 912 | } else { | ||
| 913 | await ctx.db.insert("accessPolicies", { | ||
| 914 | policyId, | ||
| 915 | workspaceId: args.workspaceId, | ||
| 916 | actorId: args.actorId?.trim() || undefined, | ||
| 917 | clientId: args.clientId?.trim() || undefined, | ||
| 918 | toolPathPattern: args.toolPathPattern, | ||
| 919 | decision: args.decision, | ||
| 920 | priority: args.priority ?? 100, | ||
| 921 | createdAt: now, | ||
| 922 | updatedAt: now, | ||
| 923 | }); | ||
| 924 | } | ||
| 925 | |||
| 926 | const updated = await ctx.db | ||
| 927 | .query("accessPolicies") | ||
| 928 | .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) | ||
| 929 | .unique(); | ||
| 930 | if (!updated) { | ||
| 931 | throw new Error(`Failed to read policy ${policyId}`); | ||
| 932 | } | ||
| 933 | return mapPolicy(updated); | ||
| 934 | }, | ||
| 935 | }); | ||
| 936 | |||
| 937 | export const listAccessPolicies = internalQuery({ | ||
| 938 | args: { workspaceId: v.id("workspaces") }, | ||
| 939 | handler: async (ctx, args) => { | ||
| 940 | const docs = await ctx.db | ||
| 941 | .query("accessPolicies") | ||
| 942 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 943 | .collect(); | ||
| 944 | |||
| 945 | return docs | ||
| 946 | .sort((a, b) => { | ||
| 947 | if (a.priority !== b.priority) { | ||
| 948 | return b.priority - a.priority; | ||
| 949 | } | ||
| 950 | return a.createdAt - b.createdAt; | ||
| 951 | }) | ||
| 952 | .map(mapPolicy); | ||
| 953 | }, | ||
| 954 | }); | ||
| 955 | |||
| 956 | async function computeBoundAuthFingerprint( | ||
| 957 | ctx: Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">, | ||
| 958 | workspaceId: Doc<"workspaces">["_id"], | ||
| 959 | sourceKey: string, | ||
| 960 | ): Promise<string> { | ||
| 961 | const prefix = "source:"; | ||
| 962 | if (!sourceKey.startsWith(prefix)) { | ||
| 963 | return normalizeSourceAuthFingerprint({ type: "none" }); | ||
| 964 | } | ||
| 965 | |||
| 966 | const sourceId = sourceKey.slice(prefix.length).trim(); | ||
| 967 | if (!sourceId) { | ||
| 968 | return normalizeSourceAuthFingerprint({ type: "none" }); | ||
| 969 | } | ||
| 970 | |||
| 971 | const source = await ctx.db | ||
| 972 | .query("toolSources") | ||
| 973 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | ||
| 974 | .unique(); | ||
| 975 | |||
| 976 | if (!source || source.workspaceId !== workspaceId) { | ||
| 977 | return normalizeSourceAuthFingerprint({ type: "none" }); | ||
| 978 | } | ||
| 979 | |||
| 980 | return normalizeSourceAuthFingerprint(asRecord(source.config).auth); | ||
| 981 | } | ||
| 982 | |||
| 983 | export const upsertCredential = internalMutation({ | ||
| 984 | args: { | ||
| 985 | id: v.optional(v.string()), | ||
| 986 | workspaceId: v.id("workspaces"), | ||
| 987 | sourceKey: v.string(), | ||
| 988 | scope: credentialScopeValidator, | ||
| 989 | actorId: v.optional(v.string()), | ||
| 990 | provider: v.optional(credentialProviderValidator), | ||
| 991 | secretJson: v.any(), | ||
| 992 | overridesJson: v.optional(v.any()), | ||
| 993 | }, | ||
| 994 | handler: async (ctx, args) => { | ||
| 995 | const now = Date.now(); | ||
| 996 | const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : ""; | ||
| 997 | const submittedSecret = asRecord(args.secretJson); | ||
| 998 | const hasSubmittedSecret = Object.keys(submittedSecret).length > 0; | ||
| 999 | |||
| 1000 | const existing = await ctx.db | ||
| 1001 | .query("sourceCredentials") | ||
| 1002 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 1003 | q | ||
| 1004 | .eq("workspaceId", args.workspaceId) | ||
| 1005 | .eq("sourceKey", args.sourceKey) | ||
| 1006 | .eq("scope", args.scope) | ||
| 1007 | .eq("actorId", actorId), | ||
| 1008 | ) | ||
| 1009 | .unique(); | ||
| 1010 | |||
| 1011 | let requestedId = args.id?.trim() || ""; | ||
| 1012 | if (requestedId.startsWith("bind_")) { | ||
| 1013 | const binding = await ctx.db | ||
| 1014 | .query("sourceCredentials") | ||
| 1015 | .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId)) | ||
| 1016 | .unique(); | ||
| 1017 | if (binding && binding.workspaceId === args.workspaceId) { | ||
| 1018 | requestedId = binding.credentialId; | ||
| 1019 | } | ||
| 1020 | } | ||
| 1021 | |||
| 1022 | const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`; | ||
| 1023 | |||
| 1024 | const linkedRows = await ctx.db | ||
| 1025 | .query("sourceCredentials") | ||
| 1026 | .withIndex("by_workspace_credential", (q) => | ||
| 1027 | q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId), | ||
| 1028 | ) | ||
| 1029 | .collect(); | ||
| 1030 | const exemplar = linkedRows[0] ?? existing ?? null; | ||
| 1031 | |||
| 1032 | const provider = args.provider ?? exemplar?.provider ?? "local-convex"; | ||
| 1033 | const fallbackSecret = asRecord(exemplar?.secretJson); | ||
| 1034 | const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; | ||
| 1035 | if (Object.keys(finalSecret).length === 0) { | ||
| 1036 | throw new Error("Credential values are required"); | ||
| 1037 | } | ||
| 1038 | |||
| 1039 | const overridesJson = args.overridesJson === undefined | ||
| 1040 | ? asRecord(existing?.overridesJson) | ||
| 1041 | : asRecord(args.overridesJson); | ||
| 1042 | |||
| 1043 | const boundAuthFingerprint = await computeBoundAuthFingerprint(ctx, args.workspaceId, args.sourceKey); | ||
| 1044 | |||
| 1045 | if (linkedRows.length > 0 && (hasSubmittedSecret || args.provider)) { | ||
| 1046 | await Promise.all(linkedRows.map(async (row) => { | ||
| 1047 | await ctx.db.patch(row._id, { | ||
| 1048 | provider, | ||
| 1049 | secretJson: finalSecret, | ||
| 1050 | updatedAt: now, | ||
| 1051 | }); | ||
| 1052 | })); | ||
| 1053 | } | ||
| 1054 | |||
| 1055 | if (existing) { | ||
| 1056 | await ctx.db.patch(existing._id, { | ||
| 1057 | credentialId: connectionId, | ||
| 1058 | provider, | ||
| 1059 | secretJson: finalSecret, | ||
| 1060 | overridesJson, | ||
| 1061 | boundAuthFingerprint, | ||
| 1062 | updatedAt: now, | ||
| 1063 | }); | ||
| 1064 | } else { | ||
| 1065 | await ctx.db.insert("sourceCredentials", { | ||
| 1066 | bindingId: `bind_${crypto.randomUUID()}`, | ||
| 1067 | credentialId: connectionId, | ||
| 1068 | workspaceId: args.workspaceId, | ||
| 1069 | sourceKey: args.sourceKey, | ||
| 1070 | scope: args.scope, | ||
| 1071 | actorId, | ||
| 1072 | provider, | ||
| 1073 | secretJson: finalSecret, | ||
| 1074 | overridesJson, | ||
| 1075 | boundAuthFingerprint, | ||
| 1076 | createdAt: now, | ||
| 1077 | updatedAt: now, | ||
| 1078 | }); | ||
| 1079 | } | ||
| 1080 | |||
| 1081 | const updated = await ctx.db | ||
| 1082 | .query("sourceCredentials") | ||
| 1083 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 1084 | q | ||
| 1085 | .eq("workspaceId", args.workspaceId) | ||
| 1086 | .eq("sourceKey", args.sourceKey) | ||
| 1087 | .eq("scope", args.scope) | ||
| 1088 | .eq("actorId", actorId), | ||
| 1089 | ) | ||
| 1090 | .unique(); | ||
| 1091 | |||
| 1092 | if (!updated) { | ||
| 1093 | throw new Error("Failed to read upserted credential"); | ||
| 1094 | } | ||
| 1095 | |||
| 1096 | return mapCredential(updated); | ||
| 1097 | }, | ||
| 1098 | }); | ||
| 1099 | |||
| 1100 | export const listCredentials = internalQuery({ | ||
| 1101 | args: { workspaceId: v.id("workspaces") }, | ||
| 1102 | handler: async (ctx, args) => { | ||
| 1103 | const docs = await ctx.db | ||
| 1104 | .query("sourceCredentials") | ||
| 1105 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 1106 | .order("desc") | ||
| 1107 | .collect(); | ||
| 1108 | return docs.map(mapCredential); | ||
| 1109 | }, | ||
| 1110 | }); | ||
| 1111 | |||
| 1112 | export const listCredentialProviders = internalQuery({ | ||
| 1113 | args: {}, | ||
| 1114 | handler: async () => { | ||
| 1115 | const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim()); | ||
| 1116 | return [ | ||
| 1117 | { | ||
| 1118 | id: workosEnabled ? "workos-vault" : "local-convex", | ||
| 1119 | label: workosEnabled ? "Encrypted" : "Local", | ||
| 1120 | description: workosEnabled | ||
| 1121 | ? "Secrets are stored in WorkOS Vault." | ||
| 1122 | : "Secrets are stored locally in Convex on this machine.", | ||
| 1123 | }, | ||
| 1124 | ] as const; | ||
| 1125 | }, | ||
| 1126 | }); | ||
| 1127 | |||
| 1128 | export const resolveCredential = internalQuery({ | ||
| 1129 | args: { | ||
| 1130 | workspaceId: v.id("workspaces"), | ||
| 1131 | sourceKey: v.string(), | ||
| 1132 | scope: credentialScopeValidator, | ||
| 1133 | actorId: v.optional(v.string()), | ||
| 1134 | }, | ||
| 1135 | handler: async (ctx, args) => { | ||
| 1136 | if (args.scope === "actor") { | ||
| 1137 | const actorId = args.actorId?.trim() || ""; | ||
| 1138 | if (!actorId) { | ||
| 1139 | return null; | ||
| 1140 | } | ||
| 1141 | |||
| 1142 | const actorDoc = await ctx.db | ||
| 1143 | .query("sourceCredentials") | ||
| 1144 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 1145 | q | ||
| 1146 | .eq("workspaceId", args.workspaceId) | ||
| 1147 | .eq("sourceKey", args.sourceKey) | ||
| 1148 | .eq("scope", "actor") | ||
| 1149 | .eq("actorId", actorId), | ||
| 1150 | ) | ||
| 1151 | .unique(); | ||
| 1152 | |||
| 1153 | return actorDoc ? mapCredential(actorDoc) : null; | ||
| 1154 | } | ||
| 1155 | |||
| 1156 | const workspaceDoc = await ctx.db | ||
| 1157 | .query("sourceCredentials") | ||
| 1158 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 1159 | q | ||
| 1160 | .eq("workspaceId", args.workspaceId) | ||
| 1161 | .eq("sourceKey", args.sourceKey) | ||
| 1162 | .eq("scope", "workspace") | ||
| 1163 | .eq("actorId", ""), | ||
| 1164 | ) | ||
| 1165 | .unique(); | ||
| 1166 | |||
| 1167 | return workspaceDoc ? mapCredential(workspaceDoc) : null; | ||
| 1168 | }, | ||
| 1169 | }); | ||
| 1170 | |||
| 1171 | export const upsertToolSource = internalMutation({ | ||
| 1172 | args: { | ||
| 1173 | id: v.optional(v.string()), | ||
| 1174 | workspaceId: v.id("workspaces"), | ||
| 1175 | name: v.string(), | ||
| 1176 | type: toolSourceTypeValidator, | ||
| 1177 | config: v.any(), | ||
| 1178 | enabled: v.optional(v.boolean()), | ||
| 1179 | }, | ||
| 1180 | handler: async (ctx, args) => { | ||
| 1181 | const now = Date.now(); | ||
| 1182 | const sourceId = args.id ?? `src_${crypto.randomUUID()}`; | ||
| 1183 | const config = asRecord(args.config); | ||
| 1184 | const specHash = computeSourceSpecHash(args.type, config); | ||
| 1185 | const authFingerprint = normalizeSourceAuthFingerprint(config.auth); | ||
| 1186 | const [existing, conflict] = await Promise.all([ | ||
| 1187 | ctx.db | ||
| 1188 | .query("toolSources") | ||
| 1189 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | ||
| 1190 | .unique(), | ||
| 1191 | ctx.db | ||
| 1192 | .query("toolSources") | ||
| 1193 | .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name)) | ||
| 1194 | .unique(), | ||
| 1195 | ]); | ||
| 1196 | |||
| 1197 | if (conflict && conflict.sourceId !== sourceId) { | ||
| 1198 | throw new Error(`Tool source name '${args.name}' already exists in workspace ${args.workspaceId}`); | ||
| 1199 | } | ||
| 1200 | |||
| 1201 | if (existing) { | ||
| 1202 | await ctx.db.patch(existing._id, { | ||
| 1203 | workspaceId: args.workspaceId, | ||
| 1204 | name: args.name, | ||
| 1205 | type: args.type, | ||
| 1206 | config, | ||
| 1207 | specHash, | ||
| 1208 | authFingerprint, | ||
| 1209 | enabled: args.enabled !== false, | ||
| 1210 | updatedAt: now, | ||
| 1211 | }); | ||
| 1212 | } else { | ||
| 1213 | await ctx.db.insert("toolSources", { | ||
| 1214 | sourceId, | ||
| 1215 | workspaceId: args.workspaceId, | ||
| 1216 | name: args.name, | ||
| 1217 | type: args.type, | ||
| 1218 | config, | ||
| 1219 | specHash, | ||
| 1220 | authFingerprint, | ||
| 1221 | enabled: args.enabled !== false, | ||
| 1222 | createdAt: now, | ||
| 1223 | updatedAt: now, | ||
| 1224 | }); | ||
| 1225 | } | ||
| 1226 | |||
| 1227 | const updated = await ctx.db | ||
| 1228 | .query("toolSources") | ||
| 1229 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | ||
| 1230 | .unique(); | ||
| 1231 | if (!updated) { | ||
| 1232 | throw new Error(`Failed to read tool source ${sourceId}`); | ||
| 1233 | } | ||
| 1234 | return mapSource(updated); | ||
| 1235 | }, | ||
| 1236 | }); | ||
| 1237 | |||
| 1238 | export const listToolSources = internalQuery({ | ||
| 1239 | args: { workspaceId: v.id("workspaces") }, | ||
| 1240 | handler: async (ctx, args) => { | ||
| 1241 | const docs = await ctx.db | ||
| 1242 | .query("toolSources") | ||
| 1243 | .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 1244 | .order("desc") | ||
| 1245 | .collect(); | ||
| 1246 | return docs.map(mapSource); | ||
| 1247 | }, | ||
| 1248 | }); | ||
| 1249 | |||
| 1250 | export const deleteToolSource = internalMutation({ | ||
| 1251 | args: { workspaceId: v.id("workspaces"), sourceId: v.string() }, | ||
| 1252 | handler: async (ctx, args) => { | ||
| 1253 | const doc = await ctx.db | ||
| 1254 | .query("toolSources") | ||
| 1255 | .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId)) | ||
| 1256 | .unique(); | ||
| 1257 | |||
| 1258 | if (!doc || doc.workspaceId !== args.workspaceId) { | ||
| 1259 | return false; | ||
| 1260 | } | ||
| 1261 | |||
| 1262 | const sourceKey = `source:${args.sourceId}`; | ||
| 1263 | const bindings = await ctx.db | ||
| 1264 | .query("sourceCredentials") | ||
| 1265 | .withIndex("by_workspace_source_scope_actor", (q) => | ||
| 1266 | q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey), | ||
| 1267 | ) | ||
| 1268 | .collect(); | ||
| 1269 | |||
| 1270 | for (const binding of bindings) { | ||
| 1271 | await ctx.db.delete(binding._id); | ||
| 1272 | } | ||
| 1273 | |||
| 1274 | await ctx.db.delete(doc._id); | ||
| 1275 | return true; | ||
| 1276 | }, | ||
| 1277 | }); | ||
| 1278 | |||
| 1279 | export const createTaskEvent = internalMutation({ | ||
| 1280 | args: { | ||
| 1281 | taskId: v.string(), | ||
| 1282 | eventName: v.string(), | ||
| 1283 | type: v.string(), | ||
| 1284 | payload: v.any(), | ||
| 1285 | }, | ||
| 1286 | handler: async (ctx, args) => { | ||
| 1287 | const task = await getTaskDoc(ctx, args.taskId); | ||
| 1288 | if (!task) { | ||
| 1289 | throw new Error(`Task not found for event: ${args.taskId}`); | ||
| 1290 | } | ||
| 1291 | |||
| 1292 | const latest = await ctx.db | ||
| 1293 | .query("taskEvents") | ||
| 1294 | .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId)) | ||
| 1295 | .order("desc") | ||
| 1296 | .first(); | ||
| 1297 | |||
| 1298 | const sequence = latest ? latest.sequence + 1 : 1; | ||
| 1299 | const createdAt = Date.now(); | ||
| 1300 | |||
| 1301 | await ctx.db.insert("taskEvents", { | ||
| 1302 | sequence, | ||
| 1303 | taskId: args.taskId, | ||
| 1304 | eventName: args.eventName, | ||
| 1305 | type: args.type, | ||
| 1306 | payload: asRecord(args.payload), | ||
| 1307 | createdAt, | ||
| 1308 | }); | ||
| 1309 | |||
| 1310 | const created = await ctx.db | ||
| 1311 | .query("taskEvents") | ||
| 1312 | .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId).eq("sequence", sequence)) | ||
| 1313 | .unique(); | ||
| 1314 | |||
| 1315 | if (!created) { | ||
| 1316 | throw new Error("Failed to read inserted task event"); | ||
| 1317 | } | ||
| 1318 | |||
| 1319 | return mapTaskEvent(created); | ||
| 1320 | }, | ||
| 1321 | }); | ||
| 1322 | |||
| 1323 | export const listTaskEvents = internalQuery({ | ||
| 1324 | args: { taskId: v.string() }, | ||
| 1325 | handler: async (ctx, args) => { | ||
| 1326 | const docs = await ctx.db | ||
| 1327 | .query("taskEvents") | ||
| 1328 | .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId)) | ||
| 1329 | .order("asc") | ||
| 1330 | .collect(); | ||
| 1331 | |||
| 1332 | return docs.map(mapTaskEvent); | ||
| 1333 | }, | ||
| 1334 | }); | ||
| 1335 | |||
| 1336 | // ── Anonymous OAuth Signing Keys ───────────────────────────────────────────── | ||
| 1337 | |||
| 1338 | /** | ||
| 1339 | * Get the active anonymous OAuth signing key pair. | ||
| 1340 | * Returns null if no key has been generated yet. | ||
| 1341 | */ | ||
| 1342 | export const getActiveAnonymousOauthSigningKey = internalQuery({ | ||
| 1343 | args: {}, | ||
| 1344 | handler: async (ctx) => { | ||
| 1345 | const doc = await ctx.db | ||
| 1346 | .query("anonymousOauthSigningKeys") | ||
| 1347 | .withIndex("by_status", (q) => q.eq("status", "active")) | ||
| 1348 | .first(); | ||
| 1349 | |||
| 1350 | if (!doc) return null; | ||
| 1351 | |||
| 1352 | return { | ||
| 1353 | keyId: doc.keyId, | ||
| 1354 | algorithm: doc.algorithm, | ||
| 1355 | privateKeyJwk: doc.privateKeyJwk, | ||
| 1356 | publicKeyJwk: doc.publicKeyJwk, | ||
| 1357 | createdAt: doc.createdAt, | ||
| 1358 | }; | ||
| 1359 | }, | ||
| 1360 | }); | ||
| 1361 | |||
| 1362 | /** | ||
| 1363 | * Store a new anonymous OAuth signing key pair, rotating any previously active key. | ||
| 1364 | */ | ||
| 1365 | export const storeAnonymousOauthSigningKey = internalMutation({ | ||
| 1366 | args: { | ||
| 1367 | keyId: v.string(), | ||
| 1368 | algorithm: v.string(), | ||
| 1369 | privateKeyJwk: v.any(), | ||
| 1370 | publicKeyJwk: v.any(), | ||
| 1371 | }, | ||
| 1372 | handler: async (ctx, args) => { | ||
| 1373 | const now = Date.now(); | ||
| 1374 | |||
| 1375 | // Rotate any currently active keys | ||
| 1376 | const activeKeys = await ctx.db | ||
| 1377 | .query("anonymousOauthSigningKeys") | ||
| 1378 | .withIndex("by_status", (q) => q.eq("status", "active")) | ||
| 1379 | .collect(); | ||
| 1380 | |||
| 1381 | for (const key of activeKeys) { | ||
| 1382 | await ctx.db.patch(key._id, { status: "rotated", rotatedAt: now }); | ||
| 1383 | } | ||
| 1384 | |||
| 1385 | // Insert the new active key | ||
| 1386 | await ctx.db.insert("anonymousOauthSigningKeys", { | ||
| 1387 | keyId: args.keyId, | ||
| 1388 | algorithm: args.algorithm, | ||
| 1389 | privateKeyJwk: args.privateKeyJwk, | ||
| 1390 | publicKeyJwk: args.publicKeyJwk, | ||
| 1391 | status: "active", | ||
| 1392 | createdAt: now, | ||
| 1393 | }); | ||
| 1394 | }, | ||
| 1395 | }); | ||
| 1396 | |||
| 1397 | // ── Anonymous OAuth Client Registrations ──────────────────────────────────── | ||
| 1398 | |||
| 1399 | /** | ||
| 1400 | * Register a new anonymous OAuth client (RFC 7591 dynamic client registration). | ||
| 1401 | */ | ||
| 1402 | export const registerAnonymousOauthClient = internalMutation({ | ||
| 1403 | args: { | ||
| 1404 | clientId: v.string(), | ||
| 1405 | clientName: v.optional(v.string()), | ||
| 1406 | redirectUris: v.array(v.string()), | ||
| 1407 | }, | ||
| 1408 | handler: async (ctx, args) => { | ||
| 1409 | const now = Date.now(); | ||
| 1410 | await ctx.db.insert("anonymousOauthClients", { | ||
| 1411 | clientId: args.clientId, | ||
| 1412 | clientName: args.clientName, | ||
| 1413 | redirectUris: args.redirectUris, | ||
| 1414 | createdAt: now, | ||
| 1415 | }); | ||
| 1416 | |||
| 1417 | return { | ||
| 1418 | client_id: args.clientId, | ||
| 1419 | client_name: args.clientName, | ||
| 1420 | redirect_uris: args.redirectUris, | ||
| 1421 | created_at: now, | ||
| 1422 | }; | ||
| 1423 | }, | ||
| 1424 | }); | ||
| 1425 | |||
| 1426 | /** | ||
| 1427 | * Look up an anonymous OAuth client by client_id. | ||
| 1428 | */ | ||
| 1429 | export const getAnonymousOauthClient = internalQuery({ | ||
| 1430 | args: { clientId: v.string() }, | ||
| 1431 | handler: async (ctx, args) => { | ||
| 1432 | const doc = await ctx.db | ||
| 1433 | .query("anonymousOauthClients") | ||
| 1434 | .withIndex("by_client_id", (q) => q.eq("clientId", args.clientId)) | ||
| 1435 | .unique(); | ||
| 1436 | |||
| 1437 | if (!doc) return null; | ||
| 1438 | |||
| 1439 | return { | ||
| 1440 | client_id: doc.clientId, | ||
| 1441 | client_name: doc.clientName, | ||
| 1442 | redirect_uris: doc.redirectUris, | ||
| 1443 | created_at: doc.createdAt, | ||
| 1444 | }; | ||
| 1445 | }, | ||
| 1446 | }); | ||
| 1447 | |||
| 1448 | // ── Anonymous OAuth Authorization Codes ────────────────────────────────────── | ||
| 1449 | |||
| 1450 | export const storeAnonymousOauthAuthorizationCode = internalMutation({ | ||
| 1451 | args: { | ||
| 1452 | code: v.string(), | ||
| 1453 | clientId: v.string(), | ||
| 1454 | redirectUri: v.string(), | ||
| 1455 | codeChallenge: v.string(), | ||
| 1456 | codeChallengeMethod: v.string(), | ||
| 1457 | actorId: v.string(), | ||
| 1458 | tokenClaims: v.optional(v.any()), | ||
| 1459 | expiresAt: v.number(), | ||
| 1460 | createdAt: v.number(), | ||
| 1461 | }, | ||
| 1462 | handler: async (ctx, args) => { | ||
| 1463 | await ctx.db.insert("anonymousOauthCodes", { | ||
| 1464 | code: args.code, | ||
| 1465 | clientId: args.clientId, | ||
| 1466 | redirectUri: args.redirectUri, | ||
| 1467 | codeChallenge: args.codeChallenge, | ||
| 1468 | codeChallengeMethod: args.codeChallengeMethod, | ||
| 1469 | actorId: args.actorId, | ||
| 1470 | tokenClaims: args.tokenClaims, | ||
| 1471 | expiresAt: args.expiresAt, | ||
| 1472 | createdAt: args.createdAt, | ||
| 1473 | }); | ||
| 1474 | }, | ||
| 1475 | }); | ||
| 1476 | |||
| 1477 | export const consumeAnonymousOauthAuthorizationCode = internalMutation({ | ||
| 1478 | args: { | ||
| 1479 | code: v.string(), | ||
| 1480 | }, | ||
| 1481 | handler: async (ctx, args) => { | ||
| 1482 | const doc = await ctx.db | ||
| 1483 | .query("anonymousOauthCodes") | ||
| 1484 | .withIndex("by_code", (q) => q.eq("code", args.code)) | ||
| 1485 | .unique(); | ||
| 1486 | |||
| 1487 | if (!doc) { | ||
| 1488 | return null; | ||
| 1489 | } | ||
| 1490 | |||
| 1491 | await ctx.db.delete(doc._id); | ||
| 1492 | |||
| 1493 | return { | ||
| 1494 | code: doc.code, | ||
| 1495 | clientId: doc.clientId, | ||
| 1496 | redirectUri: doc.redirectUri, | ||
| 1497 | codeChallenge: doc.codeChallenge, | ||
| 1498 | codeChallengeMethod: doc.codeChallengeMethod, | ||
| 1499 | actorId: doc.actorId, | ||
| 1500 | tokenClaims: doc.tokenClaims, | ||
| 1501 | expiresAt: doc.expiresAt, | ||
| 1502 | createdAt: doc.createdAt, | ||
| 1503 | }; | ||
| 1504 | }, | ||
| 1505 | }); | ||
| 1506 | |||
| 1507 | export const purgeExpiredAnonymousOauthAuthorizationCodes = internalMutation({ | ||
| 1508 | args: { | ||
| 1509 | now: v.number(), | ||
| 1510 | }, | ||
| 1511 | handler: async (ctx, args) => { | ||
| 1512 | const expired = await ctx.db | ||
| 1513 | .query("anonymousOauthCodes") | ||
| 1514 | .withIndex("by_expires_at", (q) => q.lt("expiresAt", args.now)) | ||
| 1515 | .collect(); | ||
| 1516 | |||
| 1517 | for (const doc of expired) { | ||
| 1518 | await ctx.db.delete(doc._id); | ||
| 1519 | } | ||
| 1520 | |||
| 1521 | return { purged: expired.length }; | ||
| 1522 | }, | ||
| 1523 | }); | ||
| 1524 | |||
| 1525 | export const countAnonymousOauthAuthorizationCodes = internalQuery({ | ||
| 1526 | args: {}, | ||
| 1527 | handler: async (ctx) => { | ||
| 1528 | const docs = await ctx.db.query("anonymousOauthCodes").collect(); | ||
| 1529 | return { count: docs.length }; | ||
| 1530 | }, | ||
| 1531 | }); | ||
| @@ -7,28 +7,10 @@ import { workspaceMutation } from "../core/src/function-builders"; · 6 unmodified lines | |||
| 7 | import { actorIdForAccount } from "../core/src/identity"; | 7 | import { actorIdForAccount } from "../core/src/identity"; |
| 8 | import { isKnownRuntimeId } from "../core/src/runtimes/runtime-catalog"; | 8 | import { isKnownRuntimeId } from "../core/src/runtimes/runtime-catalog"; |
| 9 | import type { ApprovalRecord, TaskRecord } from "../core/src/types"; | 9 | import type { ApprovalRecord, TaskRecord } from "../core/src/types"; |
| 10 | 10 | import { isTerminalTaskStatus, taskTerminalEventType } from "./task-status"; | |
| 11 | const DEFAULT_TIMEOUT_MS = 300_000; | 11 | import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants"; |
| 12 | 12 | import { createTaskEvent } from "./task-events"; | |
| 13 | async function publishTaskEvent( | 13 | import { 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 | |||
| 25 | function terminalEventForStatus(status: "completed" | "failed" | "timed_out" | "denied"): | ||
| 26 | "task.completed" | "task.failed" | "task.timed_out" | "task.denied" { | ||
| 27 | if (status === "completed") return "task.completed"; | ||
| 28 | if (status === "timed_out") return "task.timed_out"; | ||
| 29 | if (status === "denied") return "task.denied"; | ||
| 30 | return "task.failed"; | ||
| 31 | } | ||
| 32 | 14 | ||
| 33 | async function createTaskRecord( | 15 | async 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, |
| @@ -1,16 +1,16 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../_generated/server"; | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | import { internal } from "../_generated/api"; | 4 | import { createTaskEvent, type TaskEventName } from "../task-events"; |
| 5 | 5 | ||
| 6 | export async function publishTaskEvent( | 6 | export 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, |
| @@ -14,8 +14,51 @@ import { runCodeWithAdapter } from "../../core/src/runtimes/runtime-core"; · 13 unmodified lines | |||
| 14 | import type { TaskRecord } from "../../core/src/types"; | 14 | import type { TaskRecord } from "../../core/src/types"; |
| 15 | import { describeError } from "../../core/src/utils"; | 15 | import { describeError } from "../../core/src/utils"; |
| 16 | import { publishTaskEvent } from "./events"; | 16 | import { publishTaskEvent } from "./events"; |
| 17 | import { taskTerminalEventType } from "../task-status"; | ||
| 18 | import { markTaskFinished } from "../task-finish"; | ||
| 17 | import { invokeTool } from "./tool_invocation"; | 19 | import { invokeTool } from "./tool_invocation"; |
| 18 | 20 | ||
| 21 | async function markTaskFailedAndPublish( | ||
| 22 | ctx: ActionCtx, | ||
| 23 | args: { taskId: string; error: string }, | ||
| 24 | ): Promise<void> { | ||
| 25 | const failed = await markTaskFinished(ctx, { | ||
| 26 | taskId: args.taskId, | ||
| 27 | status: "failed", | ||
| 28 | error: args.error, | ||
| 29 | }); | ||
| 30 | |||
| 31 | if (!failed) { | ||
| 32 | return; | ||
| 33 | } | ||
| 34 | |||
| 35 | await publishTaskEvent(ctx, args.taskId, "task", "task.failed", { | ||
| 36 | taskId: args.taskId, | ||
| 37 | status: failed.status, | ||
| 38 | error: failed.error, | ||
| 39 | ...(failed.completedAt ? { completedAt: failed.completedAt } : {}), | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | |||
| 43 | async function publishTerminalTaskResult( | ||
| 44 | ctx: ActionCtx, | ||
| 45 | args: { | ||
| 46 | taskId: string; | ||
| 47 | status: "completed" | "failed" | "timed_out" | "denied"; | ||
| 48 | finished: TaskRecord; | ||
| 49 | durationMs?: number; | ||
| 50 | }, | ||
| 51 | ): Promise<void> { | ||
| 52 | await publishTaskEvent(ctx, args.taskId, "task", taskTerminalEventType(args.status), { | ||
| 53 | taskId: args.taskId, | ||
| 54 | status: args.finished.status, | ||
| 55 | exitCode: args.finished.exitCode, | ||
| 56 | durationMs: args.durationMs, | ||
| 57 | error: args.finished.error, | ||
| 58 | completedAt: args.finished.completedAt, | ||
| 59 | }); | ||
| 60 | } | ||
| 61 | |||
| 19 | export async function runQueuedTask( | 62 | export 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 | } |
| @@ -0,0 +1,74 @@ | |||
| 1 | import type { ActionCtx } from "../_generated/server"; | ||
| 2 | import { internal } from "../_generated/api"; | ||
| 3 | import { resolveCredentialPayload } from "../../core/src/credential-providers"; | ||
| 4 | import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants"; | ||
| 5 | import type { CredentialScope, ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../core/src/types"; | ||
| 6 | import { asPayload } from "../../core/src/utils"; | ||
| 7 | |||
| 8 | export async function resolveCredentialHeaders( | ||
| 9 | ctx: ActionCtx, | ||
| 10 | spec: ToolCredentialSpec, | ||
| 11 | task: TaskRecord, | ||
| 12 | ): Promise<ResolvedToolCredential | null> { | ||
| 13 | const record = await ctx.runQuery(internal.database.resolveCredential, { | ||
| 14 | workspaceId: task.workspaceId, | ||
| 15 | sourceKey: spec.sourceKey, | ||
| 16 | scope: spec.mode as CredentialScope, | ||
| 17 | actorId: task.actorId, | ||
| 18 | }); | ||
| 19 | |||
| 20 | const source = record | ||
| 21 | ? await resolveCredentialPayload(record) | ||
| 22 | : spec.staticSecretJson ?? null; | ||
| 23 | if (!source) { | ||
| 24 | return null; | ||
| 25 | } | ||
| 26 | |||
| 27 | const headers: Record<string, string> = {}; | ||
| 28 | if (spec.authType === "bearer") { | ||
| 29 | const token = String((source as Record<string, unknown>).token ?? "").trim(); | ||
| 30 | if (token) headers.authorization = `Bearer ${token}`; | ||
| 31 | } else if (spec.authType === "apiKey") { | ||
| 32 | const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key"); | ||
| 33 | const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim(); | ||
| 34 | if (value) headers[headerName] = value; | ||
| 35 | } else if (spec.authType === "basic") { | ||
| 36 | const username = String((source as Record<string, unknown>).username ?? ""); | ||
| 37 | const password = String((source as Record<string, unknown>).password ?? ""); | ||
| 38 | if (username || password) { | ||
| 39 | const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); | ||
| 40 | headers.authorization = `Basic ${encoded}`; | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {}); | ||
| 45 | const overrideHeaders = asPayload(bindingOverrides.headers); | ||
| 46 | for (const [key, value] of Object.entries(overrideHeaders)) { | ||
| 47 | if (!key) continue; | ||
| 48 | headers[key] = String(value); | ||
| 49 | } | ||
| 50 | |||
| 51 | if (Object.keys(headers).length === 0) { | ||
| 52 | return null; | ||
| 53 | } | ||
| 54 | |||
| 55 | return { | ||
| 56 | sourceKey: spec.sourceKey, | ||
| 57 | mode: spec.mode, | ||
| 58 | headers, | ||
| 59 | }; | ||
| 60 | } | ||
| 61 | |||
| 62 | export function assertPersistedCallRunnable(persistedCall: ToolCallRecord, callId: string): void { | ||
| 63 | if (persistedCall.status === "completed") { | ||
| 64 | throw new Error(`Tool call ${callId} already completed; output is not retained`); | ||
| 65 | } | ||
| 66 | |||
| 67 | if (persistedCall.status === "failed") { | ||
| 68 | throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`); | ||
| 69 | } | ||
| 70 | |||
| 71 | if (persistedCall.status === "denied") { | ||
| 72 | throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`); | ||
| 73 | } | ||
| 74 | } | ||
| @@ -0,0 +1,90 @@ | |||
| 1 | "use node"; | ||
| 2 | |||
| 3 | import type { ActionCtx } from "../_generated/server"; | ||
| 4 | import { internal } from "../_generated/api"; | ||
| 5 | import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants"; | ||
| 6 | import type { TaskRecord } from "../../core/src/types"; | ||
| 7 | import { publishTaskEvent } from "./events"; | ||
| 8 | |||
| 9 | async function finishToolCall( | ||
| 10 | ctx: ActionCtx, | ||
| 11 | args: { | ||
| 12 | taskId: string; | ||
| 13 | callId: string; | ||
| 14 | status: "completed" | "failed" | "denied"; | ||
| 15 | error?: string; | ||
| 16 | }, | ||
| 17 | ): Promise<void> { | ||
| 18 | await ctx.runMutation(internal.database.finishToolCall, args); | ||
| 19 | } | ||
| 20 | |||
| 21 | export async function denyToolCall( | ||
| 22 | ctx: ActionCtx, | ||
| 23 | args: { | ||
| 24 | task: TaskRecord; | ||
| 25 | callId: string; | ||
| 26 | toolPath: string; | ||
| 27 | deniedMessage: string; | ||
| 28 | approvalId?: string; | ||
| 29 | reason?: string; | ||
| 30 | }, | ||
| 31 | ): Promise<never> { | ||
| 32 | await finishToolCall(ctx, { | ||
| 33 | taskId: args.task.id, | ||
| 34 | callId: args.callId, | ||
| 35 | status: "denied", | ||
| 36 | error: args.deniedMessage, | ||
| 37 | }); | ||
| 38 | await publishTaskEvent(ctx, args.task.id, "task", "tool.call.denied", { | ||
| 39 | taskId: args.task.id, | ||
| 40 | callId: args.callId, | ||
| 41 | toolPath: args.toolPath, | ||
| 42 | ...(args.approvalId ? { approvalId: args.approvalId } : {}), | ||
| 43 | ...(args.reason ? { reason: args.reason } : {}), | ||
| 44 | }); | ||
| 45 | throw new Error(`${APPROVAL_DENIED_PREFIX}${args.deniedMessage}`); | ||
| 46 | } | ||
| 47 | |||
| 48 | export async function completeToolCall( | ||
| 49 | ctx: ActionCtx, | ||
| 50 | args: { | ||
| 51 | taskId: string; | ||
| 52 | callId: string; | ||
| 53 | toolPath: string; | ||
| 54 | }, | ||
| 55 | ): Promise<void> { | ||
| 56 | await finishToolCall(ctx, { | ||
| 57 | taskId: args.taskId, | ||
| 58 | callId: args.callId, | ||
| 59 | status: "completed", | ||
| 60 | }); | ||
| 61 | await publishTaskEvent(ctx, args.taskId, "task", "tool.call.completed", { | ||
| 62 | taskId: args.taskId, | ||
| 63 | callId: args.callId, | ||
| 64 | toolPath: args.toolPath, | ||
| 65 | outputRedacted: true, | ||
| 66 | }); | ||
| 67 | } | ||
| 68 | |||
| 69 | export async function failToolCall( | ||
| 70 | ctx: ActionCtx, | ||
| 71 | args: { | ||
| 72 | taskId: string; | ||
| 73 | callId: string; | ||
| 74 | toolPath: string; | ||
| 75 | error: string; | ||
| 76 | }, | ||
| 77 | ): Promise<void> { | ||
| 78 | await finishToolCall(ctx, { | ||
| 79 | taskId: args.taskId, | ||
| 80 | callId: args.callId, | ||
| 81 | status: "failed", | ||
| 82 | error: args.error, | ||
| 83 | }); | ||
| 84 | await publishTaskEvent(ctx, args.taskId, "task", "tool.call.failed", { | ||
| 85 | taskId: args.taskId, | ||
| 86 | callId: args.callId, | ||
| 87 | toolPath: args.toolPath, | ||
| 88 | error: args.error, | ||
| 89 | }); | ||
| 90 | } | ||
| @@ -0,0 +1,133 @@ | |||
| 1 | import type { ActionCtx } from "../_generated/server"; | ||
| 2 | import { parseGraphqlOperationPaths } from "../../core/src/graphql-operation-paths"; | ||
| 3 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types"; | ||
| 4 | import { getDecisionForContext, getToolDecision } from "./policy"; | ||
| 5 | import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths"; | ||
| 6 | import { baseTools, getWorkspaceTools } from "./workspace_tools"; | ||
| 7 | |||
| 8 | export function getGraphqlDecision( | ||
| 9 | task: TaskRecord, | ||
| 10 | tool: ToolDefinition, | ||
| 11 | input: unknown, | ||
| 12 | workspaceTools: Map<string, ToolDefinition>, | ||
| 13 | policies: AccessPolicyRecord[], | ||
| 14 | ): { decision: PolicyDecision; effectivePaths: string[] } { | ||
| 15 | const sourceName = tool._graphqlSource!; | ||
| 16 | const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | ||
| 17 | const queryString = typeof payload.query === "string" ? payload.query : ""; | ||
| 18 | |||
| 19 | if (!queryString.trim()) { | ||
| 20 | return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | ||
| 21 | } | ||
| 22 | |||
| 23 | const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString); | ||
| 24 | if (fieldPaths.length === 0) { | ||
| 25 | return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | ||
| 26 | } | ||
| 27 | |||
| 28 | let worstDecision: PolicyDecision = "allow"; | ||
| 29 | |||
| 30 | for (const fieldPath of fieldPaths) { | ||
| 31 | const pseudoTool = workspaceTools.get(fieldPath); | ||
| 32 | const fieldDecision = pseudoTool | ||
| 33 | ? getDecisionForContext( | ||
| 34 | pseudoTool, | ||
| 35 | { | ||
| 36 | workspaceId: task.workspaceId, | ||
| 37 | actorId: task.actorId, | ||
| 38 | clientId: task.clientId, | ||
| 39 | }, | ||
| 40 | policies, | ||
| 41 | ) | ||
| 42 | : getDecisionForContext( | ||
| 43 | { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" }, | ||
| 44 | { | ||
| 45 | workspaceId: task.workspaceId, | ||
| 46 | actorId: task.actorId, | ||
| 47 | clientId: task.clientId, | ||
| 48 | }, | ||
| 49 | policies, | ||
| 50 | ); | ||
| 51 | |||
| 52 | if (fieldDecision === "deny") { | ||
| 53 | worstDecision = "deny"; | ||
| 54 | break; | ||
| 55 | } | ||
| 56 | if (fieldDecision === "require_approval") { | ||
| 57 | worstDecision = "require_approval"; | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | return { decision: worstDecision, effectivePaths: fieldPaths }; | ||
| 62 | } | ||
| 63 | |||
| 64 | function unknownToolErrorMessage(toolPath: string, availableTools: Map<string, ToolDefinition>): string { | ||
| 65 | const suggestions = suggestToolPaths(toolPath, availableTools); | ||
| 66 | const queryHint = toolPath | ||
| 67 | .split(".") | ||
| 68 | .filter(Boolean) | ||
| 69 | .join(" "); | ||
| 70 | const suggestionText = suggestions.length > 0 | ||
| 71 | ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}` | ||
| 72 | : ""; | ||
| 73 | const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: false, depth: 2, limit: 12 });`; | ||
| 74 | return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`; | ||
| 75 | } | ||
| 76 | |||
| 77 | export async function ensureWorkspaceTools( | ||
| 78 | ctx: ActionCtx, | ||
| 79 | task: TaskRecord, | ||
| 80 | workspaceTools?: Map<string, ToolDefinition>, | ||
| 81 | ): Promise<Map<string, ToolDefinition>> { | ||
| 82 | if (workspaceTools) { | ||
| 83 | return workspaceTools; | ||
| 84 | } | ||
| 85 | const result = await getWorkspaceTools(ctx, task.workspaceId); | ||
| 86 | return result.tools; | ||
| 87 | } | ||
| 88 | |||
| 89 | export async function resolveToolForCall( | ||
| 90 | ctx: ActionCtx, | ||
| 91 | task: TaskRecord, | ||
| 92 | toolPath: string, | ||
| 93 | ): Promise<{ | ||
| 94 | tool: ToolDefinition; | ||
| 95 | resolvedToolPath: string; | ||
| 96 | workspaceTools?: Map<string, ToolDefinition>; | ||
| 97 | }> { | ||
| 98 | let workspaceTools: Map<string, ToolDefinition> | undefined; | ||
| 99 | let resolvedToolPath = toolPath; | ||
| 100 | let tool = baseTools.get(toolPath); | ||
| 101 | if (!tool) { | ||
| 102 | workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools); | ||
| 103 | tool = workspaceTools.get(toolPath); | ||
| 104 | |||
| 105 | if (!tool) { | ||
| 106 | const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools); | ||
| 107 | if (aliasedPath) { | ||
| 108 | resolvedToolPath = aliasedPath; | ||
| 109 | tool = workspaceTools.get(aliasedPath); | ||
| 110 | } | ||
| 111 | } | ||
| 112 | } | ||
| 113 | |||
| 114 | if (!tool) { | ||
| 115 | const availableTools = workspaceTools ?? baseTools; | ||
| 116 | const healedPath = resolveClosestToolPath(toolPath, availableTools); | ||
| 117 | if (healedPath) { | ||
| 118 | resolvedToolPath = healedPath; | ||
| 119 | tool = availableTools.get(healedPath); | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | if (!tool) { | ||
| 124 | const availableTools = workspaceTools ?? baseTools; | ||
| 125 | throw new Error(unknownToolErrorMessage(toolPath, availableTools)); | ||
| 126 | } | ||
| 127 | |||
| 128 | return { | ||
| 129 | tool, | ||
| 130 | resolvedToolPath, | ||
| 131 | workspaceTools, | ||
| 132 | }; | ||
| 133 | } | ||
| @@ -2,147 +2,45 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../_generated/server"; | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | import { internal } from "../_generated/api"; | 4 | import { internal } from "../_generated/api"; |
| 5 | import { resolveCredentialPayload } from "../../core/src/credential-providers"; | 5 | import { APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants"; |
| 6 | import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants"; | ||
| 7 | import { parseGraphqlOperationPaths } from "../../core/src/tool-sources"; | ||
| 8 | import type { | 6 | import 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"; |
| 20 | import { asPayload, describeError } from "../../core/src/utils"; | 15 | import { asPayload, describeError } from "../../core/src/utils"; |
| 21 | import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy"; | 16 | import { getToolDecision, isToolAllowedForTask } from "./policy"; |
| 22 | import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths"; | 17 | import { baseTools } from "./workspace_tools"; |
| 23 | import { baseTools, getWorkspaceTools } from "./workspace_tools"; | ||
| 24 | import { publishTaskEvent } from "./events"; | 18 | import { publishTaskEvent } from "./events"; |
| 19 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; | ||
| 20 | import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials"; | ||
| 21 | import { ensureWorkspaceTools, getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; | ||
| 25 | 22 | ||
| 26 | function createApprovalId(): string { | 23 | function createApprovalId(): string { |
| 27 | return `approval_${crypto.randomUUID()}`; | 24 | return `approval_${crypto.randomUUID()}`; |
| 28 | } | 25 | } |
| 29 | 26 | ||
| 30 | async function resolveCredentialHeaders( | 27 | async 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 | |||
| 92 | function getGraphqlDecision( | ||
| 93 | task: TaskRecord, | ||
| 94 | tool: ToolDefinition, | ||
| 95 | input: unknown, | ||
| 96 | workspaceTools: Map<string, ToolDefinition>, | ||
| 97 | policies: AccessPolicyRecord[], | ||
| 98 | ): { decision: PolicyDecision; effectivePaths: string[] } { | ||
| 99 | const sourceName = tool._graphqlSource!; | ||
| 100 | const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | ||
| 101 | const queryString = typeof payload.query === "string" ? payload.query : ""; | ||
| 102 | |||
| 103 | if (!queryString.trim()) { | ||
| 104 | return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | ||
| 105 | } | ||
| 106 | |||
| 107 | const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString); | ||
| 108 | if (fieldPaths.length === 0) { | ||
| 109 | return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | ||
| 110 | } | ||
| 111 | |||
| 112 | let worstDecision: PolicyDecision = "allow"; | ||
| 113 | |||
| 114 | for (const fieldPath of fieldPaths) { | ||
| 115 | const pseudoTool = workspaceTools.get(fieldPath); | ||
| 116 | const fieldDecision = pseudoTool | ||
| 117 | ? getDecisionForContext( | ||
| 118 | pseudoTool, | ||
| 119 | { | ||
| 120 | workspaceId: task.workspaceId, | ||
| 121 | actorId: task.actorId, | ||
| 122 | clientId: task.clientId, | ||
| 123 | }, | ||
| 124 | policies, | ||
| 125 | ) | ||
| 126 | : getDecisionForContext( | ||
| 127 | { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" }, | ||
| 128 | { | ||
| 129 | workspaceId: task.workspaceId, | ||
| 130 | actorId: task.actorId, | ||
| 131 | clientId: task.clientId, | ||
| 132 | }, | ||
| 133 | policies, | ||
| 134 | ); | ||
| 135 | |||
| 136 | if (fieldDecision === "deny") { | ||
| 137 | worstDecision = "deny"; | ||
| 138 | break; | ||
| 139 | } | ||
| 140 | if (fieldDecision === "require_approval") { | ||
| 141 | worstDecision = "require_approval"; | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | return { decision: worstDecision, effectivePaths: fieldPaths }; | ||
| 146 | } | 44 | } |
| 147 | 45 | ||
| 148 | export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> { | 46 | export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> { |
| @@ -153,69 +51,17 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 4 unmodified lines | |||
| 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 | } |
| @@ -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"; |
| 11 | import type { | ||
| 12 | ExternalToolSourceConfig, | ||
| 13 | GraphqlToolSourceConfig, | ||
| 14 | McpToolSourceConfig, | ||
| 15 | OpenApiToolSourceConfig, | ||
| 16 | PreparedOpenApiSpec, | ||
| 17 | } from "../../core/src/tool-source-types"; | ||
| 16 | import type { ToolSourceRecord } from "../../core/src/types"; | 18 | import type { ToolSourceRecord } from "../../core/src/types"; |
| 17 | 19 | ||
| 18 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 20 | const 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. */ |
| 21 | const OPENAPI_CACHE_VERSION = "v15"; | 23 | const TOOL_SOURCE_CACHE_VERSION = "v15"; |
| 22 | 24 | ||
| 23 | export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { | 25 | export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 24 | const signatureVersion = "v15"; | ||
| 25 | const parts = sources | 26 | 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 | ||
| 31 | export function normalizeExternalToolSource(raw: { | 32 | export 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 | }); |
| @@ -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"; |
| 13 | import type { ExternalToolSourceConfig } from "../../core/src/tool-source-types"; | ||
| 14 | import { DEFAULT_TOOLS } from "../../core/src/tools"; | 14 | import { DEFAULT_TOOLS } from "../../core/src/tools"; |
| 15 | import type { | 15 | import 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 | ||
| 78 | function mergeToolsWithCatalog(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> { | ||
| 79 | const merged = new Map<string, ToolDefinition>(); | ||
| 80 | |||
| 81 | for (const tool of baseTools.values()) { | ||
| 82 | if (tool.path === "discover") continue; | ||
| 83 | merged.set(tool.path, tool); | ||
| 84 | } | ||
| 85 | |||
| 86 | for (const tool of externalTools) { | ||
| 87 | if (tool.path === "discover") continue; | ||
| 88 | merged.set(tool.path, tool); | ||
| 89 | } | ||
| 90 | |||
| 91 | const catalogTools = createCatalogTools([...merged.values()]); | ||
| 92 | for (const tool of catalogTools) { | ||
| 93 | merged.set(tool.path, tool); | ||
| 94 | } | ||
| 95 | |||
| 96 | const discover = createDiscoverTool([...merged.values()]); | ||
| 97 | merged.set(discover.path, discover); | ||
| 98 | return merged; | ||
| 99 | } | ||
| 100 | |||
| 78 | export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> { | 101 | export 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 { |
| @@ -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">; |
| @@ -0,0 +1,100 @@ | |||
| 1 | import { WorkOS } from "@workos-inc/node"; | ||
| 2 | |||
| 3 | const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null; | ||
| 4 | |||
| 5 | export const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY); | ||
| 6 | |||
| 7 | type WorkosInvitationResponse = { | ||
| 8 | id: string; | ||
| 9 | state: string; | ||
| 10 | expires_at?: string; | ||
| 11 | }; | ||
| 12 | |||
| 13 | function requireWorkosClient(): WorkOS { | ||
| 14 | if (!workosClient) { | ||
| 15 | throw new Error("WORKOS_API_KEY is required for WorkOS invite operations"); | ||
| 16 | } | ||
| 17 | return workosClient; | ||
| 18 | } | ||
| 19 | |||
| 20 | export async function sendWorkosInvitation(args: { | ||
| 21 | email: string; | ||
| 22 | workosOrgId: string; | ||
| 23 | inviterWorkosUserId: string; | ||
| 24 | expiresInDays?: number; | ||
| 25 | roleSlug?: string; | ||
| 26 | }): Promise<WorkosInvitationResponse> { | ||
| 27 | const workos = requireWorkosClient(); | ||
| 28 | const invitation = await workos.userManagement.sendInvitation({ | ||
| 29 | email: args.email, | ||
| 30 | organizationId: args.workosOrgId, | ||
| 31 | inviterUserId: args.inviterWorkosUserId, | ||
| 32 | expiresInDays: args.expiresInDays, | ||
| 33 | roleSlug: args.roleSlug, | ||
| 34 | }); | ||
| 35 | |||
| 36 | return { | ||
| 37 | id: invitation.id, | ||
63 more lines aren’t shown here.
This diff is large: its 121 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 292 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 75 lines aren’t shown here.
This diff is large: its 596 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 309 lines aren’t shown here.
This diff is large: its 290 lines aren’t shown here.
This diff is large: its 78 lines aren’t shown here.
This diff is large: its 97 lines aren’t shown here.
This diff is large: its 159 lines aren’t shown here.
This diff is large: its 179 lines aren’t shown here.
This diff is large: its 133 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 581 lines aren’t shown here.
This diff is large: its 52 lines aren’t shown here.
This diff is large: its 196 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 66 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 332 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 160 lines aren’t shown here.
This diff is large: its 170 lines aren’t shown here.
This diff is large: its 355 lines aren’t shown here.
This diff is large: its 187 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 109 lines aren’t shown here.
This diff is large: its 115 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 339 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 126 lines aren’t shown here.
This diff is large: its 111 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 131 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 152 lines aren’t shown here.
This diff is large: its 220 lines aren’t shown here.
This diff is large: its 76 lines aren’t shown here.
This diff is large: its 166 lines aren’t shown here.
This diff is large: its 209 lines aren’t shown here.
This diff is large: its 61 lines aren’t shown here.
This diff is large: its 2615 lines aren’t shown here.
This diff is large: its 225 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 157 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 909 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 158 lines aren’t shown here.
This diff is large: its 350 lines aren’t shown here.
This diff is large: its 5 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 83 lines aren’t shown here.
This diff is large: its 67 lines aren’t shown here.