Unified · Split
| @@ -2,4 +2,44 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 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 | 8 | "start": "bun with-env bun src/index.ts" |
| 9 | 9 | }, |
| 10 | 10 | "dependencies": { |
| 11 | − "@assistant/core": "workspace:*", | |
| 12 | 11 | "@assistant/server": "workspace:*", |
| 13 | 12 | "@elysiajs/eden": "latest", |
| 14 | − "@openassistant/reacord": "workspace:*", | |
| 15 | − "convex": "^1.31.7", | |
| 16 | − "discord.js": "^14.17.3", | |
| 17 | − "effect": "latest", | |
| 18 | − "react": "^19.0.0" | |
| 13 | + "discord.js": "^14.25.1" | |
| 19 | 14 | }, |
| 20 | 15 | "devDependencies": { |
| 21 | − "@types/bun": "latest", | |
| 22 | − "@types/react": "^19.0.0" | |
| 16 | + "@types/bun": "latest" | |
| 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 | 1 | import { |
| 2 | + ActionRowBuilder, | |
| 3 | + ButtonBuilder, | |
| 4 | + ButtonStyle, | |
| 5 | + ChannelType, | |
| 9 | 6 | Client, |
| 10 | 7 | GatewayIntentBits, |
| 8 | + ModalBuilder, | |
| 9 | + Partials, | |
| 11 | 10 | REST, |
| 12 | 11 | Routes, |
| 13 | 12 | SlashCommandBuilder, |
| 13 | + TextInputBuilder, | |
| 14 | + TextInputStyle, | |
| 15 | + type Message, | |
| 16 | + type RESTPostAPIChatInputApplicationCommandsJSONBody, | |
| 14 | 17 | } from "discord.js"; |
| 15 | −import { makeReacord } from "@openassistant/reacord"; | |
| 16 | −import { createClient } from "@assistant/server/client"; | |
| 17 | −import { ConvexReactClient } from "convex/react"; | |
| 18 | −import { handleAskCommand } from "./commands/ask"; | |
| 18 | +import { createClient, unwrap } from "@assistant/server/client"; | |
| 19 | + | |
| 20 | +type ChatIdentity = { | |
| 21 | + readonly platform: "discord"; | |
| 22 | + readonly userId: string; | |
| 23 | +}; | |
| 19 | 24 | |
| 20 | −// --------------------------------------------------------------------------- | |
| 21 | −// Config | |
| 22 | −// --------------------------------------------------------------------------- | |
| 25 | +const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${Bun.env.ASSISTANT_PORT ?? "3002"}`; | |
| 26 | +const DISCORD_TOKEN = Bun.env.DISCORD_TOKEN ?? Bun.env.DISCORD_BOT_TOKEN; | |
| 27 | +const DISCORD_GUILD_ID = Bun.env.DISCORD_GUILD_ID; | |
| 28 | +const COMMAND_PREFIX = Bun.env.ASSISTANT_COMMAND_PREFIX ?? "!"; | |
| 29 | +const EXECUTOR_WEB_URL = Bun.env.EXECUTOR_WEB_URL | |
| 30 | + ?? Bun.env.OPENASSISTANT_EXECUTOR_WEB_URL | |
| 31 | + ?? Bun.env.OPENASSISTANT_EXECUTOR_URL | |
| 32 | + ?? "http://localhost:3000"; | |
| 23 | 33 | |
| 24 | −const DISCORD_TOKEN = Bun.env.DISCORD_BOT_TOKEN; | |
| 25 | 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"}`; | |
| 30 | −const CONVEX_URL = Bun.env.CONVEX_URL; | |
| 31 | −if (!CONVEX_URL) { | |
| 32 | − throw new Error("CONVEX_URL is required. Set it in your environment."); | |
| 33 | −} | |
| 39 | +const api = createClient(SERVER_URL); | |
| 40 | +const subscribedChannels = new Set<string>(); | |
| 41 | + | |
| 42 | +const LINK_MODAL_ID = "assistant_link_workos_modal"; | |
| 43 | +const LINK_OPEN_MODAL_BUTTON_ID = "assistant_link_workos_open_modal"; | |
| 44 | +const LINK_FIELD_WORKSPACE = "workspace_id"; | |
| 45 | +const LINK_FIELD_TOKEN = "access_token"; | |
| 46 | +const LINK_FIELD_ACTOR = "actor_id"; | |
| 34 | 47 | |
| 35 | −// --------------------------------------------------------------------------- | |
| 36 | −// Clients | |
| 37 | −// --------------------------------------------------------------------------- | |
| 48 | +const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [ | |
| 49 | + new SlashCommandBuilder() | |
| 50 | + .setName("ask") | |
| 51 | + .setDescription("Ask the assistant in your linked context") | |
| 52 | + .addStringOption((option) => option | |
| 53 | + .setName("prompt") | |
| 54 | + .setDescription("What you want the assistant to do") | |
| 55 | + .setRequired(true)) | |
| 56 | + .toJSON(), | |
| 57 | + new SlashCommandBuilder() | |
| 58 | + .setName("whoami") | |
| 59 | + .setDescription("Show your current linked MCP context") | |
| 60 | + .toJSON(), | |
| 61 | + new SlashCommandBuilder() | |
| 62 | + .setName("link-workos") | |
| 63 | + .setDescription("Link this Discord user to a WorkOS MCP context") | |
| 64 | + .toJSON(), | |
| 65 | + new SlashCommandBuilder() | |
| 66 | + .setName("link-anon") | |
| 67 | + .setDescription("Link to an anonymous MCP session") | |
| 68 | + .addStringOption((option) => option | |
| 69 | + .setName("session_id") | |
| 70 | + .setDescription("Optional session id to reuse")) | |
| 71 | + .toJSON(), | |
| 72 | + new SlashCommandBuilder() | |
| 73 | + .setName("unlink") | |
| 74 | + .setDescription("Remove your saved MCP link") | |
| 75 | + .toJSON(), | |
| 76 | +]; | |
| 38 | 77 | |
| 39 | −const client = new Client({ | |
| 78 | +const bot = new Client({ | |
| 40 | 79 | intents: [ |
| 41 | 80 | GatewayIntentBits.Guilds, |
| 42 | 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 }); | |
| 47 | −const api = createClient(SERVER_URL); | |
| 48 | −const convex = new ConvexReactClient(CONVEX_URL, { | |
| 49 | − unsavedChangesWarning: false, | |
| 50 | −}); | |
| 88 | +function identityFromUser(userId: string): ChatIdentity { | |
| 89 | + return { | |
| 90 | + platform: "discord", | |
| 91 | + userId, | |
| 92 | + }; | |
| 93 | +} | |
| 51 | 94 | |
| 52 | −// --------------------------------------------------------------------------- | |
| 53 | −// Slash commands | |
| 54 | −// --------------------------------------------------------------------------- | |
| 95 | +function truncate(value: string, max = 3500): string { | |
| 96 | + if (value.length <= max) return value; | |
| 97 | + return `${value.slice(0, max)}...`; | |
| 98 | +} | |
| 55 | 99 | |
| 56 | −const commands = [ | |
| 57 | − new SlashCommandBuilder() | |
| 58 | − .setName("ask") | |
| 59 | − .setDescription("Ask the AI assistant to do something") | |
| 60 | − .addStringOption((opt) => | |
| 61 | − opt.setName("prompt").setDescription("What do you want the assistant to do?").setRequired(true), | |
| 62 | − ), | |
| 63 | − new SlashCommandBuilder() | |
| 64 | − .setName("clear") | |
| 65 | − .setDescription("Push chat history out of view"), | |
| 66 | −]; | |
| 100 | +function chunkMessage(text: string, max = 1800): string[] { | |
| 101 | + if (text.length <= max) return [text]; | |
| 102 | + const chunks: string[] = []; | |
| 103 | + let remaining = text; | |
| 104 | + while (remaining.length > max) { | |
| 105 | + const splitAt = remaining.lastIndexOf("\n", max); | |
| 106 | + const end = splitAt > max * 0.4 ? splitAt : max; | |
| 107 | + chunks.push(remaining.slice(0, end)); | |
| 108 | + remaining = remaining.slice(end).trimStart(); | |
| 109 | + } | |
| 110 | + if (remaining.length > 0) chunks.push(remaining); | |
| 111 | + return chunks; | |
| 112 | +} | |
| 113 | + | |
| 114 | +async function reply(message: Message<boolean>, text: string) { | |
| 115 | + const normalized = truncate(text, 12_000); | |
| 116 | + const chunks = chunkMessage(normalized); | |
| 117 | + for (const chunk of chunks) { | |
| 118 | + await message.reply(chunk); | |
| 119 | + } | |
| 120 | +} | |
| 121 | + | |
| 122 | +function commandParts(text: string): readonly string[] { | |
| 123 | + return text.trim().split(/\s+/).filter((part) => part.length > 0); | |
| 124 | +} | |
| 125 | + | |
| 126 | +function stripBotMention(text: string, botUserId: string): string { | |
| 127 | + return text | |
| 128 | + .replaceAll(`<@${botUserId}>`, "") | |
| 129 | + .replaceAll(`<@!${botUserId}>`, "") | |
| 130 | + .trim(); | |
| 131 | +} | |
| 132 | + | |
| 133 | +function isCommand(text: string): boolean { | |
| 134 | + return text.startsWith(COMMAND_PREFIX); | |
| 135 | +} | |
| 136 | + | |
| 137 | +function parseCommand(text: string): { command: string; args: readonly string[] } { | |
| 138 | + const withoutPrefix = text.slice(COMMAND_PREFIX.length).trim(); | |
| 139 | + const [command = "", ...args] = commandParts(withoutPrefix); | |
| 140 | + return { | |
| 141 | + command: command.toLowerCase(), | |
| 142 | + args, | |
| 143 | + }; | |
| 144 | +} | |
| 145 | + | |
| 146 | +async function getContext(identity: ChatIdentity) { | |
| 147 | + const data = await unwrap( | |
| 148 | + api.api.context.resolve.post({ | |
| 149 | + platform: identity.platform, | |
| 150 | + userId: identity.userId, | |
| 151 | + }), | |
| 152 | + ); | |
| 153 | + return data.context; | |
| 154 | +} | |
| 155 | + | |
| 156 | +async function linkWorkos(identity: ChatIdentity, workspaceId: string, accessToken: string, actorId?: string) { | |
| 157 | + const data = await unwrap( | |
| 158 | + api.api.context.link.post({ | |
| 159 | + platform: identity.platform, | |
| 160 | + userId: identity.userId, | |
| 161 | + provider: "workos", | |
| 162 | + workspaceId, | |
| 163 | + accessToken, | |
| 164 | + actorId, | |
| 165 | + }), | |
| 166 | + ); | |
| 167 | + return data.context; | |
| 168 | +} | |
| 169 | + | |
| 170 | +async function linkAnonymous(identity: ChatIdentity, sessionId?: string) { | |
| 171 | + const data = await unwrap( | |
| 172 | + api.api.context.link.post({ | |
| 173 | + platform: identity.platform, | |
| 174 | + userId: identity.userId, | |
| 175 | + provider: "anonymous", | |
| 176 | + sessionId, | |
| 177 | + }), | |
| 178 | + ); | |
| 179 | + return data.context; | |
| 180 | +} | |
| 181 | + | |
| 182 | +async function unlink(identity: ChatIdentity) { | |
| 183 | + const data = await unwrap( | |
| 184 | + api.api.context.unlink.post({ | |
| 185 | + platform: identity.platform, | |
| 186 | + userId: identity.userId, | |
| 187 | + }), | |
| 188 | + ); | |
| 189 | + return data.removed; | |
| 190 | +} | |
| 191 | + | |
| 192 | +async function runPrompt(identity: ChatIdentity, prompt: string) { | |
| 193 | + const data = await unwrap( | |
| 194 | + api.api.chat.run.post({ | |
| 195 | + platform: identity.platform, | |
| 196 | + userId: identity.userId, | |
| 197 | + prompt, | |
| 198 | + }), | |
| 199 | + ); | |
| 200 | + return data; | |
| 201 | +} | |
| 202 | + | |
| 203 | +function contextLabel(context: { | |
| 204 | + source: string; | |
| 205 | + workspaceId: string; | |
| 206 | + actorId?: string; | |
| 207 | + sessionId?: string; | |
| 208 | + hasAccessToken: boolean; | |
| 209 | +}) { | |
| 210 | + return [ | |
| 211 | + `source=${context.source}`, | |
| 212 | + `workspace=${context.workspaceId}`, | |
| 213 | + context.actorId ? `actor=${context.actorId}` : undefined, | |
| 214 | + context.sessionId ? `session=${context.sessionId}` : undefined, | |
| 215 | + context.hasAccessToken ? "auth=token" : "auth=session", | |
| 216 | + ] | |
| 217 | + .filter(Boolean) | |
| 218 | + .join(" | "); | |
| 219 | +} | |
| 220 | + | |
| 221 | +function createWorkosLinkModal() { | |
| 222 | + const modal = new ModalBuilder() | |
| 223 | + .setCustomId(LINK_MODAL_ID) | |
| 224 | + .setTitle("Link WorkOS MCP Context"); | |
| 225 | + | |
| 226 | + const workspaceInput = new TextInputBuilder() | |
| 227 | + .setCustomId(LINK_FIELD_WORKSPACE) | |
| 228 | + .setLabel("Workspace ID") | |
| 229 | + .setStyle(TextInputStyle.Short) | |
| 230 | + .setRequired(true) | |
| 231 | + .setPlaceholder("e.g. j57...workspace id"); | |
| 232 | + | |
| 233 | + const tokenInput = new TextInputBuilder() | |
| 234 | + .setCustomId(LINK_FIELD_TOKEN) | |
| 235 | + .setLabel("Access Token") | |
| 236 | + .setStyle(TextInputStyle.Paragraph) | |
| 237 | + .setRequired(true) | |
| 238 | + .setPlaceholder("Paste MCP bearer token"); | |
| 239 | + | |
| 240 | + const actorInput = new TextInputBuilder() | |
| 241 | + .setCustomId(LINK_FIELD_ACTOR) | |
| 242 | + .setLabel("Actor ID (optional)") | |
| 243 | + .setStyle(TextInputStyle.Short) | |
| 244 | + .setRequired(false) | |
| 245 | + .setPlaceholder("Optional override"); | |
| 246 | + | |
| 247 | + modal.addComponents( | |
| 248 | + new ActionRowBuilder<TextInputBuilder>().addComponents(workspaceInput), | |
| 249 | + new ActionRowBuilder<TextInputBuilder>().addComponents(tokenInput), | |
| 250 | + new ActionRowBuilder<TextInputBuilder>().addComponents(actorInput), | |
| 251 | + ); | |
| 252 | + | |
| 253 | + return modal; | |
| 254 | +} | |
| 67 | 255 | |
| 68 | −async function registerCommands() { | |
| 69 | − const rest = new REST().setToken(DISCORD_TOKEN!); | |
| 256 | +function buildExecutorLink(identity: ChatIdentity): string | null { | |
| 70 | 257 | try { |
| 71 | − console.log("Registering slash commands..."); | |
| 72 | − await rest.put(Routes.applicationCommands(client.user!.id), { | |
| 73 | − body: commands.map((c) => c.toJSON()), | |
| 74 | − }); | |
| 75 | − console.log("Slash commands registered."); | |
| 76 | − } catch (error) { | |
| 77 | − console.error("Failed to register slash commands:", error); | |
| 258 | + const url = new URL(EXECUTOR_WEB_URL); | |
| 259 | + url.searchParams.set("source", "discord-bot-link"); | |
| 260 | + url.searchParams.set("discord_user", identity.userId); | |
| 261 | + return url.toString(); | |
| 262 | + } catch { | |
| 263 | + return null; | |
| 78 | 264 | } |
| 79 | 265 | } |
| 80 | 266 | |
| 81 | −// --------------------------------------------------------------------------- | |
| 82 | −// Command handler | |
| 83 | −// --------------------------------------------------------------------------- | |
| 267 | +function buildLinkWorkosPanel(identity: ChatIdentity) { | |
| 268 | + const executorLink = buildExecutorLink(identity); | |
| 269 | + const row = new ActionRowBuilder<ButtonBuilder>(); | |
| 270 | + | |
| 271 | + if (executorLink) { | |
| 272 | + row.addComponents( | |
| 273 | + new ButtonBuilder() | |
| 274 | + .setLabel("Open Executor") | |
| 275 | + .setStyle(ButtonStyle.Link) | |
| 276 | + .setURL(executorLink), | |
| 277 | + ); | |
| 278 | + } | |
| 279 | + | |
| 280 | + row.addComponents( | |
| 281 | + new ButtonBuilder() | |
| 282 | + .setCustomId(LINK_OPEN_MODAL_BUTTON_ID) | |
| 283 | + .setLabel("Paste Workspace + Token") | |
| 284 | + .setStyle(ButtonStyle.Primary), | |
| 285 | + ); | |
| 286 | + | |
| 287 | + const instructions = [ | |
| 288 | + "Link your WorkOS account in 2 quick steps:", | |
| 289 | + "1) Open Executor and sign in. Find your MCP workspace/token there.", | |
| 290 | + "2) Click 'Paste Workspace + Token' and submit the private modal.", | |
| 291 | + executorLink ? `Executor: ${executorLink}` : "Set EXECUTOR_WEB_URL to include a direct Executor link.", | |
| 292 | + ].join("\n"); | |
| 293 | + | |
| 294 | + return { | |
| 295 | + content: instructions, | |
| 296 | + components: [row], | |
| 297 | + }; | |
| 298 | +} | |
| 299 | + | |
| 300 | +async function registerSlashCommands() { | |
| 301 | + const appId = bot.user?.id; | |
| 302 | + if (!appId) return; | |
| 303 | + | |
| 304 | + const rest = new REST({ version: "10" }).setToken(BOT_TOKEN); | |
| 84 | 305 | |
| 85 | −client.on("interactionCreate", async (interaction) => { | |
| 86 | − if (!interaction.isChatInputCommand()) return; | |
| 306 | + if (DISCORD_GUILD_ID) { | |
| 307 | + await rest.put( | |
| 308 | + Routes.applicationGuildCommands(appId, DISCORD_GUILD_ID), | |
| 309 | + { body: slashCommands }, | |
| 310 | + ); | |
| 311 | + console.log(`[assistant-bot] registered guild slash commands in ${DISCORD_GUILD_ID}`); | |
| 312 | + return; | |
| 313 | + } | |
| 87 | 314 | |
| 88 | − switch (interaction.commandName) { | |
| 89 | − case "ask": | |
| 90 | − await handleAskCommand(interaction, { api, convex, reacord }); | |
| 91 | − break; | |
| 92 | − case "clear": | |
| 93 | − await interaction.reply({ content: `_${"\n".repeat(50)}_` }); | |
| 94 | − break; | |
| 95 | − default: | |
| 96 | − await interaction.reply({ content: `Unknown command: ${interaction.commandName}` }); | |
| 315 | + await rest.put( | |
| 316 | + Routes.applicationCommands(appId), | |
| 317 | + { body: slashCommands }, | |
| 318 | + ); | |
| 319 | + console.log("[assistant-bot] registered global slash commands"); | |
| 320 | +} | |
| 321 | + | |
| 322 | +async function handlePrefixCommand(message: Message<boolean>, rawText: string) { | |
| 323 | + const { command, args } = parseCommand(rawText); | |
| 324 | + const identity = identityFromUser(message.author.id); | |
| 325 | + | |
| 326 | + if (!command || command === "help") { | |
| 327 | + await reply(message, [ | |
| 328 | + "Assistant commands:", | |
| 329 | + "- /ask <prompt>", | |
| 330 | + "- /whoami", | |
| 331 | + "- /link-workos (opens secure modal)", | |
| 332 | + "- /link-anon [session_id]", | |
| 333 | + "- /unlink", | |
| 334 | + `Prefix fallback: ${COMMAND_PREFIX}whoami, ${COMMAND_PREFIX}link-anon [sessionId], ${COMMAND_PREFIX}unlink`, | |
| 335 | + ].join("\n")); | |
| 336 | + return; | |
| 337 | + } | |
| 338 | + | |
| 339 | + if (command === "whoami") { | |
| 340 | + const context = await getContext(identity); | |
| 341 | + await reply(message, `Current MCP context: ${contextLabel(context)}`); | |
| 342 | + return; | |
| 343 | + } | |
| 344 | + | |
| 345 | + if (command === "unlink") { | |
| 346 | + const removed = await unlink(identity); | |
| 347 | + await reply(message, removed ? "Link removed. You are back to anonymous context." : "No saved link found."); | |
| 348 | + return; | |
| 349 | + } | |
| 350 | + | |
| 351 | + if (command === "link-anon") { | |
| 352 | + const [sessionId] = args; | |
| 353 | + const context = await linkAnonymous(identity, sessionId); | |
| 354 | + await reply(message, `Linked anonymous context: ${contextLabel(context)}`); | |
| 355 | + return; | |
| 356 | + } | |
| 357 | + | |
| 358 | + if (command === "link-workos") { | |
| 359 | + const panel = buildLinkWorkosPanel(identity); | |
| 360 | + await reply(message, `${panel.content}\nUse /link-workos to open the button panel in an ephemeral response.`); | |
| 361 | + return; | |
| 362 | + } | |
| 363 | + | |
| 364 | + await reply(message, `Unknown command '${command}'. Run /help or ${COMMAND_PREFIX}help.`); | |
| 365 | +} | |
| 366 | + | |
| 367 | +bot.once("clientReady", async () => { | |
| 368 | + console.log(`[assistant-bot] discord bot online as ${bot.user?.tag ?? "unknown"} (server: ${SERVER_URL})`); | |
| 369 | + try { | |
| 370 | + await registerSlashCommands(); | |
| 371 | + } catch (error) { | |
| 372 | + console.error("[assistant-bot] failed to register slash commands", error); | |
| 97 | 373 | } |
| 98 | 374 | }); |
| 99 | 375 | |
| 100 | −// --------------------------------------------------------------------------- | |
| 101 | −// Start | |
| 102 | −// --------------------------------------------------------------------------- | |
| 376 | +bot.on("interactionCreate", async (interaction) => { | |
| 377 | + try { | |
| 378 | + if (interaction.isChatInputCommand()) { | |
| 379 | + const identity = identityFromUser(interaction.user.id); | |
| 380 | + | |
| 381 | + if (interaction.commandName === "ask") { | |
| 382 | + const prompt = interaction.options.getString("prompt", true); | |
| 383 | + await interaction.deferReply(); | |
| 384 | + const result = await runPrompt(identity, prompt); | |
| 385 | + const chunks = chunkMessage(result.text || "Done."); | |
| 386 | + await interaction.editReply(chunks[0] ?? "Done."); | |
| 387 | + for (let i = 1; i < chunks.length; i += 1) { | |
| 388 | + await interaction.followUp(chunks[i]!); | |
| 389 | + } | |
| 390 | + return; | |
| 391 | + } | |
| 392 | + | |
| 393 | + if (interaction.commandName === "whoami") { | |
| 394 | + const context = await getContext(identity); | |
| 395 | + await interaction.reply({ | |
| 396 | + content: `Current MCP context: ${contextLabel(context)}`, | |
| 397 | + ephemeral: true, | |
| 398 | + }); | |
| 399 | + return; | |
| 400 | + } | |
| 401 | + | |
| 402 | + if (interaction.commandName === "unlink") { | |
| 403 | + const removed = await unlink(identity); | |
| 404 | + await interaction.reply({ | |
| 405 | + content: removed ? "Link removed. You are back to anonymous context." : "No saved link found.", | |
| 406 | + ephemeral: true, | |
| 407 | + }); | |
| 408 | + return; | |
| 409 | + } | |
| 410 | + | |
| 411 | + if (interaction.commandName === "link-anon") { | |
| 412 | + const sessionId = interaction.options.getString("session_id") ?? undefined; | |
| 413 | + const context = await linkAnonymous(identity, sessionId); | |
| 414 | + await interaction.reply({ | |
| 415 | + content: `Linked anonymous context: ${contextLabel(context)}`, | |
| 416 | + ephemeral: true, | |
| 417 | + }); | |
| 418 | + return; | |
| 419 | + } | |
| 420 | + | |
| 421 | + if (interaction.commandName === "link-workos") { | |
| 422 | + const panel = buildLinkWorkosPanel(identity); | |
| 423 | + await interaction.reply({ | |
| 424 | + content: panel.content, | |
| 425 | + components: panel.components, | |
| 426 | + ephemeral: true, | |
| 427 | + }); | |
| 428 | + return; | |
| 429 | + } | |
| 430 | + | |
| 431 | + return; | |
| 432 | + } | |
| 433 | + | |
| 434 | + if (interaction.isModalSubmit() && interaction.customId === LINK_MODAL_ID) { | |
| 435 | + const identity = identityFromUser(interaction.user.id); | |
| 436 | + const workspaceId = interaction.fields.getTextInputValue(LINK_FIELD_WORKSPACE).trim(); | |
| 437 | + const accessToken = interaction.fields.getTextInputValue(LINK_FIELD_TOKEN).trim(); | |
| 438 | + const actorRaw = interaction.fields.getTextInputValue(LINK_FIELD_ACTOR).trim(); | |
| 439 | + const actorId = actorRaw.length > 0 ? actorRaw : undefined; | |
| 440 | + | |
| 441 | + if (!workspaceId || !accessToken) { | |
| 442 | + await interaction.reply({ | |
| 443 | + content: "Workspace ID and access token are required.", | |
| 444 | + ephemeral: true, | |
| 445 | + }); | |
| 446 | + return; | |
| 447 | + } | |
| 448 | + | |
| 449 | + await interaction.deferReply({ ephemeral: true }); | |
| 450 | + const context = await linkWorkos(identity, workspaceId, accessToken, actorId); | |
| 451 | + await interaction.editReply(`Linked WorkOS context: ${contextLabel(context)}`); | |
| 452 | + return; | |
| 453 | + } | |
| 454 | + | |
| 455 | + if (interaction.isButton() && interaction.customId === LINK_OPEN_MODAL_BUTTON_ID) { | |
| 456 | + await interaction.showModal(createWorkosLinkModal()); | |
| 457 | + return; | |
| 458 | + } | |
| 459 | + } catch (error) { | |
| 460 | + const text = `Warning: ${truncate(error instanceof Error ? error.message : String(error), 1200)}`; | |
| 461 | + | |
| 462 | + if (interaction.isRepliable()) { | |
| 463 | + if (interaction.replied || interaction.deferred) { | |
| 464 | + await interaction.followUp({ content: text, ephemeral: true }).catch(() => {}); | |
| 465 | + } else { | |
| 466 | + await interaction.reply({ content: text, ephemeral: true }).catch(() => {}); | |
| 467 | + } | |
| 468 | + } | |
| 469 | + } | |
| 470 | +}); | |
| 471 | + | |
| 472 | +bot.on("messageCreate", async (message) => { | |
| 473 | + if (!message.inGuild() && message.channel.type !== ChannelType.DM) return; | |
| 474 | + if (message.author.bot) return; | |
| 475 | + | |
| 476 | + const botUserId = bot.user?.id; | |
| 477 | + if (!botUserId) return; | |
| 103 | 478 | |
| 104 | −client.once("clientReady", async () => { | |
| 105 | − console.log(`Logged in as ${client.user?.tag}`); | |
| 106 | − await registerCommands(); | |
| 107 | − console.log(`Connected to server at ${SERVER_URL}`); | |
| 108 | − console.log(`Convex at ${CONVEX_URL}`); | |
| 479 | + const rawText = message.content?.trim() ?? ""; | |
| 480 | + if (!rawText && !message.mentions.users.has(botUserId)) return; | |
| 481 | + | |
| 482 | + const mentioned = message.mentions.users.has(botUserId); | |
| 483 | + const subscribed = subscribedChannels.has(message.channelId); | |
| 484 | + const command = isCommand(rawText); | |
| 485 | + | |
| 486 | + if (!mentioned && !subscribed && !command && message.channel.type !== ChannelType.DM) { | |
| 487 | + return; | |
| 488 | + } | |
| 489 | + | |
| 490 | + try { | |
| 491 | + if (command) { | |
| 492 | + await handlePrefixCommand(message, rawText); | |
| 493 | + return; | |
| 494 | + } | |
| 495 | + | |
| 496 | + let prompt = rawText; | |
| 497 | + if (mentioned) { | |
| 498 | + subscribedChannels.add(message.channelId); | |
| 499 | + prompt = stripBotMention(rawText, botUserId); | |
| 500 | + if (!prompt) { | |
| 501 | + await reply(message, "I am listening. Send a prompt here, or use `/ask`."); | |
| 502 | + return; | |
| 503 | + } | |
| 504 | + } | |
| 505 | + | |
| 506 | + await message.channel.sendTyping(); | |
| 507 | + const result = await runPrompt(identityFromUser(message.author.id), prompt); | |
| 508 | + await reply(message, result.text || "Done."); | |
| 509 | + } catch (error) { | |
| 510 | + const messageText = error instanceof Error ? error.message : String(error); | |
| 511 | + await reply(message, `Warning: ${truncate(messageText, 1400)}`); | |
| 512 | + } | |
| 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 | 77 | export interface AgentOptions { |
| 78 | 78 | readonly executorUrl: string; |
| 79 | 79 | readonly workspaceId: string; |
| 80 | − readonly actorId: string; | |
| 80 | + readonly actorId?: string; | |
| 81 | 81 | readonly clientId?: string; |
| 82 | + readonly sessionId?: string; | |
| 83 | + readonly mcpAccessToken?: string; | |
| 82 | 84 | readonly apiKey?: string; |
| 83 | 85 | readonly modelId?: string; |
| 84 | 86 | readonly context?: string; |
| @@ -152,6 +154,8 @@ export function createAgent(options: AgentOptions) { · 67 unmodified lines | ||
| 152 | 154 | workspaceId, |
| 153 | 155 | actorId, |
| 154 | 156 | clientId, |
| 157 | + sessionId, | |
| 158 | + mcpAccessToken, | |
| 155 | 159 | apiKey, |
| 156 | 160 | modelId = "claude-sonnet-4-5", |
| 157 | 161 | context, |
| @@ -192,10 +196,22 @@ export function createAgent(options: AgentOptions) { · 34 unmodified lines | ||
| 192 | 196 | emit({ type: "status", message: "Connecting..." }); |
| 193 | 197 | const mcpUrl = new URL(`${executorBaseUrl}/mcp`); |
| 194 | 198 | mcpUrl.searchParams.set("workspaceId", workspaceId); |
| 195 | − mcpUrl.searchParams.set("actorId", actorId); | |
| 199 | + if (actorId) mcpUrl.searchParams.set("actorId", actorId); | |
| 196 | 200 | if (clientId) mcpUrl.searchParams.set("clientId", clientId); |
| 197 | − | |
| 198 | − const transport = new StreamableHTTPClientTransport(mcpUrl); | |
| 201 | + if (sessionId) mcpUrl.searchParams.set("sessionId", sessionId); | |
| 202 | + | |
| 203 | + const transport = mcpAccessToken | |
| 204 | + ? new StreamableHTTPClientTransport(mcpUrl, { | |
| 205 | + fetch: async (input, init) => { | |
| 206 | + const headers = new Headers(init?.headers); | |
| 207 | + headers.set("authorization", `Bearer ${mcpAccessToken}`); | |
| 208 | + return await fetch(input, { | |
| 209 | + ...init, | |
| 210 | + headers, | |
| 211 | + }); | |
| 212 | + }, | |
| 213 | + }) | |
| 214 | + : new StreamableHTTPClientTransport(mcpUrl); | |
| 199 | 215 | const mcp = new Client({ name: "assistant-agent", version: "0.1.0" }); |
| 200 | 216 | |
| 201 | 217 | let mcpConnected = false; |
| @@ -1,81 +1,36 @@ | ||
| 1 | −/** | |
| 2 | − * Assistant Server entry point. | |
| 3 | − */ | |
| 4 | − | |
| 5 | 1 | import { createApp } from "./routes"; |
| 6 | −import { ConvexHttpClient } from "convex/browser"; | |
| 7 | −import { api } from "@executor/convex/_generated/api"; | |
| 8 | −import type { Id } from "@executor/convex/_generated/dataModel"; | |
| 2 | +import { defaultLinksFilePath } from "./link-store"; | |
| 9 | 3 | |
| 10 | 4 | const PORT = Number(Bun.env.ASSISTANT_PORT ?? Bun.env.PORT ?? 3002); |
| 11 | 5 | const CONVEX_URL = Bun.env.CONVEX_URL; |
| 12 | 6 | if (!CONVEX_URL) { |
| 13 | 7 | throw new Error("CONVEX_URL is required. Set it in your environment."); |
| 14 | 8 | } |
| 9 | + | |
| 15 | 10 | const EXECUTOR_URL = Bun.env.EXECUTOR_URL |
| 16 | 11 | ?? Bun.env.CONVEX_SITE_URL |
| 17 | 12 | ?? (CONVEX_URL.includes(".convex.cloud") |
| 18 | 13 | ? CONVEX_URL.replace(".convex.cloud", ".convex.site") |
| 19 | 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}`); | |
| 61 | −if (ANON_SESSION_ID) { | |
| 62 | − console.log(`[assistant] executor anon session: ${ANON_SESSION_ID}`); | |
| 16 | +const contextLines: string[] = []; | |
| 17 | +if (Bun.env.POSTHOG_PROJECT_ID) { | |
| 18 | + contextLines.push(`- PostHog project ID: ${Bun.env.POSTHOG_PROJECT_ID}`); | |
| 63 | 19 | } |
| 64 | 20 | |
| 65 | −const contextLines: string[] = []; | |
| 66 | −if (Bun.env.POSTHOG_PROJECT_ID) contextLines.push(`- PostHog project ID: ${Bun.env.POSTHOG_PROJECT_ID}`); | |
| 21 | +const linksFile = Bun.env.ASSISTANT_LINKS_FILE ?? defaultLinksFilePath; | |
| 67 | 22 | |
| 68 | 23 | const app = createApp({ |
| 69 | 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 | 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 | 31 | app.listen(PORT); |
| 78 | 32 | console.log(`[assistant] server running at http://localhost:${PORT}`); |
| 79 | 33 | console.log(`[assistant] executor at ${EXECUTOR_URL}`); |
| 34 | +console.log(`[assistant] link store at ${linksFile}`); | |
| 80 | 35 | |
| 81 | 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 | 1 | import { Elysia, t } from "elysia"; |
| 9 | 2 | import { createAgent } from "@assistant/core"; |
| 10 | −import type { TaskEvent } from "@assistant/core"; | |
| 11 | 3 | import { ConvexHttpClient } from "convex/browser"; |
| 12 | 4 | import { api } from "@executor/convex/_generated/api"; |
| 13 | 5 | import type { Id } from "@executor/convex/_generated/dataModel"; |
| 14 | − | |
| 15 | −// --------------------------------------------------------------------------- | |
| 16 | −// Config | |
| 17 | −// --------------------------------------------------------------------------- | |
| 6 | +import { createFileLinkStore, type LinkedMcpContext } from "./link-store"; | |
| 18 | 7 | |
| 19 | 8 | export interface ServerOptions { |
| 20 | 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 | 22 | readonly workspaceId: Id<"workspaces">; |
| 22 | − readonly actorId: string; | |
| 23 | + readonly actorId?: string; | |
| 24 | + readonly sessionId?: string; | |
| 23 | 25 | readonly clientId?: string; |
| 24 | − readonly context?: string; | |
| 25 | − readonly convexUrl: string; | |
| 26 | + readonly mcpAccessToken?: string; | |
| 27 | + readonly linked: boolean; | |
| 28 | + readonly source: "anonymous" | "linked_anonymous" | "linked_workos"; | |
| 26 | 29 | } |
| 27 | 30 | |
| 28 | −// --------------------------------------------------------------------------- | |
| 29 | −// App | |
| 30 | −// --------------------------------------------------------------------------- | |
| 31 | +function normalizeIdentityPart(value: string): string { | |
| 32 | + return value | |
| 33 | + .toLowerCase() | |
| 34 | + .trim() | |
| 35 | + .replace(/[^a-z0-9]+/g, "_") | |
| 36 | + .replace(/^_+|_+$/g, "") | |
| 37 | + .slice(0, 64); | |
| 38 | +} | |
| 31 | 39 | |
| 32 | −export function createApp(options: ServerOptions) { | |
| 33 | − const agent = createAgent({ | |
| 34 | − executorUrl: options.executorUrl, | |
| 35 | − workspaceId: options.workspaceId, | |
| 36 | − actorId: options.actorId, | |
| 37 | − clientId: options.clientId, | |
| 38 | − context: options.context, | |
| 39 | − }); | |
| 40 | +function identityKey(identity: ChatIdentity): string { | |
| 41 | + return `${normalizeIdentityPart(identity.platform)}:${normalizeIdentityPart(identity.userId)}`; | |
| 42 | +} | |
| 43 | + | |
| 44 | +function defaultSessionId(identity: ChatIdentity): string { | |
| 45 | + const platform = normalizeIdentityPart(identity.platform) || "chat"; | |
| 46 | + const user = normalizeIdentityPart(identity.userId) || "user"; | |
| 47 | + return `mcp_assistant_${platform}_${user}`; | |
| 48 | +} | |
| 40 | 49 | |
| 50 | +export function createApp(options: ServerOptions) { | |
| 41 | 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; | |
| 44 | − function generateAgentTaskId(): string { | |
| 45 | − return `atask_${Date.now()}_${++counter}`; | |
| 46 | − } | |
| 76 | + const clientId = linked?.clientId ?? defaultClientId; | |
| 47 | 77 | |
| 48 | − const app = new Elysia() | |
| 49 | − .get("/api/context", () => ({ | |
| 50 | − workspaceId: options.workspaceId, | |
| 51 | − actorId: options.actorId, | |
| 52 | − clientId: options.clientId, | |
| 53 | − })) | |
| 54 | − | |
| 55 | − .post("/api/tasks", async ({ body }) => { | |
| 56 | − const agentTaskId = generateAgentTaskId(); | |
| 57 | − | |
| 58 | − await convex.mutation(api.database.createAgentTask, { | |
| 59 | − id: agentTaskId, | |
| 60 | − prompt: body.prompt, | |
| 61 | − requesterId: body.requesterId, | |
| 62 | − workspaceId: options.workspaceId, | |
| 63 | − actorId: options.actorId, | |
| 78 | + if (linked?.provider === "anonymous") { | |
| 79 | + await linkStore.set(key, { | |
| 80 | + provider: "anonymous", | |
| 81 | + workspaceId: anonymous.workspaceId, | |
| 82 | + actorId: anonymous.actorId, | |
| 83 | + sessionId: anonymous.sessionId, | |
| 84 | + clientId, | |
| 85 | + linkedAt: linked.linkedAt, | |
| 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; | |
| 67 | − | |
| 68 | − agent.run(body.prompt, (event: TaskEvent) => { | |
| 69 | − if (event.type === "code_result") toolCalls++; | |
| 70 | − | |
| 71 | − if (event.type === "agent_message") { | |
| 72 | − convex.mutation(api.database.updateAgentTask, { | |
| 73 | − agentTaskId, | |
| 74 | − resultText: event.text, | |
| 75 | − codeRuns: toolCalls, | |
| 76 | − }).catch((err) => console.error(`[${agentTaskId}]`, err)); | |
| 77 | − } | |
| 99 | + function toPublicContext(context: ResolvedExecutorContext) { | |
| 100 | + return { | |
| 101 | + workspaceId: context.workspaceId, | |
| 102 | + actorId: context.actorId, | |
| 103 | + sessionId: context.sessionId, | |
| 104 | + clientId: context.clientId, | |
| 105 | + linked: context.linked, | |
| 106 | + source: context.source, | |
| 107 | + hasAccessToken: Boolean(context.mcpAccessToken), | |
| 108 | + }; | |
| 109 | + } | |
| 78 | 110 | |
| 79 | − if (event.type === "completed") { | |
| 80 | − convex.mutation(api.database.updateAgentTask, { | |
| 81 | − agentTaskId, | |
| 82 | − status: "completed", | |
| 83 | − codeRuns: toolCalls, | |
| 84 | − }).catch((err) => console.error(`[${agentTaskId}]`, err)); | |
| 85 | − } | |
| 111 | + const providerSchema = t.Union([t.Literal("anonymous"), t.Literal("workos")]); | |
| 86 | 112 | |
| 87 | − if (event.type === "error") { | |
| 88 | − convex.mutation(api.database.updateAgentTask, { | |
| 89 | − agentTaskId, | |
| 90 | − status: "failed", | |
| 91 | − error: event.error, | |
| 92 | − codeRuns: toolCalls, | |
| 93 | − }).catch((err) => console.error(`[${agentTaskId}]`, err)); | |
| 113 | + const app = new Elysia() | |
| 114 | + .post( | |
| 115 | + "/api/context/resolve", | |
| 116 | + async ({ body }) => { | |
| 117 | + const context = await resolveContext(body); | |
| 118 | + return { context: toPublicContext(context) }; | |
| 119 | + }, | |
| 120 | + { | |
| 121 | + body: t.Object({ | |
| 122 | + platform: t.String({ minLength: 1 }), | |
| 123 | + userId: t.String({ minLength: 1 }), | |
| 124 | + }), | |
| 125 | + }, | |
| 126 | + ) | |
| 127 | + | |
| 128 | + .post( | |
| 129 | + "/api/context/link", | |
| 130 | + async ({ body }) => { | |
| 131 | + const identity = { platform: body.platform, userId: body.userId } satisfies ChatIdentity; | |
| 132 | + const key = identityKey(identity); | |
| 133 | + const clientId = body.clientId?.trim() || defaultClientId; | |
| 134 | + const linkedAt = Date.now(); | |
| 135 | + | |
| 136 | + if (body.provider === "workos") { | |
| 137 | + if (!body.workspaceId?.trim()) { | |
| 138 | + throw new Error("workspaceId is required for workos links"); | |
| 139 | + } | |
| 140 | + if (!body.accessToken?.trim()) { | |
| 141 | + throw new Error("accessToken is required for workos links"); | |
| 142 | + } | |
| 143 | + | |
| 144 | + const link: LinkedMcpContext = { | |
| 145 | + provider: "workos", | |
| 146 | + workspaceId: body.workspaceId as Id<"workspaces">, | |
| 147 | + actorId: body.actorId?.trim() || undefined, | |
| 148 | + sessionId: body.sessionId?.trim() || undefined, | |
| 149 | + accessToken: body.accessToken.trim(), | |
| 150 | + clientId, | |
| 151 | + linkedAt, | |
| 152 | + }; | |
| 153 | + await linkStore.set(key, link); | |
| 154 | + const context = await resolveContext(identity); | |
| 155 | + return { linked: true, context: toPublicContext(context) }; | |
| 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 }; | |
| 106 | − }, { | |
| 107 | − body: t.Object({ | |
| 108 | − prompt: t.String({ minLength: 1 }), | |
| 109 | − requesterId: t.String({ minLength: 1 }), | |
| 110 | − }), | |
| 111 | − }); | |
| 158 | + const requestedSessionId = body.sessionId?.trim() || defaultSessionId(identity); | |
| 159 | + const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, { | |
| 160 | + sessionId: requestedSessionId, | |
| 161 | + }); | |
| 162 | + | |
| 163 | + const link: LinkedMcpContext = { | |
| 164 | + provider: "anonymous", | |
| 165 | + workspaceId: anonymous.workspaceId, | |
| 166 | + actorId: anonymous.actorId, | |
| 167 | + sessionId: anonymous.sessionId, | |
| 168 | + clientId, | |
| 169 | + linkedAt, | |
| 170 | + }; | |
| 171 | + await linkStore.set(key, link); | |
| 172 | + const context = await resolveContext(identity); | |
| 173 | + return { linked: true, context: toPublicContext(context) }; | |
| 174 | + }, | |
| 175 | + { | |
| 176 | + body: t.Object({ | |
| 177 | + platform: t.String({ minLength: 1 }), | |
| 178 | + userId: t.String({ minLength: 1 }), | |
| 179 | + provider: providerSchema, | |
| 180 | + workspaceId: t.Optional(t.String({ minLength: 1 })), | |
| 181 | + actorId: t.Optional(t.String({ minLength: 1 })), | |
| 182 | + sessionId: t.Optional(t.String({ minLength: 1 })), | |
| 183 | + accessToken: t.Optional(t.String({ minLength: 1 })), | |
| 184 | + clientId: t.Optional(t.String({ minLength: 1 })), | |
| 185 | + }), | |
| 186 | + }, | |
| 187 | + ) | |
| 188 | + | |
| 189 | + .post( | |
| 190 | + "/api/context/unlink", | |
| 191 | + async ({ body }) => { | |
| 192 | + const removed = await linkStore.delete(identityKey(body)); | |
| 193 | + return { removed }; | |
| 194 | + }, | |
| 195 | + { | |
| 196 | + body: t.Object({ | |
| 197 | + platform: t.String({ minLength: 1 }), | |
| 198 | + userId: t.String({ minLength: 1 }), | |
| 199 | + }), | |
| 200 | + }, | |
| 201 | + ) | |
| 202 | + | |
| 203 | + .post( | |
| 204 | + "/api/chat/run", | |
| 205 | + async ({ body }) => { | |
| 206 | + const context = await resolveContext({ | |
| 207 | + platform: body.platform, | |
| 208 | + userId: body.userId, | |
| 209 | + }); | |
| 210 | + | |
| 211 | + const agent = createAgent({ | |
| 212 | + executorUrl: options.executorUrl, | |
| 213 | + workspaceId: context.workspaceId, | |
| 214 | + actorId: context.actorId, | |
| 215 | + sessionId: context.sessionId, | |
| 216 | + clientId: context.clientId, | |
| 217 | + mcpAccessToken: context.mcpAccessToken, | |
| 218 | + context: options.context, | |
| 219 | + }); | |
| 220 | + | |
| 221 | + const result = await agent.run(body.prompt); | |
| 222 | + return { | |
| 223 | + text: result.text, | |
| 224 | + toolCalls: result.toolCalls, | |
| 225 | + context: toPublicContext(context), | |
| 226 | + }; | |
| 227 | + }, | |
| 228 | + { | |
| 229 | + body: t.Object({ | |
| 230 | + platform: t.String({ minLength: 1 }), | |
| 231 | + userId: t.String({ minLength: 1 }), | |
| 232 | + prompt: t.String({ minLength: 1 }), | |
| 233 | + }), | |
| 234 | + }, | |
| 235 | + ); | |
| 112 | 236 | |
| 113 | 237 | return app; |
| 114 | 238 | } |
| @@ -33,18 +33,12 @@ · 32 unmodified lines | ||
| 33 | 33 | "assistant/packages/bot": { |
| 34 | 34 | "name": "@assistant/bot", |
| 35 | 35 | "dependencies": { |
| 36 | − "@assistant/core": "workspace:*", | |
| 37 | 36 | "@assistant/server": "workspace:*", |
| 38 | 37 | "@elysiajs/eden": "latest", |
| 39 | − "@openassistant/reacord": "workspace:*", | |
| 40 | − "convex": "^1.31.7", | |
| 41 | − "discord.js": "^14.17.3", | |
| 42 | − "effect": "latest", | |
| 43 | − "react": "^19.0.0", | |
| 38 | + "discord.js": "^14.25.1", | |
| 44 | 39 | }, |
| 45 | 40 | "devDependencies": { |
| 46 | 41 | "@types/bun": "latest", |
| 47 | − "@types/react": "^19.0.0", | |
| 48 | 42 | }, |
| 49 | 43 | }, |
| 50 | 44 | "assistant/packages/core": { |
| @@ -1627,7 +1621,7 @@ · 1576 unmodified lines | ||
| 1627 | 1621 | |
| 1628 | 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 | 1626 | "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], |
| 1633 | 1627 | |
| @@ -2541,7 +2535,7 @@ · 907 unmodified lines | ||
| 2541 | 2535 | |
| 2542 | 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 | 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 | 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 | 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 | 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 | 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 | 3106 | "@opentelemetry/instrumentation-grpc/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.27.0", "", {}, "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg=="], |
| 3113 | 3107 | |
| 3114 | 3108 | "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], |
| @@ -1,45 +1,16 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { Suspense, useEffect, useMemo, useState, type FormEvent } from "react"; | |
| 4 | −import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; | |
| 5 | −import { useMutation } from "convex/react"; | |
| 6 | −import { | |
| 7 | − LayoutDashboard, | |
| 8 | − Play, | |
| 9 | − ShieldCheck, | |
| 10 | − Wrench, | |
| 11 | − Menu, | |
| 12 | − X, | |
| 13 | − ChevronsUpDown, | |
| 14 | − Plus, | |
| 15 | − Check, | |
| 16 | − Settings, | |
| 17 | − LogIn, | |
| 18 | −} from "lucide-react"; | |
| 3 | +import { Suspense, useEffect, useState } from "react"; | |
| 4 | +import { Link, useLocation, useNavigate } from "react-router"; | |
| 5 | +import { LayoutDashboard, Play, ShieldCheck, Wrench, Menu, X } from "lucide-react"; | |
| 19 | 6 | import { Button } from "@/components/ui/button"; |
| 20 | 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 | 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 | 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 | 14 | import { cn } from "@/lib/utils"; |
| 44 | 15 | |
| 45 | 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 | 55 | function Sidebar() { |
| 609 | 56 | return ( |
| 610 | 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 | 112 | export function AppShell({ children }: { children: React.ReactNode }) { |
| 735 | 113 | const location = useLocation(); |
| 736 | 114 | const pathname = location.pathname; |
| @@ -7,6 +7,7 @@ import { toast } from "sonner"; · 6 unmodified lines | ||
| 7 | 7 | import { useSession } from "@/lib/session-context"; |
| 8 | 8 | import { convexApi } from "@/lib/convex-api"; |
| 9 | 9 | import type { PendingApprovalRecord } from "@/lib/types"; |
| 10 | +import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | |
| 10 | 11 | |
| 11 | 12 | export function ApprovalNotifier() { |
| 12 | 13 | const navigate = useNavigate(); |
| @@ -16,7 +17,7 @@ export function ApprovalNotifier() { · 3 unmodified lines | ||
| 16 | 17 | |
| 17 | 18 | const approvals = useQuery( |
| 18 | 19 | convexApi.workspace.listPendingApprovals, |
| 19 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 20 | + workspaceQueryArgs(context), | |
| 20 | 21 | ); |
| 21 | 22 | |
| 22 | 23 | useEffect(() => { |
| @@ -21,62 +21,8 @@ import type { PendingApprovalRecord } from "@/lib/types"; · 20 unmodified lines | ||
| 21 | 21 | import { toast } from "sonner"; |
| 22 | 22 | import { formatTimeAgo } from "@/lib/format"; |
| 23 | 23 | import { FormattedCodeBlock } from "@/components/formatted-code-block"; |
| 24 | − | |
| 25 | −function formatApprovalInput( | |
| 26 | − input: unknown, | |
| 27 | −): { | |
| 28 | − content: string; | |
| 29 | − language: "json" | "text"; | |
| 30 | −} | null { | |
| 31 | − if (input === null || input === undefined) { | |
| 32 | − return null; | |
| 33 | − } | |
| 34 | − | |
| 35 | − if (typeof input === "string") { | |
| 36 | − const trimmed = input.trim(); | |
| 37 | − if (!trimmed) { | |
| 38 | − return null; | |
| 39 | − } | |
| 40 | − | |
| 41 | − try { | |
| 42 | − const parsed = JSON.parse(trimmed); | |
| 43 | − const serialized = JSON.stringify(parsed, null, 2); | |
| 44 | − if (serialized === "null" || serialized === "{}") { | |
| 45 | − return null; | |
| 46 | − } | |
| 47 | − return { | |
| 48 | − content: serialized, | |
| 49 | − language: "json", | |
| 50 | − }; | |
| 51 | − } catch { | |
| 52 | − return { | |
| 53 | − content: trimmed, | |
| 54 | − language: "text", | |
| 55 | − }; | |
| 56 | − } | |
| 57 | − } | |
| 58 | − | |
| 59 | − try { | |
| 60 | − const serialized = JSON.stringify(input, null, 2); | |
| 61 | − if (serialized === "null" || serialized === "{}") { | |
| 62 | − return null; | |
| 63 | − } | |
| 64 | − | |
| 65 | − return { | |
| 66 | − content: serialized, | |
| 67 | − language: "json", | |
| 68 | − }; | |
| 69 | − } catch { | |
| 70 | − const fallback = String(input).trim(); | |
| 71 | − if (!fallback) { | |
| 72 | − return null; | |
| 73 | − } | |
| 74 | − return { | |
| 75 | − content: fallback, | |
| 76 | − language: "text", | |
| 77 | − }; | |
| 78 | − } | |
| 79 | −} | |
| 24 | +import { formatApprovalInput } from "@/lib/approval-input-format"; | |
| 25 | +import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | |
| 80 | 26 | |
| 81 | 27 | function ApprovalCard({ |
| 82 | 28 | approval, |
| @@ -113,7 +59,11 @@ function ApprovalCard({ · 30 unmodified lines | ||
| 113 | 59 | }; |
| 114 | 60 | |
| 115 | 61 | const inputDisplay = useMemo( |
| 116 | − () => formatApprovalInput(approval.input), | |
| 62 | + () => | |
| 63 | + formatApprovalInput(approval.input, { | |
| 64 | + hideSerializedNull: true, | |
| 65 | + hideSerializedEmptyObject: true, | |
| 66 | + }), | |
| 117 | 67 | [approval.input], |
| 118 | 68 | ); |
| 119 | 69 | |
| @@ -203,7 +153,7 @@ export function ApprovalsView() { · 83 unmodified lines | ||
| 203 | 153 | |
| 204 | 154 | const approvals = useQuery( |
| 205 | 155 | convexApi.workspace.listPendingApprovals, |
| 206 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 156 | + workspaceQueryArgs(context), | |
| 207 | 157 | ); |
| 208 | 158 | const approvalsLoading = !!context && approvals === undefined; |
| 209 | 159 | |
| @@ -255,7 +205,7 @@ export function ApprovalsView() { · 45 unmodified lines | ||
| 255 | 205 | <Clock className="h-3.5 w-3.5" /> |
| 256 | 206 | {count} pending approval{count !== 1 ? "s" : ""} |
| 257 | 207 | </div> |
| 258 | − {approvals!.map((a: PendingApprovalRecord) => ( | |
| 208 | + {(approvals ?? []).map((a: PendingApprovalRecord) => ( | |
| 259 | 209 | <ApprovalCard key={a.id} approval={a} /> |
| 260 | 210 | ))} |
| 261 | 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 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useRef, useEffect, useState } from "react"; | |
| 3 | +import { useRef, useEffect, useMemo, useState } from "react"; | |
| 4 | 4 | import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react"; |
| 5 | 5 | import { Loader2 } from "lucide-react"; |
| 6 | 6 | import { useTheme } from "next-themes"; |
| 7 | 7 | import type { ToolDescriptor } from "@/lib/types"; |
| 8 | 8 | import { cn } from "@/lib/utils"; |
| 9 | − | |
| 10 | −// ── Generate TypeScript declarations for the tools proxy ── | |
| 11 | − | |
| 12 | −interface NamespaceNode { | |
| 13 | − children: Map<string, NamespaceNode>; | |
| 14 | − tools: ToolDescriptor[]; | |
| 15 | −} | |
| 16 | − | |
| 17 | −function buildTree(tools: ToolDescriptor[]): NamespaceNode { | |
| 18 | − const root: NamespaceNode = { children: new Map(), tools: [] }; | |
| 19 | − for (const tool of tools) { | |
| 20 | − const parts = tool.path.split("."); | |
| 21 | − if (parts.length === 1) { | |
| 22 | − root.tools.push(tool); | |
| 23 | − } else { | |
| 24 | − // Navigate/create namespace nodes for all segments except the last (which is the method name) | |
| 25 | − let node = root; | |
| 26 | − for (let i = 0; i < parts.length - 1; i++) { | |
| 27 | − if (!node.children.has(parts[i])) { | |
| 28 | − node.children.set(parts[i], { children: new Map(), tools: [] }); | |
| 29 | − } | |
| 30 | − node = node.children.get(parts[i])!; | |
| 31 | − } | |
| 32 | − node.tools.push(tool); | |
| 33 | − } | |
| 34 | − } | |
| 35 | − return root; | |
| 36 | −} | |
| 37 | − | |
| 38 | −function emitToolMethod(tool: ToolDescriptor, dtsSources: Set<string>): string { | |
| 39 | − const funcName = tool.path.split(".").pop()!; | |
| 40 | − const approvalNote = | |
| 41 | − tool.approval === "required" | |
| 42 | − ? " **Requires approval** - execution will pause until approved." | |
| 43 | − : ""; | |
| 44 | − const desc = tool.description | |
| 45 | − ? `${tool.description}${approvalNote}` | |
| 46 | − : approvalNote || "Call this tool."; | |
| 47 | − | |
| 48 | − // For OpenAPI tools with operationId, use indexed access types only when | |
| 49 | − // this source has a loaded .d.ts block. | |
| 50 | − const hasSourceDts = Boolean(tool.source && dtsSources.has(tool.source)); | |
| 51 | − if (tool.operationId && hasSourceDts) { | |
| 52 | − const opKey = JSON.stringify(tool.operationId); | |
| 53 | − return ` /** | |
| 54 | − * ${desc} | |
| 55 | − *${tool.source ? ` @source ${tool.source}` : ""} | |
| 56 | − */ | |
| 57 | − ${funcName}(input: ToolInput<operations[${opKey}]>): Promise<ToolOutput<operations[${opKey}]>>;`; | |
| 58 | − } | |
| 59 | − | |
| 60 | − // Fallback for MCP, GraphQL, builtins — use argsType/returnsType strings | |
| 61 | − const strictArgsType = tool.strictArgsType?.trim(); | |
| 62 | − const strictReturnsType = tool.strictReturnsType?.trim(); | |
| 63 | − const fallbackArgsType = tool.argsType?.trim(); | |
| 64 | − const fallbackReturnsType = tool.returnsType?.trim(); | |
| 65 | − const hasArgsType = Boolean(strictArgsType || fallbackArgsType); | |
| 66 | − const argsType = strictArgsType || fallbackArgsType || "Record<string, unknown>"; | |
| 67 | − const returnsType = strictReturnsType || fallbackReturnsType || "unknown"; | |
| 68 | − const inputParam = !hasArgsType || argsType === "{}" | |
| 69 | − ? `input?: ${argsType}` | |
| 70 | − : `input: ${argsType}`; | |
| 71 | − | |
| 72 | − return ` /** | |
| 73 | − * ${desc} | |
| 74 | − *${tool.source ? ` @source ${tool.source}` : ""} | |
| 75 | − */ | |
| 76 | − ${funcName}(${inputParam}): Promise<${returnsType}>;`; | |
| 77 | −} | |
| 78 | − | |
| 79 | −function emitNamespaceInterface( | |
| 80 | − name: string, | |
| 81 | − node: NamespaceNode, | |
| 82 | − dtsSources: Set<string>, | |
| 83 | − out: string[], | |
| 84 | −): void { | |
| 85 | − // First, recursively emit child namespace interfaces | |
| 86 | − for (const [childName, childNode] of node.children) { | |
| 87 | − emitNamespaceInterface(`${name}_${childName}`, childNode, dtsSources, out); | |
| 88 | − } | |
| 89 | − | |
| 90 | − // Build this namespace's interface | |
| 91 | − const members: string[] = []; | |
| 92 | − | |
| 93 | − // Add child namespace accessors | |
| 94 | − for (const [childName, childNode] of node.children) { | |
| 95 | − const toolCount = | |
| 96 | − childNode.tools.length + countAllTools(childNode); | |
| 97 | − members.push(` /** ${toolCount} tool${toolCount !== 1 ? "s" : ""} in the \`${childName}\` namespace */ | |
| 98 | − readonly ${childName}: ToolNS_${name}_${childName};`); | |
| 99 | − } | |
| 100 | − | |
| 101 | − // Add tool methods | |
| 102 | − for (const tool of node.tools) { | |
| 103 | − members.push(emitToolMethod(tool, dtsSources)); | |
| 104 | − } | |
| 105 | − | |
| 106 | − out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`); | |
| 107 | −} | |
| 108 | − | |
| 109 | −function countAllTools(node: NamespaceNode): number { | |
| 110 | − let count = node.tools.length; | |
| 111 | − for (const child of node.children.values()) { | |
| 112 | − count += countAllTools(child); | |
| 113 | − } | |
| 114 | − return count; | |
| 115 | −} | |
| 116 | − | |
| 117 | −/** TS helper types for OpenAPI indexed access (same as typechecker.ts) */ | |
| 118 | −const OPENAPI_HELPER_TYPES = ` | |
| 119 | −type _Normalize<T> = Exclude<T, undefined | null>; | |
| 120 | −type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; | |
| 121 | −type _Simplify<T> = { [K in keyof T]: T[K] } & {}; | |
| 122 | −type _ParamsOf<Op> = | |
| 123 | − Op extends { parameters: infer P } ? P : | |
| 124 | − Op extends { parameters?: infer P } ? P : | |
| 125 | − never; | |
| 126 | −type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> = | |
| 127 | − _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never; | |
| 128 | −type _BodyOf<Op> = | |
| 129 | − Op extends { requestBody?: infer B } ? B : | |
| 130 | − Op extends { requestBody: infer B } ? B : | |
| 131 | − never; | |
| 132 | −type _BodyContent<B> = | |
| 133 | − B extends { content: infer C } | |
| 134 | − ? C extends Record<string, infer V> ? V : never | |
| 135 | − : never; | |
| 136 | −type ToolInput<Op> = _Simplify< | |
| 137 | − _OrEmpty<_ParamAt<Op, "query">> & | |
| 138 | − _OrEmpty<_ParamAt<Op, "path">> & | |
| 139 | − _OrEmpty<_ParamAt<Op, "header">> & | |
| 140 | − _OrEmpty<_ParamAt<Op, "cookie">> & | |
| 141 | − _OrEmpty<_BodyContent<_BodyOf<Op>>> | |
| 142 | −>; | |
| 143 | −type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never; | |
| 144 | −type _RespAt<Op, Code extends PropertyKey> = | |
| 145 | − _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never; | |
| 146 | −type _ResponsePayload<R> = | |
| 147 | − [R] extends [never] ? never : | |
| 148 | − R extends { content: infer C } | |
| 149 | − ? C extends Record<string, infer V> ? V : unknown | |
| 150 | − : R extends { schema: infer S } ? S : unknown; | |
| 151 | −type _HasStatus<Op, Code extends PropertyKey> = | |
| 152 | − [_RespAt<Op, Code>] extends [never] ? false : true; | |
| 153 | −type _PayloadAt<Op, Code extends PropertyKey> = | |
| 154 | − Code extends 204 | 205 | |
| 155 | − ? (_HasStatus<Op, Code> extends true ? void : never) | |
| 156 | − : _ResponsePayload<_RespAt<Op, Code>>; | |
| 157 | −type _FirstKnown<T extends readonly unknown[]> = | |
| 158 | − T extends readonly [infer H, ...infer Rest] | |
| 159 | − ? [H] extends [never] ? _FirstKnown<Rest> : H | |
| 160 | − : unknown; | |
| 161 | −type ToolOutput<Op> = _FirstKnown<[ | |
| 162 | − _PayloadAt<Op, 200>, | |
| 163 | − _PayloadAt<Op, 201>, | |
| 164 | − _PayloadAt<Op, 202>, | |
| 165 | − _PayloadAt<Op, 203>, | |
| 166 | − _PayloadAt<Op, 204>, | |
| 167 | − _PayloadAt<Op, 205>, | |
| 168 | − _PayloadAt<Op, 206>, | |
| 169 | − _PayloadAt<Op, 207>, | |
| 170 | − _PayloadAt<Op, 208>, | |
| 171 | − _PayloadAt<Op, 226>, | |
| 172 | − _PayloadAt<Op, "default">, | |
| 173 | − unknown | |
| 174 | −]>; | |
| 175 | −`; | |
| 176 | − | |
| 177 | −function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string { | |
| 178 | − const root = buildTree(tools); | |
| 179 | − | |
| 180 | − const interfaces: string[] = []; | |
| 181 | − | |
| 182 | − // Emit all namespace interfaces recursively | |
| 183 | − for (const [name, node] of root.children) { | |
| 184 | − emitNamespaceInterface(name, node, dtsSources, interfaces); | |
| 185 | − } | |
| 186 | − | |
| 187 | − // Build root ToolsProxy interface | |
| 188 | − const rootMembers: string[] = []; | |
| 189 | − | |
| 190 | − // Root-level namespace accessors | |
| 191 | − for (const [name] of root.children) { | |
| 192 | − rootMembers.push(` readonly ${name}: ToolNS_${name};`); | |
| 193 | − } | |
| 194 | − | |
| 195 | − // Root-level tools (rare but possible) | |
| 196 | − for (const tool of root.tools) { | |
| 197 | − rootMembers.push(emitToolMethod(tool, dtsSources)); | |
| 198 | − } | |
| 199 | − | |
| 200 | − let dts = ` | |
| 201 | −/** | |
| 202 | − * The \`tools\` object is a proxy that lets you call registered executor tools. | |
| 203 | − * Each call returns a Promise with the tool's result. | |
| 204 | − * Tools marked with "approval: required" will pause execution until approved. | |
| 205 | − */ | |
| 206 | −`; | |
| 207 | − | |
| 208 | − // Note: .d.ts blocks from OpenAPI sources are fetched separately and registered | |
| 209 | − // as a distinct Monaco extra lib (see dtsUrls effect). This keeps generateToolsDts | |
| 210 | − // fast and avoids bundling multi-MB .d.ts content into the tool declarations string. | |
| 211 | − | |
| 212 | − dts += interfaces.join("\n\n") + "\n\n"; | |
| 213 | − dts += `interface ToolsProxy {\n${rootMembers.join("\n\n")}\n}\n\n`; | |
| 214 | − dts += `declare const tools: ToolsProxy;\n`; | |
| 215 | − | |
| 216 | − return dts; | |
| 217 | −} | |
| 218 | − | |
| 219 | −// ── Base environment declarations ── | |
| 220 | −// These provide types for the executor runtime environment | |
| 221 | −// (console, setTimeout, etc.) since we don't include the full DOM lib. | |
| 222 | − | |
| 223 | −const BASE_ENVIRONMENT_DTS = ` | |
| 224 | −interface Console { | |
| 225 | − /** Console output is discarded; use explicit return values for results. */ | |
| 226 | − log(...args: any[]): void; | |
| 227 | − /** Console output is discarded; use explicit return values for results. */ | |
| 228 | − error(...args: any[]): void; | |
| 229 | − /** Console output is discarded; use explicit return values for results. */ | |
| 230 | − warn(...args: any[]): void; | |
| 231 | − info(...args: any[]): void; | |
| 232 | − debug(...args: any[]): void; | |
| 233 | −} | |
| 234 | −declare var console: Console; | |
| 235 | − | |
| 236 | −declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number; | |
| 237 | −declare function clearTimeout(id: number): void; | |
| 238 | −declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number; | |
| 239 | −declare function clearInterval(id: number): void; | |
| 240 | −`; | |
| 241 | − | |
| 242 | −const DIAGNOSTIC_CODES_TO_IGNORE = [ | |
| 243 | − 1375, // 'await' expressions are only allowed at the top level of a file when that file is a module | |
| 244 | − 1378, // Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022'... | |
| 245 | − 2307, // Cannot find module | |
| 246 | − 80005, // 'require' call may be converted to an import | |
| 247 | −]; | |
| 248 | − | |
| 249 | −function setDiagnosticsOptions(monaco: Monaco, suppressSemantic: boolean) { | |
| 250 | − // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 251 | − const ts = (monaco.languages as any).typescript; | |
| 252 | − ts.javascriptDefaults.setDiagnosticsOptions({ | |
| 253 | − noSemanticValidation: suppressSemantic, | |
| 254 | − noSyntaxValidation: false, | |
| 255 | − diagnosticCodesToIgnore: DIAGNOSTIC_CODES_TO_IGNORE, | |
| 256 | − }); | |
| 257 | −} | |
| 9 | +import { | |
| 10 | + BASE_ENVIRONMENT_DTS, | |
| 11 | + OPENAPI_HELPER_TYPES, | |
| 12 | + generateToolsDts, | |
| 13 | +} from "./code-editor-types"; | |
| 14 | +import { | |
| 15 | + CODE_EDITOR_OPTIONS, | |
| 16 | + configureJavascriptDefaults, | |
| 17 | + defineExecutorThemes, | |
| 18 | + setDiagnosticsOptions, | |
| 19 | +} from "./code-editor-monaco"; | |
| 258 | 20 | |
| 259 | 21 | interface CodeEditorProps { |
| 260 | 22 | value: string; |
| @@ -285,20 +47,24 @@ export function CodeEditor({ · 24 unmodified lines | ||
| 285 | 47 | const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]); |
| 286 | 48 | const toolsLibVersion = useRef(0); |
| 287 | 49 | const fetchedDtsUrls = useRef<string>(""); |
| 288 | − const dtsSources = new Set(Object.keys(dtsUrls ?? {})); | |
| 50 | + const dtsSources = useMemo(() => new Set(Object.keys(dtsUrls ?? {})), [dtsUrls]); | |
| 289 | 51 | const toolTypesLoading = typesLoading || dtsHydrating; |
| 290 | 52 | |
| 291 | 53 | // Fetch and register .d.ts blobs from OpenAPI sources |
| 292 | 54 | useEffect(() => { |
| 293 | 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 | 62 | return; |
| 296 | 63 | } |
| 297 | 64 | const m = monacoRef.current; |
| 298 | 65 | if (!m) return; |
| 299 | 66 | |
| 300 | − // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 301 | − const jsDefaults = (m.languages as any).typescript.javascriptDefaults; | |
| 67 | + const jsDefaults = m.languages.typescript.javascriptDefaults; | |
| 302 | 68 | |
| 303 | 69 | // Skip if URLs haven't changed |
| 304 | 70 | const urlsKey = JSON.stringify(dtsUrls); |
| @@ -310,23 +76,24 @@ export function CodeEditor({ · 5 unmodified lines | ||
| 310 | 76 | dtsLibDisposables.current = []; |
| 311 | 77 | |
| 312 | 78 | let cancelled = false; |
| 313 | − setDtsHydrating(true); | |
| 314 | − | |
| 315 | − // Fetch each .d.ts blob and register with Monaco | |
| 316 | 79 | const entries = Object.entries(dtsUrls); |
| 317 | − Promise.all( | |
| 318 | − entries.map(async ([sourceKey, url]) => { | |
| 319 | − try { | |
| 320 | − const resp = await fetch(url); | |
| 321 | − if (!resp.ok || cancelled) return null; | |
| 322 | − const content = await resp.text(); | |
| 323 | − return { sourceKey, content }; | |
| 324 | − } catch { | |
| 325 | − return null; | |
| 326 | − } | |
| 327 | − }), | |
| 328 | − ) | |
| 329 | − .then((results) => { | |
| 80 | + | |
| 81 | + void Promise.resolve() | |
| 82 | + .then(async () => { | |
| 83 | + setDtsHydrating(true); | |
| 84 | + const results = await Promise.all( | |
| 85 | + entries.map(async ([sourceKey, url]) => { | |
| 86 | + try { | |
| 87 | + const resp = await fetch(url); | |
| 88 | + if (!resp.ok || cancelled) return null; | |
| 89 | + const content = await resp.text(); | |
| 90 | + return { sourceKey, content }; | |
| 91 | + } catch { | |
| 92 | + return null; | |
| 93 | + } | |
| 94 | + }), | |
| 95 | + ); | |
| 96 | + | |
| 330 | 97 | if (cancelled) return; |
| 331 | 98 | |
| 332 | 99 | // Build the helper types + .d.ts declarations |
| @@ -361,8 +128,7 @@ export function CodeEditor({ · 28 unmodified lines | ||
| 361 | 128 | const m = monacoRef.current; |
| 362 | 129 | if (!m) return; |
| 363 | 130 | |
| 364 | − // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 365 | − const jsDefaults = (m.languages as any).typescript.javascriptDefaults; | |
| 131 | + const jsDefaults = m.languages.typescript.javascriptDefaults; | |
| 366 | 132 | |
| 367 | 133 | // Dispose previous tool type declarations |
| 368 | 134 | toolsLibDisposable.current?.dispose(); |
| @@ -376,7 +142,7 @@ export function CodeEditor({ · 7 unmodified lines | ||
| 376 | 142 | dts, |
| 377 | 143 | `file:///node_modules/@types/executor-tools/v${version}.d.ts`, |
| 378 | 144 | ); |
| 379 | − }, [tools, dtsUrls]); | |
| 145 | + }, [tools, dtsSources]); | |
| 380 | 146 | |
| 381 | 147 | // Avoid transient semantic errors while tool metadata is still loading. |
| 382 | 148 | useEffect(() => { |
| @@ -397,29 +163,7 @@ export function CodeEditor({ · 14 unmodified lines | ||
| 397 | 163 | const handleBeforeMount: BeforeMount = (monaco) => { |
| 398 | 164 | monacoRef.current = monaco; |
| 399 | 165 | |
| 400 | − // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 401 | − const ts = (monaco.languages as any).typescript; | |
| 402 | − | |
| 403 | − // Configure JavaScript/TypeScript defaults for our execution environment | |
| 404 | − // Code runs inside an AsyncFunction body so top-level await is valid | |
| 405 | − // and there are no imports/exports. | |
| 406 | − setDiagnosticsOptions(monaco, toolTypesLoading); | |
| 407 | − | |
| 408 | − ts.javascriptDefaults.setCompilerOptions({ | |
| 409 | − target: ts.ScriptTarget.ESNext, | |
| 410 | − module: ts.ModuleKind.ESNext, | |
| 411 | − moduleResolution: ts.ModuleResolutionKind.NodeJs, | |
| 412 | − allowNonTsExtensions: true, | |
| 413 | − checkJs: true, | |
| 414 | − strict: false, | |
| 415 | − noEmit: true, | |
| 416 | − allowJs: true, | |
| 417 | − lib: ["esnext"], | |
| 418 | − }); | |
| 419 | − | |
| 420 | − // Ensure the worker eagerly syncs models so that completions from | |
| 421 | − // addExtraLib declarations are available immediately. | |
| 422 | − ts.javascriptDefaults.setEagerModelSync(true); | |
| 166 | + const ts = configureJavascriptDefaults(monaco, toolTypesLoading); | |
| 423 | 167 | |
| 424 | 168 | // Add stable environment declarations (once) |
| 425 | 169 | envLibDisposable.current?.dispose(); |
| @@ -438,97 +182,7 @@ export function CodeEditor({ · 12 unmodified lines | ||
| 438 | 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. | |
| 442 | − monaco.editor.defineTheme("executor-light", { | |
| 443 | − base: "vs", | |
| 444 | − inherit: true, | |
| 445 | − rules: [ | |
| 446 | − { token: "comment", foreground: "7f8692", fontStyle: "italic" }, | |
| 447 | − { token: "keyword", foreground: "0f8a6a" }, | |
| 448 | − { token: "string", foreground: "a46822" }, | |
| 449 | − { token: "number", foreground: "a46822" }, | |
| 450 | − { token: "type", foreground: "0f8a6a" }, | |
| 451 | − { token: "function", foreground: "5c470f" }, | |
| 452 | − { token: "variable", foreground: "1f2430" }, | |
| 453 | − { token: "operator", foreground: "6f7785" }, | |
| 454 | − ], | |
| 455 | − colors: { | |
| 456 | − "editor.background": "#ffffff", | |
| 457 | − "editor.foreground": "#1f2430", | |
| 458 | − "editor.lineHighlightBackground": "#f4f7fb", | |
| 459 | − "editor.selectionBackground": "#c7def5", | |
| 460 | − "editor.inactiveSelectionBackground": "#dbe8f7", | |
| 461 | − "editorCursor.foreground": "#0f8a6a", | |
| 462 | − "editorLineNumber.foreground": "#9aa3b2", | |
| 463 | − "editorLineNumber.activeForeground": "#6f7785", | |
| 464 | − "editorIndentGuide.background": "#e3e8ef", | |
| 465 | − "editorIndentGuide.activeBackground": "#ccd5e2", | |
| 466 | − "editor.selectionHighlightBackground": "#c7def540", | |
| 467 | − "editorWidget.background": "#ffffff", | |
| 468 | − "editorWidget.border": "#d6dde8", | |
| 469 | − "editorSuggestWidget.background": "#ffffff", | |
| 470 | − "editorSuggestWidget.border": "#d6dde8", | |
| 471 | − "editorSuggestWidget.selectedBackground": "#cfe1f6", | |
| 472 | − "editorSuggestWidget.selectedForeground": "#111827", | |
| 473 | − "editorSuggestWidget.selectedIconForeground": "#0f8a6a", | |
| 474 | − "editorSuggestWidget.highlightForeground": "#0f8a6a", | |
| 475 | − "editorHoverWidget.background": "#ffffff", | |
| 476 | − "editorHoverWidget.border": "#d6dde8", | |
| 477 | − "list.focusBackground": "#cfe1f6", | |
| 478 | − "list.focusForeground": "#111827", | |
| 479 | − "list.highlightForeground": "#0f8a6a", | |
| 480 | − "input.background": "#ffffff", | |
| 481 | − "input.border": "#d6dde8", | |
| 482 | − "scrollbar.shadow": "#00000000", | |
| 483 | − "scrollbarSlider.background": "#c2cad880", | |
| 484 | − "scrollbarSlider.hoverBackground": "#a6afbe", | |
| 485 | − "scrollbarSlider.activeBackground": "#8f98a8", | |
| 486 | − "focusBorder": "#0f8a6a30", | |
| 487 | − }, | |
| 488 | − }); | |
| 489 | − | |
| 490 | − monaco.editor.defineTheme("executor-dark", { | |
| 491 | − base: "vs-dark", | |
| 492 | − inherit: true, | |
| 493 | − rules: [ | |
| 494 | − { token: "comment", foreground: "5a6370", fontStyle: "italic" }, | |
| 495 | − { token: "keyword", foreground: "6bdfb8" }, | |
| 496 | − { token: "string", foreground: "c4a46c" }, | |
| 497 | − { token: "number", foreground: "c4a46c" }, | |
| 498 | − { token: "type", foreground: "6bdfb8" }, | |
| 499 | − { token: "function", foreground: "dcdcaa" }, | |
| 500 | − { token: "variable", foreground: "c8ccd4" }, | |
| 501 | − { token: "operator", foreground: "8a93a5" }, | |
| 502 | − ], | |
| 503 | − colors: { | |
| 504 | − "editor.background": "#0f1117", | |
| 505 | − "editor.foreground": "#c8ccd4", | |
| 506 | − "editor.lineHighlightBackground": "#161922", | |
| 507 | − "editor.selectionBackground": "#264f78", | |
| 508 | − "editor.inactiveSelectionBackground": "#1d2536", | |
| 509 | − "editorCursor.foreground": "#6bdfb8", | |
| 510 | − "editorLineNumber.foreground": "#3a3f4b", | |
| 511 | − "editorLineNumber.activeForeground": "#5a6370", | |
| 512 | − "editorIndentGuide.background": "#1e2230", | |
| 513 | − "editorIndentGuide.activeBackground": "#2a3040", | |
| 514 | − "editor.selectionHighlightBackground": "#264f7830", | |
| 515 | − "editorWidget.background": "#161922", | |
| 516 | − "editorWidget.border": "#2a3040", | |
| 517 | − "editorSuggestWidget.background": "#161922", | |
| 518 | − "editorSuggestWidget.border": "#2a3040", | |
| 519 | − "editorSuggestWidget.selectedBackground": "#1d2a3a", | |
| 520 | − "editorSuggestWidget.highlightForeground": "#6bdfb8", | |
| 521 | − "editorHoverWidget.background": "#161922", | |
| 522 | − "editorHoverWidget.border": "#2a3040", | |
| 523 | − "input.background": "#0f1117", | |
| 524 | − "input.border": "#2a3040", | |
| 525 | − "scrollbar.shadow": "#00000000", | |
| 526 | − "scrollbarSlider.background": "#2a304080", | |
| 527 | − "scrollbarSlider.hoverBackground": "#3a4050", | |
| 528 | − "scrollbarSlider.activeBackground": "#4a5060", | |
| 529 | − "focusBorder": "#6bdfb830", | |
| 530 | − }, | |
| 531 | − }); | |
| 185 | + defineExecutorThemes(monaco); | |
| 532 | 186 | }; |
| 533 | 187 | |
| 534 | 188 | const monacoTheme = resolvedTheme === "light" ? "executor-light" : "executor-dark"; |
| @@ -566,64 +220,7 @@ export function CodeEditor({ · 31 unmodified lines | ||
| 566 | 220 | onChange={(v) => onChange(v ?? "")} |
| 567 | 221 | beforeMount={handleBeforeMount} |
| 568 | 222 | onMount={handleMount} |
| 569 | − options={{ | |
| 570 | − minimap: { enabled: false }, | |
| 571 | − fixedOverflowWidgets: true, | |
| 572 | − fontSize: 13, | |
| 573 | − lineHeight: 22, | |
| 574 | − fontFamily: "var(--font-geist-mono), 'JetBrains Mono', monospace", | |
| 575 | − fontLigatures: true, | |
| 576 | − tabSize: 2, | |
| 577 | − padding: { top: 12, bottom: 12 }, | |
| 578 | − scrollBeyondLastLine: false, | |
| 579 | − renderLineHighlight: "gutter", | |
| 580 | − guides: { | |
| 581 | − indentation: true, | |
| 582 | − bracketPairs: true, | |
| 583 | − }, | |
| 584 | − bracketPairColorization: { | |
| 585 | − enabled: true, | |
| 586 | − }, | |
| 587 | − suggest: { | |
| 588 | − showMethods: true, | |
| 589 | − showFunctions: true, | |
| 590 | − showFields: true, | |
| 591 | − showVariables: true, | |
| 592 | − showModules: true, | |
| 593 | − showProperties: true, | |
| 594 | − showKeywords: true, | |
| 595 | − preview: true, | |
| 596 | − shareSuggestSelections: true, | |
| 597 | − }, | |
| 598 | − quickSuggestions: { | |
| 599 | − other: true, | |
| 600 | − comments: false, | |
| 601 | − strings: true, | |
| 602 | − }, | |
| 603 | − acceptSuggestionOnCommitCharacter: true, | |
| 604 | − parameterHints: { | |
| 605 | − enabled: true, | |
| 606 | − cycle: true, | |
| 607 | − }, | |
| 608 | − inlineSuggest: { | |
| 609 | − enabled: true, | |
| 610 | − }, | |
| 611 | − wordWrap: "on", | |
| 612 | − automaticLayout: true, | |
| 613 | − overviewRulerLanes: 0, | |
| 614 | − hideCursorInOverviewRuler: true, | |
| 615 | − overviewRulerBorder: false, | |
| 616 | − scrollbar: { | |
| 617 | − vertical: "auto", | |
| 618 | − horizontal: "auto", | |
| 619 | − verticalScrollbarSize: 6, | |
| 620 | − horizontalScrollbarSize: 6, | |
| 621 | − }, | |
| 622 | − roundedSelection: true, | |
| 623 | − cursorBlinking: "smooth", | |
| 624 | − cursorSmoothCaretAnimation: "on", | |
| 625 | − smoothScrolling: true, | |
| 626 | − }} | |
| 223 | + options={CODE_EDITOR_OPTIONS} | |
| 627 | 224 | loading={ |
| 628 | 225 | <div className="flex h-full items-center justify-center bg-background text-xs font-mono text-muted-foreground"> |
| 629 | 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 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useMemo } from "react"; | |
| 4 | −import { useNavigate } from "react-router"; | |
| 5 | −import { | |
| 6 | − Play, | |
| 7 | − ShieldCheck, | |
| 8 | − Clock, | |
| 9 | − CheckCircle2, | |
| 10 | − XCircle, | |
| 11 | − AlertTriangle, | |
| 12 | − ArrowRight, | |
| 13 | − Wrench, | |
| 14 | − ChevronRight, | |
| 15 | − Server, | |
| 16 | − Globe, | |
| 17 | −} from "lucide-react"; | |
| 18 | −import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| 19 | −import { Button } from "@/components/ui/button"; | |
| 3 | +import { CheckCircle2, Play, ShieldCheck, XCircle } from "lucide-react"; | |
| 20 | 4 | import { Skeleton } from "@/components/ui/skeleton"; |
| 21 | 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 | 11 | import { useSession } from "@/lib/session-context"; |
| 24 | 12 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 25 | 13 | import { useQuery } from "convex/react"; |
| 26 | 14 | import { convexApi } from "@/lib/convex-api"; |
| 27 | −import type { TaskRecord, PendingApprovalRecord, ToolDescriptor } from "@/lib/types"; | |
| 28 | −import { formatTime, formatTimeAgo } from "@/lib/format"; | |
| 29 | − | |
| 30 | −function StatCard({ | |
| 31 | − label, | |
| 32 | − value, | |
| 33 | − icon: Icon, | |
| 34 | − accent, | |
| 35 | −}: { | |
| 36 | − label: string; | |
| 37 | − value: number | string; | |
| 38 | − icon: React.ComponentType<{ className?: string }>; | |
| 39 | − accent?: "green" | "amber" | "red" | "default"; | |
| 40 | −}) { | |
| 41 | − const accentClass = { | |
| 42 | − green: "text-terminal-green", | |
| 43 | − amber: "text-terminal-amber", | |
| 44 | − red: "text-terminal-red", | |
| 45 | − default: "text-muted-foreground", | |
| 46 | − }[accent ?? "default"]; | |
| 47 | − | |
| 48 | − return ( | |
| 49 | − <Card className="bg-card border-border"> | |
| 50 | − <CardContent className="p-4"> | |
| 51 | − <div className="flex items-center justify-between"> | |
| 52 | − <div> | |
| 53 | − <p className="text-[11px] font-mono uppercase tracking-widest text-muted-foreground"> | |
| 54 | − {label} | |
| 55 | − </p> | |
| 56 | − <p className={`text-2xl font-semibold mt-1 font-mono ${accentClass}`}> | |
| 57 | − {value} | |
| 58 | − </p> | |
| 59 | − </div> | |
| 60 | − <div className={`${accentClass} opacity-40`}> | |
| 61 | − <Icon className="h-8 w-8" /> | |
| 62 | − </div> | |
| 63 | − </div> | |
| 64 | − </CardContent> | |
| 65 | − </Card> | |
| 66 | − ); | |
| 67 | −} | |
| 68 | − | |
| 69 | −function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) { | |
| 70 | − const navigate = useNavigate(); | |
| 71 | − return ( | |
| 72 | − <button | |
| 73 | − onClick={() => navigate(`/tasks?selected=${approval.taskId}`)} | |
| 74 | − className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group" | |
| 75 | − > | |
| 76 | − <div className="h-2 w-2 rounded-full bg-terminal-amber pulse-dot shrink-0" /> | |
| 77 | − <div className="flex-1 min-w-0"> | |
| 78 | − <span className="text-sm font-mono text-foreground"> | |
| 79 | − {approval.toolPath} | |
| 80 | − </span> | |
| 81 | − <span className="text-[11px] text-muted-foreground ml-2"> | |
| 82 | − {formatTimeAgo(approval.createdAt)} | |
| 83 | − </span> | |
| 84 | − </div> | |
| 85 | − <ArrowRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> | |
| 86 | − </button> | |
| 87 | − ); | |
| 88 | −} | |
| 89 | − | |
| 90 | −function RecentTaskRow({ task }: { task: TaskRecord }) { | |
| 91 | − const navigate = useNavigate(); | |
| 92 | − return ( | |
| 93 | − <button | |
| 94 | − onClick={() => navigate(`/tasks?selected=${task.id}`)} | |
| 95 | − className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group" | |
| 96 | − > | |
| 97 | − <div className="flex-1 min-w-0"> | |
| 98 | − <span className="text-sm font-mono text-foreground truncate block"> | |
| 99 | − {task.id} | |
| 100 | − </span> | |
| 101 | − <span className="text-[11px] text-muted-foreground"> | |
| 102 | − {task.runtimeId} · {formatTime(task.createdAt)} | |
| 103 | − </span> | |
| 104 | − </div> | |
| 105 | − <TaskStatusBadge status={task.status} /> | |
| 106 | − </button> | |
| 107 | − ); | |
| 108 | −} | |
| 109 | − | |
| 110 | −/** Derive the "source name" from a tool's source field (e.g. "openapi:github" → "github"). */ | |
| 111 | −function sourceLabel(source?: string): string { | |
| 112 | − if (!source) return "built-in"; | |
| 113 | − const colonIdx = source.indexOf(":"); | |
| 114 | − return colonIdx >= 0 ? source.slice(colonIdx + 1) : source; | |
| 115 | −} | |
| 116 | − | |
| 117 | −/** Derive the source type prefix (e.g. "openapi", "mcp", "graphql"). */ | |
| 118 | −function sourceType(source?: string): string { | |
| 119 | − if (!source) return "local"; | |
| 120 | − const colonIdx = source.indexOf(":"); | |
| 121 | − return colonIdx >= 0 ? source.slice(0, colonIdx) : "local"; | |
| 122 | −} | |
| 123 | − | |
| 124 | −function ToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) { | |
| 125 | − const navigate = useNavigate(); | |
| 126 | − | |
| 127 | − const groups = useMemo(() => { | |
| 128 | − const map = new Map< | |
| 129 | − string, | |
| 130 | − { | |
| 131 | − name: string; | |
| 132 | − type: string; | |
| 133 | − tools: ToolDescriptor[]; | |
| 134 | − namespaces: Set<string>; | |
| 135 | − approvalCount: number; | |
| 136 | − } | |
| 137 | − >(); | |
| 138 | − | |
| 139 | − for (const tool of tools) { | |
| 140 | − const name = sourceLabel(tool.source); | |
| 141 | − const type = sourceType(tool.source); | |
| 142 | − let group = map.get(name); | |
| 143 | − if (!group) { | |
| 144 | − group = { name, type, tools: [], namespaces: new Set(), approvalCount: 0 }; | |
| 145 | − map.set(name, group); | |
| 146 | − } | |
| 147 | − group.tools.push(tool); | |
| 148 | − // Extract namespace: first two segments of the path (e.g. "github.repos" from "github.repos.list") | |
| 149 | − const parts = tool.path.split("."); | |
| 150 | − if (parts.length >= 2) { | |
| 151 | − group.namespaces.add(`${parts[0]}.${parts[1]}`); | |
| 152 | − } | |
| 153 | − if (tool.approval === "required") { | |
| 154 | − group.approvalCount++; | |
| 155 | − } | |
| 156 | − } | |
| 157 | − | |
| 158 | − return Array.from(map.values()).sort((a, b) => b.tools.length - a.tools.length); | |
| 159 | − }, [tools]); | |
| 160 | − | |
| 161 | − const totalApprovals = tools.filter((t) => t.approval === "required").length; | |
| 162 | − | |
| 163 | − return ( | |
| 164 | − <Card className="bg-card border-border"> | |
| 165 | − <CardHeader className="pb-3"> | |
| 166 | − <div className="flex items-center justify-between"> | |
| 167 | − <CardTitle className="text-sm font-medium flex items-center gap-2"> | |
| 168 | − <Wrench className="h-4 w-4 text-muted-foreground" /> | |
| 169 | − Tool Sources | |
| 170 | − <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded"> | |
| 171 | − {tools.length} tools | |
| 172 | − </span> | |
| 173 | − {totalApprovals > 0 && ( | |
| 174 | − <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded"> | |
| 175 | − {totalApprovals} gated | |
| 176 | − </span> | |
| 177 | − )} | |
| 178 | − </CardTitle> | |
| 179 | − <Button | |
| 180 | − variant="ghost" | |
| 181 | − size="sm" | |
| 182 | − className="text-xs h-7" | |
| 183 | − onClick={() => navigate("/tools")} | |
| 184 | − > | |
| 185 | − Manage | |
| 186 | − <ArrowRight className="h-3 w-3 ml-1" /> | |
| 187 | − </Button> | |
| 188 | − </div> | |
| 189 | − </CardHeader> | |
| 190 | − <CardContent className="pt-0"> | |
| 191 | − <div className="grid gap-1"> | |
| 192 | − {groups.map((group) => { | |
| 193 | − const SourceIcon = group.type === "mcp" ? Server : Globe; | |
| 194 | − return ( | |
| 195 | − <button | |
| 196 | − key={group.name} | |
| 197 | − onClick={() => navigate(`/tools?source=${encodeURIComponent(group.name)}`)} | |
| 198 | − className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent/40 transition-colors text-left group/row w-full" | |
| 199 | − > | |
| 200 | − <div className="h-7 w-7 rounded bg-muted flex items-center justify-center shrink-0"> | |
| 201 | − <SourceIcon className="h-3.5 w-3.5 text-muted-foreground" /> | |
| 202 | − </div> | |
| 203 | − <div className="flex-1 min-w-0"> | |
| 204 | − <div className="flex items-center gap-2"> | |
| 205 | − <span className="text-sm font-mono font-medium text-foreground"> | |
| 206 | − {group.name} | |
| 207 | − </span> | |
| 208 | − <span className="text-[10px] font-mono text-muted-foreground/70 uppercase tracking-wider"> | |
| 209 | − {group.type} | |
| 210 | − </span> | |
| 211 | − </div> | |
| 212 | − <span className="text-[11px] text-muted-foreground"> | |
| 213 | − {group.tools.length} tool{group.tools.length !== 1 ? "s" : ""} | |
| 214 | − {group.namespaces.size > 0 && ( | |
| 215 | − <> · {group.namespaces.size} namespace{group.namespaces.size !== 1 ? "s" : ""}</> | |
| 216 | − )} | |
| 217 | − {group.approvalCount > 0 && ( | |
| 218 | − <> · <span className="text-terminal-amber">{group.approvalCount} gated</span></> | |
| 219 | − )} | |
| 220 | − </span> | |
| 221 | − </div> | |
| 222 | − <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40 opacity-0 group-hover/row:opacity-100 transition-opacity shrink-0" /> | |
| 223 | − </button> | |
| 224 | − ); | |
| 225 | − })} | |
| 226 | − </div> | |
| 227 | − </CardContent> | |
| 228 | − </Card> | |
| 229 | − ); | |
| 230 | −} | |
| 15 | +import { | |
| 16 | + getPendingApprovals, | |
| 17 | + getRecentTasks, | |
| 18 | + getSetupSteps, | |
| 19 | + getTaskStats, | |
| 20 | +} from "@/components/dashboard-view-helpers"; | |
| 21 | +import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | |
| 231 | 22 | |
| 232 | 23 | export function DashboardView() { |
| 233 | 24 | const { context, loading: sessionLoading } = useSession(); |
| 234 | 25 | |
| 235 | 26 | const tasks = useQuery( |
| 236 | 27 | convexApi.workspace.listTasks, |
| 237 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 28 | + workspaceQueryArgs(context), | |
| 238 | 29 | ); |
| 239 | 30 | |
| 240 | 31 | const approvals = useQuery( |
| 241 | 32 | convexApi.workspace.listPendingApprovals, |
| 242 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 33 | + workspaceQueryArgs(context), | |
| 243 | 34 | ); |
| 244 | 35 | |
| 245 | 36 | const sources = useQuery( |
| 246 | 37 | convexApi.workspace.listToolSources, |
| 247 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 38 | + workspaceQueryArgs(context), | |
| 248 | 39 | ); |
| 249 | 40 | |
| 250 | 41 | const { tools } = useWorkspaceTools(context ?? null); |
| @@ -265,38 +56,10 @@ export function DashboardView() { · 14 unmodified lines | ||
| 265 | 56 | const pendingCount = approvals?.length ?? 0; |
| 266 | 57 | const sourceCount = sources?.length ?? 0; |
| 267 | 58 | const taskItems = tasks ?? []; |
| 268 | − const runningCount = | |
| 269 | − tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0; | |
| 270 | − const completedCount = | |
| 271 | − tasks?.filter((t: TaskRecord) => t.status === "completed").length ?? 0; | |
| 272 | − const failedCount = | |
| 273 | − tasks?.filter((t: TaskRecord) => ["failed", "timed_out", "denied"].includes(t.status)) | |
| 274 | − .length ?? 0; | |
| 275 | − const recentTasks = taskItems.slice(0, 8); | |
| 276 | − | |
| 277 | − const setupSteps = [ | |
| 278 | − { | |
| 279 | − label: "Connect a tool source", | |
| 280 | − done: sourceCount > 0, | |
| 281 | − href: "/tools", | |
| 282 | − pendingText: "Add MCP, OpenAPI, or GraphQL", | |
| 283 | − doneText: `${sourceCount} source${sourceCount === 1 ? "" : "s"} connected`, | |
| 284 | − }, | |
| 285 | − { | |
| 286 | − label: "Run a first task", | |
| 287 | − done: taskItems.length > 0, | |
| 288 | − href: "/tasks", | |
| 289 | − pendingText: "Use the advanced runner to execute code", | |
| 290 | − doneText: `${taskItems.length} task${taskItems.length === 1 ? "" : "s"} recorded`, | |
| 291 | − }, | |
| 292 | − { | |
| 293 | − label: "Review gated calls", | |
| 294 | − done: pendingCount === 0, | |
| 295 | − href: "/approvals", | |
| 296 | − pendingText: `${pendingCount} approval${pendingCount === 1 ? "" : "s"} waiting`, | |
| 297 | − doneText: "Approval queue is clear", | |
| 298 | − }, | |
| 299 | − ]; | |
| 59 | + const { runningCount, completedCount, failedCount } = getTaskStats(taskItems); | |
| 60 | + const recentTasks = getRecentTasks(taskItems); | |
| 61 | + const pendingApprovals = getPendingApprovals(approvals ?? []); | |
| 62 | + const setupSteps = getSetupSteps({ sourceCount, taskCount: taskItems.length, pendingCount }); | |
| 300 | 63 | |
| 301 | 64 | return ( |
| 302 | 65 | <div className="space-y-6"> |
| @@ -305,75 +68,28 @@ export function DashboardView() { · 2 unmodified lines | ||
| 305 | 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"> | |
| 309 | − <CardContent className="p-5 md:p-6 space-y-5"> | |
| 310 | − <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> | |
| 311 | − <div className="space-y-1.5 max-w-2xl"> | |
| 312 | − <p className="text-[11px] uppercase tracking-widest text-muted-foreground">Landing View</p> | |
| 313 | − <h2 className="text-lg font-semibold tracking-tight"> | |
| 314 | − {taskItems.length === 0 | |
| 315 | − ? "Connect tools and run your first task" | |
| 316 | − : "Your task activity and approval queue are live"} | |
| 317 | − </h2> | |
| 318 | − <p className="text-sm text-muted-foreground"> | |
| 319 | − Tasks is the primary workflow. Use the advanced runner only when you want direct code execution. | |
| 320 | − </p> | |
| 321 | − </div> | |
| 322 | − <div className="flex flex-wrap items-center gap-2"> | |
| 323 | − <Button asChild size="sm" className="h-8 text-xs"> | |
| 324 | − <a href="/tasks">Open task activity</a> | |
| 325 | − </Button> | |
| 326 | − <Button asChild variant="outline" size="sm" className="h-8 text-xs"> | |
| 327 | − <a href="/tools">Manage tools</a> | |
| 328 | − </Button> | |
| 329 | − </div> | |
| 330 | − </div> | |
| 71 | + <DashboardSetupCard taskCount={taskItems.length} setupSteps={setupSteps} /> | |
| 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 | 73 | <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> |
| 358 | − <StatCard | |
| 74 | + <DashboardStatCard | |
| 359 | 75 | label="Pending Approvals" |
| 360 | 76 | value={pendingCount} |
| 361 | 77 | icon={ShieldCheck} |
| 362 | 78 | accent={pendingCount > 0 ? "amber" : "default"} |
| 363 | 79 | /> |
| 364 | − <StatCard | |
| 80 | + <DashboardStatCard | |
| 365 | 81 | label="Running" |
| 366 | 82 | value={runningCount} |
| 367 | 83 | icon={Play} |
| 368 | 84 | accent={runningCount > 0 ? "green" : "default"} |
| 369 | 85 | /> |
| 370 | − <StatCard | |
| 86 | + <DashboardStatCard | |
| 371 | 87 | label="Completed" |
| 372 | 88 | value={completedCount} |
| 373 | 89 | icon={CheckCircle2} |
| 374 | 90 | accent="green" |
| 375 | 91 | /> |
| 376 | − <StatCard | |
| 92 | + <DashboardStatCard | |
| 377 | 93 | label="Failed" |
| 378 | 94 | value={failedCount} |
| 379 | 95 | icon={XCircle} |
| @@ -381,83 +97,12 @@ export function DashboardView() { · 1 unmodified line | ||
| 381 | 97 | /> |
| 382 | 98 | </div> |
| 383 | 99 | |
| 384 | − {/* Two column layout */} | |
| 385 | 100 | <div className="grid lg:grid-cols-2 gap-6"> |
| 386 | − {/* Pending approvals */} | |
| 387 | − <Card className="bg-card border-border"> | |
| 388 | − <CardHeader className="pb-3"> | |
| 389 | − <div className="flex items-center justify-between"> | |
| 390 | − <CardTitle className="text-sm font-medium flex items-center gap-2"> | |
| 391 | − <AlertTriangle className="h-4 w-4 text-terminal-amber" /> | |
| 392 | − Pending Approvals | |
| 393 | − {pendingCount > 0 && ( | |
| 394 | − <span className="text-[10px] font-mono bg-terminal-amber/15 text-terminal-amber px-1.5 py-0.5 rounded"> | |
| 395 | − {pendingCount} | |
| 396 | − </span> | |
| 397 | − )} | |
| 398 | − </CardTitle> | |
| 399 | − <Button | |
| 400 | − variant="ghost" | |
| 401 | − size="sm" | |
| 402 | − className="text-xs h-7" | |
| 403 | − asChild | |
| 404 | − > | |
| 405 | − <a href="/approvals">View all</a> | |
| 406 | − </Button> | |
| 407 | − </div> | |
| 408 | − </CardHeader> | |
| 409 | − <CardContent className="pt-0"> | |
| 410 | − {pendingCount === 0 ? ( | |
| 411 | − <div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> | |
| 412 | − <CheckCircle2 className="h-4 w-4 mr-2 text-terminal-green/50" /> | |
| 413 | − No pending approvals | |
| 414 | − </div> | |
| 415 | − ) : ( | |
| 416 | − <div className="space-y-0.5"> | |
| 417 | − {(approvals ?? []).slice(0, 5).map((a: PendingApprovalRecord) => ( | |
| 418 | − <PendingApprovalRow key={a.id} approval={a} /> | |
| 419 | − ))} | |
| 420 | − </div> | |
| 421 | − )} | |
| 422 | − </CardContent> | |
| 423 | − </Card> | |
| 424 | − | |
| 425 | − {/* Recent tasks */} | |
| 426 | − <Card className="bg-card border-border"> | |
| 427 | − <CardHeader className="pb-3"> | |
| 428 | − <div className="flex items-center justify-between"> | |
| 429 | − <CardTitle className="text-sm font-medium flex items-center gap-2"> | |
| 430 | − <Clock className="h-4 w-4 text-muted-foreground" /> | |
| 431 | − Recent Tasks | |
| 432 | − </CardTitle> | |
| 433 | − <Button | |
| 434 | − variant="ghost" | |
| 435 | − size="sm" | |
| 436 | − className="text-xs h-7" | |
| 437 | − asChild | |
| 438 | − > | |
| 439 | − <a href="/tasks">View all</a> | |
| 440 | − </Button> | |
| 441 | − </div> | |
| 442 | − </CardHeader> | |
| 443 | − <CardContent className="pt-0"> | |
| 444 | − {recentTasks.length === 0 ? ( | |
| 445 | − <div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> | |
| 446 | − No tasks yet | |
| 447 | − </div> | |
| 448 | − ) : ( | |
| 449 | − <div className="space-y-0.5"> | |
| 450 | − {recentTasks.map((t: TaskRecord) => ( | |
| 451 | − <RecentTaskRow key={t.id} task={t} /> | |
| 452 | − ))} | |
| 453 | − </div> | |
| 454 | − )} | |
| 455 | − </CardContent> | |
| 456 | − </Card> | |
| 101 | + <DashboardPendingApprovalsCard pendingCount={pendingCount} approvals={pendingApprovals} /> | |
| 102 | + <DashboardRecentTasksCard recentTasks={recentTasks} /> | |
| 457 | 103 | </div> |
| 458 | 104 | |
| 459 | − {/* Tools summary — grouped by source */} | |
| 460 | − {tools.length > 0 && <ToolsSummaryCard tools={tools} />} | |
| 105 | + {tools.length > 0 && <DashboardToolsSummaryCard tools={tools} />} | |
| 461 | 106 | </div> |
| 462 | 107 | ); |
| 463 | 108 | } |
| @@ -2,6 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { useMemo, useState } from "react"; |
| 4 | 4 | import { Check, Copy } from "lucide-react"; |
| 5 | +import Image from "next/image"; | |
| 5 | 6 | import { Button } from "@/components/ui/button"; |
| 6 | 7 | import { Input } from "@/components/ui/input"; |
| 7 | 8 | import { Label } from "@/components/ui/label"; |
| @@ -118,7 +119,14 @@ export function McpSetupCard({ · 110 unmodified lines | ||
| 118 | 119 | {MCP_PROVIDERS.map((item) => ( |
| 119 | 120 | <SelectItem key={item.id} value={item.id} className="text-xs"> |
| 120 | 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 | 130 | <span>{item.name}</span> |
| 123 | 131 | </div> |
| 124 | 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 | 3 | import { useState, useCallback } from "react"; |
| 4 | 4 | import { useNavigate, useSearchParams } from "react-router"; |
| 5 | −import { | |
| 6 | − Play, | |
| 7 | − ChevronRight, | |
| 8 | − X, | |
| 9 | − Send, | |
| 10 | − ShieldCheck, | |
| 11 | − ShieldX, | |
| 12 | − CheckCircle2, | |
| 13 | −} from "lucide-react"; | |
| 5 | +import { Play, ShieldCheck } from "lucide-react"; | |
| 14 | 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 15 | 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 | 8 | import { Skeleton } from "@/components/ui/skeleton"; |
| 27 | −import { Separator } from "@/components/ui/separator"; | |
| 28 | 9 | import { PageHeader } from "@/components/page-header"; |
| 29 | 10 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| 30 | −import { TaskStatusBadge } from "@/components/status-badge"; | |
| 31 | −import { FormattedCodeBlock } from "@/components/formatted-code-block"; | |
| 11 | +import { TaskDetail } from "@/components/task-detail"; | |
| 12 | +import { TaskComposer } from "@/components/task-composer"; | |
| 13 | +import { TaskListItem } from "@/components/task-list-item"; | |
| 32 | 14 | import { useSession } from "@/lib/session-context"; |
| 33 | −import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | |
| 34 | −import { useMutation, useQuery } from "convex/react"; | |
| 15 | +import { useQuery } from "convex/react"; | |
| 35 | 16 | import { convexApi } from "@/lib/convex-api"; |
| 17 | +import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | |
| 36 | 18 | import type { |
| 37 | − RuntimeTargetDescriptor, | |
| 38 | 19 | TaskRecord, |
| 39 | 20 | PendingApprovalRecord, |
| 40 | 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 | 22 | // ── Tasks View ── |
| 521 | 23 | |
| 522 | 24 | export function TasksView() { |
| @@ -528,14 +30,14 @@ export function TasksView() { · 5 unmodified lines | ||
| 528 | 30 | |
| 529 | 31 | const tasks = useQuery( |
| 530 | 32 | convexApi.workspace.listTasks, |
| 531 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 33 | + workspaceQueryArgs(context), | |
| 532 | 34 | ); |
| 533 | 35 | const tasksLoading = !!context && tasks === undefined; |
| 534 | 36 | const taskItems = tasks ?? []; |
| 535 | 37 | |
| 536 | 38 | const approvals = useQuery( |
| 537 | 39 | convexApi.workspace.listPendingApprovals, |
| 538 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 40 | + workspaceQueryArgs(context), | |
| 539 | 41 | ); |
| 540 | 42 | const pendingApprovals = approvals ?? []; |
| 541 | 43 | |
| @@ -631,10 +133,10 @@ export function TasksView() { · 89 unmodified lines | ||
| 631 | 133 | </Card> |
| 632 | 134 | |
| 633 | 135 | <div> |
| 634 | − {selectedTask ? ( | |
| 136 | + {selectedTask && context ? ( | |
| 635 | 137 | <TaskDetail |
| 636 | 138 | task={selectedTask} |
| 637 | − workspaceId={context!.workspaceId} | |
| 139 | + workspaceId={context.workspaceId} | |
| 638 | 140 | sessionId={context?.sessionId} |
| 639 | 141 | pendingApprovals={selectedTaskApprovals} |
| 640 | 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 | 1 | "use client"; |
| 2 | 2 | |
| 3 | +import { useState, useMemo, useCallback, useRef } from "react"; | |
| 4 | +import { cn } from "@/lib/utils"; | |
| 3 | 5 | import { |
| 4 | − useState, | |
| 5 | − useMemo, | |
| 6 | − useCallback, | |
| 7 | − useEffect, | |
| 8 | − useRef, | |
| 9 | − memo, | |
| 10 | −} from "react"; | |
| 11 | −import { | |
| 12 | − Search, | |
| 13 | − ChevronRight, | |
| 14 | − ChevronDown, | |
| 15 | − ShieldCheck, | |
| 16 | − Zap, | |
| 17 | − Server, | |
| 18 | − Globe, | |
| 19 | − Filter, | |
| 20 | − X, | |
| 21 | − FolderTree, | |
| 22 | − List, | |
| 23 | − Check, | |
| 24 | − Copy, | |
| 25 | − Layers, | |
| 26 | − Loader2, | |
| 27 | −} from "lucide-react"; | |
| 28 | −import { Input } from "@/components/ui/input"; | |
| 29 | −import { Button } from "@/components/ui/button"; | |
| 6 | + collectGroupKeys, | |
| 7 | +} from "@/lib/tool-explorer-grouping"; | |
| 8 | +import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types"; | |
| 9 | +import { findToolsInGroupByKey } from "./tool-explorer-helpers"; | |
| 30 | 10 | import { |
| 31 | − DropdownMenu, | |
| 32 | − DropdownMenuContent, | |
| 33 | − DropdownMenuCheckboxItem, | |
| 34 | − DropdownMenuLabel, | |
| 35 | − DropdownMenuSeparator, | |
| 36 | − DropdownMenuTrigger, | |
| 37 | −} from "@/components/ui/dropdown-menu"; | |
| 11 | + autoExpandedKeysForSearch, | |
| 12 | + countSelectedTools, | |
| 13 | + expandedKeysForSource, | |
| 14 | + filterToolsBySearch, | |
| 15 | + filterToolsBySourceAndApproval, | |
| 16 | + flatToolsForView, | |
| 17 | + sourceOptionsFromTools, | |
| 18 | + treeGroupsForView, | |
| 19 | + type FilterApproval, | |
| 20 | +} from "./tool-explorer-derived"; | |
| 21 | +import { ToolExplorerLoading } from "./tool-explorer-loading"; | |
| 38 | 22 | import { |
| 39 | − Tooltip, | |
| 40 | − TooltipContent, | |
| 41 | − TooltipProvider, | |
| 42 | − TooltipTrigger, | |
| 43 | −} from "@/components/ui/tooltip"; | |
| 23 | + EmptyState, | |
| 24 | + VirtualFlatList, | |
| 25 | +} from "./tool-explorer-rows"; | |
| 26 | +import { GroupNode, SourceSidebar } from "./tool-explorer-groups"; | |
| 44 | 27 | import { |
| 45 | − Collapsible, | |
| 46 | − CollapsibleContent, | |
| 47 | − CollapsibleTrigger, | |
| 48 | −} from "@/components/ui/collapsible"; | |
| 49 | −import { Streamdown } from "streamdown"; | |
| 50 | −import { createCodePlugin } from "@streamdown/code"; | |
| 51 | −import { cn } from "@/lib/utils"; | |
| 52 | −import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types"; | |
| 53 | − | |
| 54 | −// ── Streamdown code plugin (light + dark dual-theme) ── | |
| 55 | −const codePlugin = createCodePlugin({ | |
| 56 | − themes: ["github-light", "github-dark"], | |
| 57 | −}); | |
| 58 | − | |
| 59 | −// ── Helpers ── | |
| 60 | − | |
| 61 | −function sourceLabel(source?: string): string { | |
| 62 | − if (!source) return "built-in"; | |
| 63 | − const idx = source.indexOf(":"); | |
| 64 | − return idx >= 0 ? source.slice(idx + 1) : source; | |
| 65 | −} | |
| 66 | − | |
| 67 | −function sourceType(source?: string): string { | |
| 68 | − if (!source) return "local"; | |
| 69 | − const idx = source.indexOf(":"); | |
| 70 | − return idx >= 0 ? source.slice(0, idx) : "local"; | |
| 71 | −} | |
| 72 | − | |
| 73 | −function toolNamespace(path: string): string { | |
| 74 | − const parts = path.split("."); | |
| 75 | − if (parts.length >= 2) return parts.slice(0, -1).join("."); | |
| 76 | − return parts[0]; | |
| 77 | −} | |
| 78 | − | |
| 79 | −function toolOperation(path: string): string { | |
| 80 | − const parts = path.split("."); | |
| 81 | − return parts[parts.length - 1]; | |
| 82 | −} | |
| 83 | − | |
| 84 | −function trimLeadingNamespace(path: string, prefix: string): string { | |
| 85 | − const dottedPrefix = `${prefix}.`; | |
| 86 | − if (path.startsWith(dottedPrefix)) { | |
| 87 | − return path.slice(dottedPrefix.length); | |
| 88 | − } | |
| 89 | − return path; | |
| 90 | −} | |
| 91 | − | |
| 92 | −function displaySourceName(name: string): string { | |
| 93 | − const parts = name.split(/[-_.]+/).filter(Boolean); | |
| 94 | − if (parts.length === 0) return name; | |
| 95 | − | |
| 96 | − const deduped = parts.filter((part, index, all) => { | |
| 97 | − if (index === 0) return true; | |
| 98 | − return part.toLowerCase() !== all[index - 1]?.toLowerCase(); | |
| 99 | − }); | |
| 100 | − | |
| 101 | − const tokenMap: Record<string, string> = { | |
| 102 | − api: "API", | |
| 103 | − oauth: "OAuth", | |
| 104 | − graphql: "GraphQL", | |
| 105 | − mcp: "MCP", | |
| 106 | − github: "GitHub", | |
| 107 | − }; | |
| 108 | − | |
| 109 | − return deduped | |
| 110 | − .map((token) => { | |
| 111 | − const lower = token.toLowerCase(); | |
| 112 | − if (tokenMap[lower]) return tokenMap[lower]; | |
| 113 | − return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`; | |
| 114 | − }) | |
| 115 | − .join(" "); | |
| 116 | −} | |
| 117 | − | |
| 118 | −// ── Types for grouped tree data ── | |
| 119 | − | |
| 120 | −interface ToolGroup { | |
| 121 | − key: string; | |
| 122 | − label: string; | |
| 123 | − type: "source" | "namespace"; | |
| 124 | − sourceType?: string; | |
| 125 | − childCount: number; | |
| 126 | − approvalCount: number; | |
| 127 | − children: ToolGroup[] | ToolDescriptor[]; | |
| 128 | −} | |
| 129 | − | |
| 130 | −type GroupBy = "source" | "namespace" | "approval"; | |
| 131 | −type ViewMode = "tree" | "flat"; | |
| 132 | − | |
| 133 | −// ── Build grouped data structures ── | |
| 134 | − | |
| 135 | −function buildSourceTree(tools: ToolDescriptor[]): ToolGroup[] { | |
| 136 | − const bySource = new Map<string, ToolDescriptor[]>(); | |
| 137 | − for (const tool of tools) { | |
| 138 | − const src = sourceLabel(tool.source); | |
| 139 | − let list = bySource.get(src); | |
| 140 | − if (!list) { | |
| 141 | − list = []; | |
| 142 | − bySource.set(src, list); | |
| 143 | − } | |
| 144 | − list.push(tool); | |
| 145 | − } | |
| 146 | − | |
| 147 | − return Array.from(bySource.entries()) | |
| 148 | − .sort((a, b) => b[1].length - a[1].length) | |
| 149 | − .map(([src, srcTools]) => { | |
| 150 | − const sType = srcTools[0] ? sourceType(srcTools[0].source) : "local"; | |
| 151 | − | |
| 152 | − const byNs = new Map<string, ToolDescriptor[]>(); | |
| 153 | − for (const tool of srcTools) { | |
| 154 | − const ns = toolNamespace(tool.path); | |
| 155 | − let list = byNs.get(ns); | |
| 156 | − if (!list) { | |
| 157 | − list = []; | |
| 158 | − byNs.set(ns, list); | |
| 159 | − } | |
| 160 | − list.push(tool); | |
| 161 | − } | |
| 162 | − | |
| 163 | − const nsGroups: ToolGroup[] = Array.from(byNs.entries()) | |
| 164 | − .sort((a, b) => a[0].localeCompare(b[0])) | |
| 165 | − .map(([ns, nsTools]) => ({ | |
| 166 | − key: `source:${src}:ns:${ns}`, | |
| 167 | − label: trimLeadingNamespace(ns, src), | |
| 168 | − type: "namespace" as const, | |
| 169 | − childCount: nsTools.length, | |
| 170 | − approvalCount: nsTools.filter((t) => t.approval === "required") | |
| 171 | − .length, | |
| 172 | − children: [...nsTools].sort((a, b) => | |
| 173 | − a.path.localeCompare(b.path), | |
| 174 | − ), | |
| 175 | − })); | |
| 176 | − | |
| 177 | − return { | |
| 178 | − key: `source:${src}`, | |
| 179 | − label: src, | |
| 180 | − type: "source" as const, | |
| 181 | − sourceType: sType, | |
| 182 | − childCount: srcTools.length, | |
| 183 | − approvalCount: srcTools.filter((t) => t.approval === "required") | |
| 184 | − .length, | |
| 185 | − children: nsGroups, | |
| 186 | − }; | |
| 187 | − }); | |
| 188 | −} | |
| 189 | − | |
| 190 | −function buildNamespaceTree(tools: ToolDescriptor[]): ToolGroup[] { | |
| 191 | − const byNs = new Map<string, ToolDescriptor[]>(); | |
| 192 | − for (const tool of tools) { | |
| 193 | − const ns = toolNamespace(tool.path); | |
| 194 | − let list = byNs.get(ns); | |
| 195 | − if (!list) { | |
| 196 | − list = []; | |
| 197 | − byNs.set(ns, list); | |
| 198 | − } | |
| 199 | − list.push(tool); | |
| 200 | − } | |
| 201 | − | |
| 202 | − return Array.from(byNs.entries()) | |
| 203 | − .sort((a, b) => a[0].localeCompare(b[0])) | |
| 204 | − .map(([ns, nsTools]) => ({ | |
| 205 | − key: `ns:${ns}`, | |
| 206 | − label: ns, | |
| 207 | − type: "namespace" as const, | |
| 208 | − childCount: nsTools.length, | |
| 209 | − approvalCount: nsTools.filter((t) => t.approval === "required").length, | |
| 210 | − children: [...nsTools].sort((a, b) => a.path.localeCompare(b.path)), | |
| 211 | − })); | |
| 212 | −} | |
| 213 | − | |
| 214 | −function buildApprovalTree(tools: ToolDescriptor[]): ToolGroup[] { | |
| 215 | − const gated = tools.filter((t) => t.approval === "required"); | |
| 216 | − const auto = tools.filter((t) => t.approval !== "required"); | |
| 217 | − const groups: ToolGroup[] = []; | |
| 218 | − | |
| 219 | − if (gated.length > 0) { | |
| 220 | − groups.push({ | |
| 221 | − key: "approval:required", | |
| 222 | − label: "Approval Required", | |
| 223 | − type: "namespace", | |
| 224 | − childCount: gated.length, | |
| 225 | − approvalCount: gated.length, | |
| 226 | − children: [...gated].sort((a, b) => a.path.localeCompare(b.path)), | |
| 227 | − }); | |
| 228 | − } | |
| 229 | − if (auto.length > 0) { | |
| 230 | − groups.push({ | |
| 231 | − key: "approval:auto", | |
| 232 | − label: "Auto-approved", | |
| 233 | − type: "namespace", | |
| 234 | − childCount: auto.length, | |
| 235 | − approvalCount: 0, | |
| 236 | − children: [...auto].sort((a, b) => a.path.localeCompare(b.path)), | |
| 237 | − }); | |
| 238 | − } | |
| 239 | − | |
| 240 | − return groups; | |
| 241 | −} | |
| 242 | − | |
| 243 | −function collectGroupKeys(groups: ToolGroup[]): Set<string> { | |
| 244 | − const keys = new Set<string>(); | |
| 245 | − const stack = [...groups]; | |
| 246 | − | |
| 247 | − while (stack.length > 0) { | |
| 248 | − const group = stack.pop(); | |
| 249 | − if (!group) continue; | |
| 250 | − | |
| 251 | − keys.add(group.key); | |
| 252 | − if (group.children.length > 0 && "key" in group.children[0]) { | |
| 253 | − stack.push(...(group.children as ToolGroup[])); | |
| 254 | − } | |
| 255 | − } | |
| 256 | − | |
| 257 | − return keys; | |
| 258 | −} | |
| 259 | − | |
| 260 | −// ── Copy button ── | |
| 261 | − | |
| 262 | −function CopyButton({ | |
| 263 | − text, | |
| 264 | − className, | |
| 265 | −}: { | |
| 266 | − text: string; | |
| 267 | − className?: string; | |
| 268 | −}) { | |
| 269 | − const [copied, setCopied] = useState(false); | |
| 270 | − | |
| 271 | − const handleCopy = useCallback( | |
| 272 | − (e: React.MouseEvent) => { | |
| 273 | − e.stopPropagation(); | |
| 274 | − navigator.clipboard.writeText(text); | |
| 275 | − setCopied(true); | |
| 276 | − setTimeout(() => setCopied(false), 1500); | |
| 277 | − }, | |
| 278 | − [text], | |
| 279 | − ); | |
| 280 | − | |
| 281 | − return ( | |
| 282 | − <TooltipProvider delayDuration={200}> | |
| 283 | − <Tooltip> | |
| 284 | − <TooltipTrigger asChild> | |
| 285 | − <button | |
| 286 | − onClick={handleCopy} | |
| 287 | − className={cn( | |
| 288 | − "h-5 w-5 rounded flex items-center justify-center text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted transition-colors shrink-0", | |
| 289 | − className, | |
| 290 | − )} | |
| 291 | − > | |
| 292 | − {copied ? ( | |
| 293 | − <Check className="h-3 w-3 text-terminal-green" /> | |
| 294 | − ) : ( | |
| 295 | − <Copy className="h-3 w-3" /> | |
| 296 | − )} | |
| 297 | − </button> | |
| 298 | − </TooltipTrigger> | |
| 299 | − <TooltipContent side="left" className="text-[11px]"> | |
| 300 | − Copy path | |
| 301 | − </TooltipContent> | |
| 302 | − </Tooltip> | |
| 303 | − </TooltipProvider> | |
| 304 | − ); | |
| 305 | −} | |
| 306 | − | |
| 307 | −// ── Pretty-print a TS type literal for display ── | |
| 308 | −// Turns `{ owner: string; repo: string; ref?: string }` into a multi-line | |
| 309 | −// indented format. Handles nested objects/arrays. Pure string transform. | |
| 310 | − | |
| 311 | −function prettyType(raw: string): string { | |
| 312 | − const trimmed = raw.trim(); | |
| 313 | − // If it's already multi-line or short enough, leave it | |
| 314 | − if (trimmed.includes("\n") || trimmed.length < 50) return trimmed; | |
| 315 | − | |
| 316 | − let out = ""; | |
| 317 | − let indent = 0; | |
| 318 | − const TAB = " "; | |
| 319 | − let i = 0; | |
| 320 | − | |
| 321 | − while (i < trimmed.length) { | |
| 322 | − const ch = trimmed[i]; | |
| 323 | − | |
| 324 | − // Array type bracket — e.g. `string[]` or `}[]` — keep inline | |
| 325 | − if (ch === "[" && trimmed[i + 1] === "]") { | |
| 326 | − out += "[]"; | |
| 327 | − i += 2; | |
| 328 | − } else if (ch === "{") { | |
| 329 | − indent++; | |
| 330 | − out += ch + "\n" + TAB.repeat(indent); | |
| 331 | − i++; | |
| 332 | − // skip whitespace after brace | |
| 333 | − while (i < trimmed.length && trimmed[i] === " ") i++; | |
| 334 | − } else if (ch === "}") { | |
| 335 | − indent = Math.max(0, indent - 1); | |
| 336 | − // trim trailing whitespace on current line | |
| 337 | − out = out.replace(/\s+$/, ""); | |
| 338 | − out += "\n" + TAB.repeat(indent) + ch; | |
| 339 | − i++; | |
| 340 | − } else if (ch === ";" && trimmed[i + 1] === " ") { | |
| 341 | − // property separator — newline after semicolon | |
| 342 | − out += ";\n" + TAB.repeat(indent); | |
| 343 | − i += 2; // skip the semicolon + space | |
| 344 | − } else if (ch === "," && indent > 0 && trimmed[i + 1] === " ") { | |
| 345 | − out += ",\n" + TAB.repeat(indent); | |
| 346 | − i += 2; | |
| 347 | − } else { | |
| 348 | − out += ch; | |
| 349 | − i++; | |
| 350 | − } | |
| 351 | − } | |
| 352 | − | |
| 353 | − return out; | |
| 354 | −} | |
| 355 | − | |
| 356 | −// ── Formatted + highlighted type signature display ── | |
| 357 | − | |
| 358 | −function TypeSignature({ raw, label }: { raw: string; label: string }) { | |
| 359 | − const formatted = useMemo(() => prettyType(raw), [raw]); | |
| 360 | − const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null); | |
| 361 | − | |
| 362 | − useEffect(() => { | |
| 363 | − let cancelled = false; | |
| 364 | − import("shiki").then(({ codeToHtml }) => | |
| 365 | − codeToHtml(formatted, { | |
| 366 | − lang: "typescript", | |
| 367 | − themes: { light: "github-light", dark: "github-dark" }, | |
| 368 | − }).then((html) => { | |
| 369 | − if (!cancelled) setHighlightedHtml(html); | |
| 370 | − }), | |
| 371 | − ); | |
| 372 | − return () => { cancelled = true; }; | |
| 373 | − }, [formatted]); | |
| 374 | − | |
| 375 | − return ( | |
| 376 | − <div> | |
| 377 | − <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50 mb-1"> | |
| 378 | − {label} | |
| 379 | − </p> | |
| 380 | − {highlightedHtml ? ( | |
| 381 | − <div | |
| 382 | − className="type-signature text-[11px] leading-relaxed bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_code]:!text-[11px] [&_code]:!leading-relaxed" | |
| 383 | − dangerouslySetInnerHTML={{ __html: highlightedHtml }} | |
| 384 | − /> | |
| 385 | − ) : ( | |
| 386 | − <pre className="text-[11px] font-mono leading-relaxed text-foreground/80 bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto whitespace-pre"> | |
| 387 | − {formatted} | |
| 388 | − </pre> | |
| 389 | − )} | |
| 390 | − </div> | |
| 391 | − ); | |
| 392 | −} | |
| 393 | − | |
| 394 | −// ── Inline tool detail (shown when tool row is expanded) ── | |
| 395 | − | |
| 396 | −function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) { | |
| 397 | − // Align details under the expand chevron. | |
| 398 | − const insetLeft = depth * 20 + 8 + 16 + 8; | |
| 399 | − return ( | |
| 400 | − <div className="pr-2 pb-3 pt-1 space-y-2.5" style={{ paddingLeft: insetLeft }}> | |
| 401 | − {/* Description rendered as markdown/HTML via Streamdown */} | |
| 402 | − {tool.description && ( | |
| 403 | − <div className="tool-description text-[12px] text-muted-foreground leading-relaxed"> | |
| 404 | − <Streamdown plugins={{ code: codePlugin }}> | |
| 405 | − {tool.description} | |
| 406 | − </Streamdown> | |
| 407 | − </div> | |
| 408 | − )} | |
| 409 | − | |
| 410 | − {/* Metadata pills */} | |
| 411 | − <div className="flex flex-wrap gap-1.5"> | |
| 412 | − <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded"> | |
| 413 | − {tool.path} | |
| 414 | − </span> | |
| 415 | − {tool.source && ( | |
| 416 | − <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded"> | |
| 417 | − source: {tool.source} | |
| 418 | − </span> | |
| 419 | − )} | |
| 420 | − {tool.operationId && ( | |
| 421 | − <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded"> | |
| 422 | − op: {tool.operationId} | |
| 423 | − </span> | |
| 424 | − )} | |
| 425 | − </div> | |
| 426 | − | |
| 427 | − {/* Args / Returns — pretty-printed type signatures */} | |
| 428 | − {tool.argsType && ( | |
| 429 | − <TypeSignature raw={tool.argsType} label="Arguments" /> | |
| 430 | − )} | |
| 431 | − {tool.returnsType && ( | |
| 432 | − <TypeSignature raw={tool.returnsType} label="Returns" /> | |
| 433 | − )} | |
| 434 | − </div> | |
| 435 | − ); | |
| 436 | −} | |
| 437 | − | |
| 438 | −// ── Tool row — self-contained expand state, memoized ── | |
| 439 | −// Expand/collapse is local state so toggling one row never re-renders siblings. | |
| 440 | − | |
| 441 | −const ToolRow = memo(function ToolRow({ | |
| 442 | − tool, | |
| 443 | − label, | |
| 444 | − depth, | |
| 445 | − selected, | |
| 446 | − onSelect, | |
| 447 | −}: { | |
| 448 | − tool: ToolDescriptor; | |
| 449 | − label: string; | |
| 450 | − depth: number; | |
| 451 | − selected: boolean; | |
| 452 | − onSelect: (e: React.MouseEvent) => void; | |
| 453 | −}) { | |
| 454 | − const [expanded, setExpanded] = useState(false); | |
| 455 | − | |
| 456 | − return ( | |
| 457 | − <Collapsible open={expanded} onOpenChange={setExpanded}> | |
| 458 | − <CollapsibleTrigger asChild> | |
| 459 | − <div | |
| 460 | − className={cn( | |
| 461 | − "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/tool select-none", | |
| 462 | − expanded | |
| 463 | − ? "sticky bg-background/95 backdrop-blur-sm bg-accent/30" | |
| 464 | − : selected | |
| 465 | − ? "bg-primary/5 ring-1 ring-primary/10" | |
| 466 | − : "hover:bg-accent/20", | |
| 467 | − )} | |
| 468 | − style={{ | |
| 469 | − paddingLeft: `${depth * 20 + 8}px`, | |
| 470 | − ...(expanded ? { top: `${depth * 32}px`, zIndex: 20 - depth } : {}), | |
| 471 | − }} | |
| 472 | − > | |
| 473 | − {/* Selection checkbox */} | |
| 474 | − <button | |
| 475 | − onClick={onSelect} | |
| 476 | − className={cn( | |
| 477 | − "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors", | |
| 478 | − selected | |
| 479 | − ? "bg-primary border-primary text-primary-foreground" | |
| 480 | − : "border-border hover:border-muted-foreground/50", | |
| 481 | − )} | |
| 482 | − > | |
| 483 | − {selected && <Check className="h-2.5 w-2.5" />} | |
| 484 | − </button> | |
| 485 | − | |
| 486 | − {/* Expand chevron */} | |
| 487 | − <div className="h-4 w-4 flex items-center justify-center shrink-0"> | |
| 488 | − {expanded ? ( | |
| 489 | − <ChevronDown className="h-3 w-3 text-muted-foreground/50" /> | |
| 490 | − ) : ( | |
| 491 | − <ChevronRight className="h-3 w-3 text-muted-foreground/30" /> | |
| 492 | − )} | |
| 493 | − </div> | |
| 494 | − | |
| 495 | − {/* Tool icon */} | |
| 496 | − <Zap className="h-3 w-3 text-primary/60 shrink-0" /> | |
| 497 | − | |
| 498 | − {/* Tool name */} | |
| 499 | − <span className="text-[13px] font-mono text-foreground/90 truncate"> | |
| 500 | − {label} | |
| 501 | − </span> | |
| 502 | − | |
| 503 | − {/* Badges */} | |
| 504 | − {tool.approval === "required" && ( | |
| 505 | − <span className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/8 px-1.5 py-0.5 rounded border border-terminal-amber/15 shrink-0"> | |
| 506 | − <ShieldCheck className="h-2.5 w-2.5" /> | |
| 507 | − gated | |
| 508 | − </span> | |
| 509 | − )} | |
| 510 | − | |
| 511 | − {/* Right side actions */} | |
| 512 | − <div className="ml-auto flex items-center gap-1 shrink-0 opacity-0 group-hover/tool:opacity-100 transition-opacity"> | |
| 513 | − <CopyButton text={tool.path} /> | |
| 514 | − </div> | |
| 515 | − </div> | |
| 516 | − </CollapsibleTrigger> | |
| 517 | − | |
| 518 | − <CollapsibleContent> | |
| 519 | − <ToolDetail tool={tool} depth={depth} /> | |
| 520 | − </CollapsibleContent> | |
| 521 | − </Collapsible> | |
| 522 | − ); | |
| 523 | −}); | |
| 524 | − | |
| 525 | −// ── Group row with Collapsible children ── | |
| 526 | − | |
| 527 | −function GroupNode({ | |
| 528 | − group, | |
| 529 | − depth, | |
| 530 | − expandedKeys, | |
| 531 | − onToggle, | |
| 532 | − selectedKeys, | |
| 533 | − onSelectGroup, | |
| 534 | − onSelectTool, | |
| 535 | − search, | |
| 536 | −}: { | |
| 537 | − group: ToolGroup; | |
| 538 | − depth: number; | |
| 539 | − expandedKeys: Set<string>; | |
| 540 | − onToggle: (key: string) => void; | |
| 541 | − selectedKeys: Set<string>; | |
| 542 | − onSelectGroup: (key: string, e: React.MouseEvent) => void; | |
| 543 | − onSelectTool: (path: string, e: React.MouseEvent) => void; | |
| 544 | − search: string; | |
| 545 | −}) { | |
| 546 | − const isExpanded = expandedKeys.has(group.key); | |
| 547 | − const isSource = group.type === "source"; | |
| 548 | − const isGroupSelected = selectedKeys.has(group.key); | |
| 549 | − const SourceIcon = | |
| 550 | − group.sourceType === "mcp" | |
| 551 | − ? Server | |
| 552 | − : group.sourceType === "graphql" | |
| 553 | − ? Layers | |
| 554 | − : Globe; | |
| 555 | − | |
| 556 | − const hasNestedGroups = | |
| 557 | − group.children.length > 0 && "key" in group.children[0]; | |
| 558 | − | |
| 559 | − return ( | |
| 560 | − <Collapsible open={isExpanded} onOpenChange={() => onToggle(group.key)}> | |
| 561 | − <CollapsibleTrigger asChild> | |
| 562 | − <div | |
| 563 | − className={cn( | |
| 564 | − "flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group/row select-none", | |
| 565 | − "sticky bg-background/95 backdrop-blur-sm", | |
| 566 | − isExpanded && "border-b border-border/30", | |
| 567 | − isGroupSelected | |
| 568 | − ? "bg-primary/10 ring-1 ring-primary/20" | |
| 569 | − : "hover:bg-accent/30", | |
| 570 | − )} | |
| 571 | − style={{ | |
| 572 | − paddingLeft: `${depth * 20 + 8}px`, | |
| 573 | − top: `${depth * 32}px`, | |
| 574 | − zIndex: 20 - depth, | |
| 575 | − }} | |
| 576 | − > | |
| 577 | − {/* Selection checkbox */} | |
| 578 | − <button | |
| 579 | − onClick={(e) => onSelectGroup(group.key, e)} | |
| 580 | − className={cn( | |
| 581 | − "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors", | |
| 582 | − isGroupSelected | |
| 583 | − ? "bg-primary border-primary text-primary-foreground" | |
| 584 | − : "border-border hover:border-muted-foreground/50", | |
| 585 | − )} | |
| 586 | − > | |
| 587 | − {isGroupSelected && <Check className="h-2.5 w-2.5" />} | |
| 588 | − </button> | |
| 589 | − | |
| 590 | − {/* Expand/collapse chevron */} | |
| 591 | − <div className="h-4 w-4 flex items-center justify-center shrink-0"> | |
| 592 | − {isExpanded ? ( | |
| 593 | − <ChevronDown className="h-3 w-3 text-muted-foreground" /> | |
| 594 | − ) : ( | |
| 595 | − <ChevronRight className="h-3 w-3 text-muted-foreground" /> | |
| 596 | − )} | |
| 597 | − </div> | |
| 598 | − | |
| 599 | − {/* Icon */} | |
| 600 | − {isSource && ( | |
| 601 | − <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0"> | |
| 602 | − <SourceIcon className="h-3 w-3 text-muted-foreground" /> | |
| 603 | − </div> | |
| 604 | − )} | |
| 605 | − | |
| 606 | − {/* Label */} | |
| 607 | − <span | |
| 608 | − className={cn( | |
| 609 | − "font-mono text-[13px] truncate", | |
| 610 | − isSource | |
| 611 | − ? "font-semibold text-foreground" | |
| 612 | − : "font-medium text-foreground/90", | |
| 613 | − )} | |
| 614 | − > | |
| 615 | − {group.label} | |
| 616 | − </span> | |
| 617 | − | |
| 618 | − {/* Metadata */} | |
| 619 | − <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto flex items-center gap-2 shrink-0"> | |
| 620 | − {isSource && group.sourceType && ( | |
| 621 | − <span className="uppercase tracking-wider opacity-70"> | |
| 622 | − {group.sourceType} | |
| 623 | − </span> | |
| 624 | − )} | |
| 625 | − {group.approvalCount > 0 && ( | |
| 626 | − <span className="inline-flex items-center gap-0.5 text-terminal-amber"> | |
| 627 | − <ShieldCheck className="h-2.5 w-2.5" /> | |
| 628 | − {group.approvalCount} | |
| 629 | − </span> | |
| 630 | − )} | |
| 631 | − <span className="tabular-nums">{group.childCount}</span> | |
| 632 | − </span> | |
| 633 | − </div> | |
| 634 | − </CollapsibleTrigger> | |
| 635 | − | |
| 636 | − <CollapsibleContent> | |
| 637 | − {hasNestedGroups | |
| 638 | − ? (group.children as ToolGroup[]).map((child) => ( | |
| 639 | − <GroupNode | |
| 640 | − key={child.key} | |
| 641 | − group={child} | |
| 642 | − depth={depth + 1} | |
| 643 | − expandedKeys={expandedKeys} | |
| 644 | − onToggle={onToggle} | |
| 645 | − selectedKeys={selectedKeys} | |
| 646 | − onSelectGroup={onSelectGroup} | |
| 647 | − onSelectTool={onSelectTool} | |
| 648 | − search={search} | |
| 649 | − /> | |
| 650 | − )) | |
| 651 | − : (group.children as ToolDescriptor[]).map((tool) => ( | |
| 652 | − <SelectableToolRow | |
| 653 | − key={tool.path} | |
| 654 | − tool={tool} | |
| 655 | − label={search ? tool.path : toolOperation(tool.path)} | |
| 656 | − depth={depth + 1} | |
| 657 | − selectedKeys={selectedKeys} | |
| 658 | − onSelectTool={onSelectTool} | |
| 659 | − /> | |
| 660 | − ))} | |
| 661 | − </CollapsibleContent> | |
| 662 | − </Collapsible> | |
| 663 | − ); | |
| 664 | −} | |
| 665 | − | |
| 666 | −/** | |
| 667 | − * Thin adapter: extracts a boolean from the Set so ToolRow's memo sees | |
| 668 | − * a primitive `selected` and skips re-render when a *different* tool changes. | |
| 669 | − */ | |
| 670 | −const SelectableToolRow = memo(function SelectableToolRow({ | |
| 671 | − tool, | |
| 672 | − label, | |
| 673 | − depth, | |
| 674 | − selectedKeys, | |
| 675 | − onSelectTool, | |
| 676 | −}: { | |
| 677 | − tool: ToolDescriptor; | |
| 678 | − label: string; | |
| 679 | − depth: number; | |
| 680 | − selectedKeys: Set<string>; | |
| 681 | − onSelectTool: (path: string, e: React.MouseEvent) => void; | |
| 682 | −}) { | |
| 683 | − const selected = selectedKeys.has(tool.path); | |
| 684 | − const handleSelect = useCallback( | |
| 685 | − (e: React.MouseEvent) => onSelectTool(tool.path, e), | |
| 686 | − [onSelectTool, tool.path], | |
| 687 | − ); | |
| 688 | − | |
| 689 | − return ( | |
| 690 | − <ToolRow | |
| 691 | − tool={tool} | |
| 692 | − label={label} | |
| 693 | − depth={depth} | |
| 694 | − selected={selected} | |
| 695 | − onSelect={handleSelect} | |
| 696 | − /> | |
| 697 | − ); | |
| 698 | −}, | |
| 699 | −(prev, next) => | |
| 700 | − prev.tool === next.tool && | |
| 701 | − prev.label === next.label && | |
| 702 | − prev.depth === next.depth && | |
| 703 | − prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path), | |
| 704 | −); | |
| 705 | − | |
| 706 | −// ── Sidebar (Source list) ── | |
| 707 | − | |
| 708 | −function SourceSidebar({ | |
| 709 | − tools, | |
| 710 | − activeSource, | |
| 711 | − onSelectSource, | |
| 712 | −}: { | |
| 713 | − tools: ToolDescriptor[]; | |
| 714 | − activeSource: string | null; | |
| 715 | − onSelectSource: (source: string | null) => void; | |
| 716 | −}) { | |
| 717 | − const groups = useMemo(() => { | |
| 718 | − const map = new Map< | |
| 719 | − string, | |
| 720 | − { name: string; type: string; count: number; approvalCount: number } | |
| 721 | − >(); | |
| 722 | − | |
| 723 | − for (const tool of tools) { | |
| 724 | − const name = sourceLabel(tool.source); | |
| 725 | − const type = sourceType(tool.source); | |
| 726 | − let group = map.get(name); | |
| 727 | − if (!group) { | |
| 728 | − group = { name, type, count: 0, approvalCount: 0 }; | |
| 729 | − map.set(name, group); | |
| 730 | − } | |
| 731 | − group.count++; | |
| 732 | − if (tool.approval === "required") group.approvalCount++; | |
| 733 | − } | |
| 734 | − | |
| 735 | − return Array.from(map.values()).sort((a, b) => b.count - a.count); | |
| 736 | − }, [tools]); | |
| 737 | − | |
| 738 | − return ( | |
| 739 | − <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block"> | |
| 740 | − <div className="px-3 pb-2 pt-1"> | |
| 741 | − <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50"> | |
| 742 | − Sources | |
| 743 | − </p> | |
| 744 | − </div> | |
| 745 | − <div className="space-y-0.5 px-1"> | |
| 746 | − <button | |
| 747 | − onClick={() => onSelectSource(null)} | |
| 748 | − className={cn( | |
| 749 | − "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]", | |
| 750 | − activeSource === null | |
| 751 | − ? "bg-accent/40 text-foreground" | |
| 752 | − : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | |
| 753 | − )} | |
| 754 | − > | |
| 755 | − <Layers className="h-3 w-3 shrink-0" /> | |
| 756 | − <span className="font-medium truncate">All sources</span> | |
| 757 | − <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60"> | |
| 758 | − {tools.length} | |
| 759 | − </span> | |
| 760 | − </button> | |
| 761 | − | |
| 762 | − {groups.map((g) => { | |
| 763 | − const Icon = g.type === "mcp" ? Server : Globe; | |
| 764 | − return ( | |
| 765 | − <button | |
| 766 | − key={g.name} | |
| 767 | − onClick={() => onSelectSource(g.name)} | |
| 768 | − className={cn( | |
| 769 | − "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]", | |
| 770 | − activeSource === g.name | |
| 771 | − ? "bg-accent/40 text-foreground" | |
| 772 | − : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | |
| 773 | − )} | |
| 774 | − > | |
| 775 | − <Icon className="h-3 w-3 shrink-0" /> | |
| 776 | − <span className="font-mono font-medium truncate">{g.name}</span> | |
| 777 | − <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60"> | |
| 778 | − {g.count} | |
| 779 | − </span> | |
| 780 | − </button> | |
| 781 | − ); | |
| 782 | − })} | |
| 783 | − </div> | |
| 784 | − </div> | |
| 785 | − ); | |
| 786 | −} | |
| 28 | + ToolExplorerToolbar, | |
| 29 | + type GroupBy, | |
| 30 | + type ViewMode, | |
| 31 | +} from "./tool-explorer-toolbar"; | |
| 787 | 32 | |
| 788 | 33 | // ── Main Explorer ── |
| 789 | 34 | |
| @@ -811,16 +56,12 @@ export function ToolExplorer({ · 21 unmodified lines | ||
| 811 | 56 | const [search, setSearch] = useState(""); |
| 812 | 57 | const [viewMode, setViewMode] = useState<ViewMode>("tree"); |
| 813 | 58 | const [groupBy, setGroupBy] = useState<GroupBy>("source"); |
| 814 | − const [internalActiveSource, setInternalActiveSource] = useState<string | null>( | |
| 815 | − initialSource, | |
| 816 | − ); | |
| 59 | + const [internalActiveSource, setInternalActiveSource] = useState<string | null>(initialSource); | |
| 817 | 60 | const [expandedKeys, setExpandedKeys] = useState<Set<string>>( |
| 818 | − () => (initialSource ? new Set([`source:${initialSource}`]) : new Set()), | |
| 61 | + () => expandedKeysForSource(initialSource), | |
| 819 | 62 | ); |
| 820 | 63 | const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set()); |
| 821 | − const [filterApproval, setFilterApproval] = useState< | |
| 822 | − "all" | "required" | "auto" | |
| 823 | − >("all"); | |
| 64 | + const [filterApproval, setFilterApproval] = useState<FilterApproval>("all"); | |
| 824 | 65 | const treeListRef = useRef<HTMLDivElement>(null); |
| 825 | 66 | const flatListRef = useRef<HTMLDivElement>(null); |
| 826 | 67 | const resolvedActiveSource = |
| @@ -830,78 +71,35 @@ export function ToolExplorer({ · 3 unmodified lines | ||
| 830 | 71 | if (activeSource === undefined) { |
| 831 | 72 | setInternalActiveSource(source); |
| 832 | 73 | } |
| 74 | + | |
| 833 | 75 | onActiveSourceChange?.(source); |
| 834 | − setExpandedKeys(source ? new Set([`source:${source}`]) : new Set()); | |
| 76 | + setExpandedKeys(expandedKeysForSource(source)); | |
| 835 | 77 | }, [activeSource, onActiveSourceChange]); |
| 836 | 78 | |
| 837 | − // Filter tools by active source and approval filter | |
| 838 | 79 | const filteredTools = useMemo(() => { |
| 839 | − let result = tools; | |
| 840 | − | |
| 841 | − if (resolvedActiveSource) { | |
| 842 | − result = result.filter( | |
| 843 | − (t) => sourceLabel(t.source) === resolvedActiveSource, | |
| 844 | − ); | |
| 845 | − } | |
| 846 | − | |
| 847 | − if (filterApproval === "required") { | |
| 848 | − result = result.filter((t) => t.approval === "required"); | |
| 849 | − } else if (filterApproval === "auto") { | |
| 850 | − result = result.filter((t) => t.approval !== "required"); | |
| 851 | − } | |
| 852 | − | |
| 853 | − return result; | |
| 80 | + return filterToolsBySourceAndApproval( | |
| 81 | + tools, | |
| 82 | + resolvedActiveSource, | |
| 83 | + filterApproval, | |
| 84 | + ); | |
| 854 | 85 | }, [tools, resolvedActiveSource, filterApproval]); |
| 855 | 86 | |
| 856 | − // Apply search filter | |
| 857 | 87 | const searchedTools = useMemo(() => { |
| 858 | − if (!search) return filteredTools; | |
| 859 | − const lowerSearch = search.toLowerCase(); | |
| 860 | − return filteredTools.filter( | |
| 861 | − (t) => | |
| 862 | − t.path.toLowerCase().includes(lowerSearch) || | |
| 863 | − t.description.toLowerCase().includes(lowerSearch), | |
| 864 | − ); | |
| 88 | + return filterToolsBySearch(filteredTools, search); | |
| 865 | 89 | }, [filteredTools, search]); |
| 866 | 90 | |
| 867 | − // Build grouped tree | |
| 868 | 91 | const treeGroups = useMemo(() => { |
| 869 | − if (viewMode === "flat") return []; | |
| 870 | − if (groupBy === "source") return buildSourceTree(searchedTools); | |
| 871 | − if (groupBy === "namespace") return buildNamespaceTree(searchedTools); | |
| 872 | − return buildApprovalTree(searchedTools); | |
| 92 | + return treeGroupsForView(searchedTools, viewMode, groupBy); | |
| 873 | 93 | }, [searchedTools, viewMode, groupBy]); |
| 874 | 94 | |
| 875 | − // Flat list | |
| 876 | 95 | const flatTools = useMemo(() => { |
| 877 | − if (viewMode !== "flat") return []; | |
| 878 | − return [...searchedTools].sort((a, b) => a.path.localeCompare(b.path)); | |
| 96 | + return flatToolsForView(searchedTools, viewMode); | |
| 879 | 97 | }, [searchedTools, viewMode]); |
| 880 | 98 | |
| 881 | − // Auto-expand matching groups while searching without mutating user state. | |
| 882 | 99 | const autoExpandedKeys = useMemo(() => { |
| 883 | − if (search.length < 2 || viewMode !== "tree") { | |
| 884 | − return null; | |
| 885 | − } | |
| 886 | − | |
| 887 | − const allGroupKeys = new Set<string>(); | |
| 888 | − const lowerSearch = search.toLowerCase(); | |
| 889 | − const matching = filteredTools.filter( | |
| 890 | − (t) => | |
| 891 | − t.path.toLowerCase().includes(lowerSearch) || | |
| 892 | − t.description.toLowerCase().includes(lowerSearch), | |
| 893 | − ); | |
| 894 | − | |
| 895 | − for (const tool of matching) { | |
| 896 | − const src = sourceLabel(tool.source); | |
| 897 | − const ns = toolNamespace(tool.path); | |
| 898 | − allGroupKeys.add(`source:${src}`); | |
| 899 | − allGroupKeys.add(`source:${src}:ns:${ns}`); | |
| 900 | − allGroupKeys.add(`ns:${ns}`); | |
| 901 | − } | |
| 902 | − | |
| 903 | − return allGroupKeys; | |
| 100 | + return autoExpandedKeysForSearch(search, filteredTools, viewMode); | |
| 904 | 101 | }, [search, filteredTools, viewMode]); |
| 102 | + | |
| 905 | 103 | const visibleExpandedKeys = autoExpandedKeys ?? expandedKeys; |
| 906 | 104 | |
| 907 | 105 | const toggleExpand = useCallback((key: string) => { |
| @@ -939,44 +137,7 @@ export function ToolExplorer({ · 31 unmodified lines | ||
| 939 | 137 | e.preventDefault(); |
| 940 | 138 | setSelectedKeys((prev) => { |
| 941 | 139 | const next = new Set(prev); |
| 942 | − | |
| 943 | − const findToolsInGroup = ( | |
| 944 | − groups: ToolGroup[], | |
| 945 | − ): ToolDescriptor[] => { | |
| 946 | − const result: ToolDescriptor[] = []; | |
| 947 | − for (const g of groups) { | |
| 948 | − if (g.key === key) { | |
| 949 | − const collectTools = (group: ToolGroup): void => { | |
| 950 | − if ( | |
| 951 | − group.children.length > 0 && | |
| 952 | − "key" in group.children[0] | |
| 953 | − ) { | |
| 954 | − for (const child of group.children as ToolGroup[]) { | |
| 955 | − collectTools(child); | |
| 956 | − } | |
| 957 | − } else { | |
| 958 | − result.push( | |
| 959 | − ...(group.children as ToolDescriptor[]), | |
| 960 | − ); | |
| 961 | − } | |
| 962 | − }; | |
| 963 | − collectTools(g); | |
| 964 | − return result; | |
| 965 | − } | |
| 966 | − if ( | |
| 967 | − g.children.length > 0 && | |
| 968 | − "key" in g.children[0] | |
| 969 | − ) { | |
| 970 | − const found = findToolsInGroup( | |
| 971 | − g.children as ToolGroup[], | |
| 972 | − ); | |
| 973 | − if (found.length > 0) return found; | |
| 974 | − } | |
| 975 | − } | |
| 976 | − return result; | |
| 977 | − }; | |
| 978 | − | |
| 979 | − const childTools = findToolsInGroup(treeGroups); | |
| 140 | + const childTools = findToolsInGroupByKey(treeGroups, key); | |
| 980 | 141 | const allSelected = |
| 981 | 142 | childTools.length > 0 && |
| 982 | 143 | childTools.every((t) => prev.has(t.path)); |
| @@ -1003,11 +164,19 @@ export function ToolExplorer({ · 20 unmodified lines | ||
| 1003 | 164 | }, [filteredTools]); |
| 1004 | 165 | |
| 1005 | 166 | const selectedToolCount = useMemo(() => { |
| 1006 | − return Array.from(selectedKeys).filter((k) => | |
| 1007 | − filteredTools.some((t) => t.path === k), | |
| 1008 | − ).length; | |
| 167 | + return countSelectedTools(selectedKeys, filteredTools); | |
| 1009 | 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 | 180 | const handleExplorerWheel = useCallback( |
| 1012 | 181 | (e: React.WheelEvent<HTMLDivElement>) => { |
| 1013 | 182 | const listEl = viewMode === "flat" ? flatListRef.current : treeListRef.current; |
| @@ -1028,33 +197,7 @@ export function ToolExplorer({ · 14 unmodified lines | ||
| 1028 | 197 | [viewMode], |
| 1029 | 198 | ); |
| 1030 | 199 | |
| 1031 | − if (loading) { | |
| 1032 | − const enabledSources = sources.filter((s) => s.enabled); | |
| 1033 | − return ( | |
| 1034 | − <div className="space-y-3 rounded-md border border-border/40 bg-muted/20 p-3"> | |
| 1035 | − <div className="flex items-center gap-2 text-sm text-muted-foreground"> | |
| 1036 | − <Loader2 className="h-4 w-4 animate-spin" /> | |
| 1037 | − Loading tool inventory... | |
| 1038 | − </div> | |
| 1039 | − {enabledSources.length > 0 && ( | |
| 1040 | − <div className="space-y-1"> | |
| 1041 | − {enabledSources.map((s) => ( | |
| 1042 | − <div | |
| 1043 | − key={s.id} | |
| 1044 | − className="flex items-center gap-2 rounded-md border border-border bg-background/70 px-3 py-2 text-xs" | |
| 1045 | − > | |
| 1046 | − <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> | |
| 1047 | − <span className="font-medium truncate">{displaySourceName(s.name)}</span> | |
| 1048 | − <span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"> | |
| 1049 | − {s.type} | |
| 1050 | − </span> | |
| 1051 | − </div> | |
| 1052 | − ))} | |
| 1053 | − </div> | |
| 1054 | − )} | |
| 1055 | − </div> | |
| 1056 | − ); | |
| 1057 | − } | |
| 200 | + if (loading) return <ToolExplorerLoading sources={sources} />; | |
| 1058 | 201 | |
| 1059 | 202 | return ( |
| 1060 | 203 | <div className="flex" onWheelCapture={handleExplorerWheel}> |
| @@ -1066,284 +209,37 @@ export function ToolExplorer({ · 5 unmodified lines | ||
| 1066 | 209 | /> |
| 1067 | 210 | ) : null} |
| 1068 | 211 | |
| 1069 | − {/* Main content */} | |
| 1070 | − <div className={cn("flex-1 min-w-0 flex flex-col", showSourceSidebar ? "pl-0 lg:pl-4" : "pl-0")}> | |
| 1071 | − <div className="shrink-0"> | |
| 1072 | − {/* Toolbar */} | |
| 1073 | − <div className="flex items-center gap-2 pb-3 flex-wrap"> | |
| 1074 | − {/* Search */} | |
| 1075 | − <div className="relative flex-1 min-w-[200px]"> | |
| 1076 | − <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" /> | |
| 1077 | − <Input | |
| 1078 | − value={search} | |
| 1079 | − onChange={(e) => setSearch(e.target.value)} | |
| 1080 | − placeholder={`Search ${filteredTools.length} tools...`} | |
| 1081 | − className="h-8 text-xs pl-8 bg-background/50 border-border/50 focus:border-primary/30" | |
| 1082 | − /> | |
| 1083 | − {search && ( | |
| 1084 | − <button | |
| 1085 | − onClick={() => setSearch("")} | |
| 1086 | − className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 rounded flex items-center justify-center text-muted-foreground hover:text-foreground" | |
| 1087 | − > | |
| 1088 | − <X className="h-3 w-3" /> | |
| 1089 | − </button> | |
| 1090 | − )} | |
| 1091 | − </div> | |
| 1092 | − | |
| 1093 | − {/* View mode toggle */} | |
| 1094 | − <div className="flex h-8 rounded-md border border-border/50 overflow-hidden"> | |
| 1095 | − <button | |
| 1096 | − onClick={() => setViewMode("tree")} | |
| 1097 | − className={cn( | |
| 1098 | − "px-2 flex items-center gap-1 text-[11px] transition-colors", | |
| 1099 | − viewMode === "tree" | |
| 1100 | − ? "bg-accent/40 text-foreground" | |
| 1101 | − : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | |
| 1102 | − )} | |
| 1103 | − > | |
| 1104 | − <FolderTree className="h-3 w-3" /> | |
| 1105 | − <span className="hidden sm:inline">Tree</span> | |
| 1106 | − </button> | |
| 1107 | − <button | |
| 1108 | − onClick={() => setViewMode("flat")} | |
| 1109 | − className={cn( | |
| 1110 | − "px-2 flex items-center gap-1 text-[11px] transition-colors border-l border-border/50", | |
| 1111 | − viewMode === "flat" | |
| 1112 | − ? "bg-accent/40 text-foreground" | |
| 1113 | − : "text-muted-foreground hover:text-foreground hover:bg-accent/20", | |
| 1114 | − )} | |
| 1115 | − > | |
| 1116 | − <List className="h-3 w-3" /> | |
| 1117 | − <span className="hidden sm:inline">Flat</span> | |
| 1118 | − </button> | |
| 1119 | − </div> | |
| 1120 | − | |
| 1121 | − {/* Group by (only in tree mode) */} | |
| 1122 | − {viewMode === "tree" && ( | |
| 1123 | − <DropdownMenu> | |
| 1124 | − <DropdownMenuTrigger asChild> | |
| 1125 | − <Button | |
| 1126 | − variant="outline" | |
| 1127 | − size="sm" | |
| 1128 | − className="h-8 text-[11px] border-border/50" | |
| 1129 | − > | |
| 1130 | − <FolderTree className="h-3 w-3 mr-1" /> | |
| 1131 | − {groupBy === "source" | |
| 1132 | − ? "By source" | |
| 1133 | − : groupBy === "namespace" | |
| 1134 | − ? "By namespace" | |
| 1135 | − : "By approval"} | |
| 1136 | − </Button> | |
| 1137 | − </DropdownMenuTrigger> | |
| 1138 | − <DropdownMenuContent align="end"> | |
| 1139 | − <DropdownMenuLabel className="text-[11px]"> | |
| 1140 | − Group by | |
| 1141 | − </DropdownMenuLabel> | |
| 1142 | − <DropdownMenuSeparator /> | |
| 1143 | − <DropdownMenuCheckboxItem | |
| 1144 | − checked={groupBy === "source"} | |
| 1145 | − onCheckedChange={() => setGroupBy("source")} | |
| 1146 | − className="text-xs" | |
| 1147 | − > | |
| 1148 | − Source → Namespace | |
| 1149 | − </DropdownMenuCheckboxItem> | |
| 1150 | − <DropdownMenuCheckboxItem | |
| 1151 | − checked={groupBy === "namespace"} | |
| 1152 | − onCheckedChange={() => setGroupBy("namespace")} | |
| 1153 | − className="text-xs" | |
| 1154 | − > | |
| 1155 | − Namespace | |
| 1156 | − </DropdownMenuCheckboxItem> | |
| 1157 | − <DropdownMenuCheckboxItem | |
| 1158 | − checked={groupBy === "approval"} | |
| 1159 | − onCheckedChange={() => setGroupBy("approval")} | |
| 1160 | − className="text-xs" | |
| 1161 | − > | |
| 1162 | − Approval mode | |
| 1163 | − </DropdownMenuCheckboxItem> | |
| 1164 | − </DropdownMenuContent> | |
| 1165 | − </DropdownMenu> | |
| 1166 | − )} | |
| 1167 | − | |
| 1168 | − {/* Filter */} | |
| 1169 | − <DropdownMenu> | |
| 1170 | − <DropdownMenuTrigger asChild> | |
| 1171 | − <Button | |
| 1172 | − variant="outline" | |
| 1173 | − size="sm" | |
| 1174 | − className={cn( | |
| 1175 | − "h-8 text-[11px] border-border/50", | |
| 1176 | − filterApproval !== "all" && | |
| 1177 | − "border-primary/30 text-primary", | |
| 1178 | − )} | |
| 1179 | − > | |
| 1180 | − <Filter className="h-3 w-3 mr-1" /> | |
| 1181 | − {filterApproval === "all" | |
| 1182 | − ? "Filter" | |
| 1183 | − : filterApproval === "required" | |
| 1184 | − ? "Gated only" | |
| 1185 | − : "Auto only"} | |
| 1186 | − </Button> | |
| 1187 | − </DropdownMenuTrigger> | |
| 1188 | − <DropdownMenuContent align="end"> | |
| 1189 | − <DropdownMenuLabel className="text-[11px]"> | |
| 1190 | − Approval filter | |
| 1191 | − </DropdownMenuLabel> | |
| 1192 | − <DropdownMenuSeparator /> | |
| 1193 | − <DropdownMenuCheckboxItem | |
| 1194 | − checked={filterApproval === "all"} | |
| 1195 | − onCheckedChange={() => setFilterApproval("all")} | |
| 1196 | − className="text-xs" | |
| 1197 | − > | |
| 1198 | − All tools | |
| 1199 | − </DropdownMenuCheckboxItem> | |
| 1200 | − <DropdownMenuCheckboxItem | |
| 1201 | − checked={filterApproval === "required"} | |
| 1202 | − onCheckedChange={() => setFilterApproval("required")} | |
| 1203 | − className="text-xs" | |
| 1204 | − > | |
| 1205 | − Approval required | |
| 1206 | − </DropdownMenuCheckboxItem> | |
| 1207 | − <DropdownMenuCheckboxItem | |
| 1208 | − checked={filterApproval === "auto"} | |
| 1209 | − onCheckedChange={() => setFilterApproval("auto")} | |
| 1210 | − className="text-xs" | |
| 1211 | − > | |
| 1212 | − Auto-approved | |
| 1213 | − </DropdownMenuCheckboxItem> | |
| 1214 | − </DropdownMenuContent> | |
| 1215 | − </DropdownMenu> | |
| 1216 | − | |
| 1217 | − {/* Mobile source selector */} | |
| 1218 | − <DropdownMenu> | |
| 1219 | − <DropdownMenuTrigger asChild> | |
| 1220 | − <Button | |
| 1221 | − variant="outline" | |
| 1222 | − size="sm" | |
| 1223 | − className={cn( | |
| 1224 | − "h-8 text-[11px] border-border/50", | |
| 1225 | − showSourceSidebar && "lg:hidden", | |
| 1226 | − resolvedActiveSource && "border-primary/30 text-primary", | |
| 1227 | − )} | |
| 1228 | − > | |
| 1229 | − <Server className="h-3 w-3 mr-1" /> | |
| 1230 | − {resolvedActiveSource ?? "All"} | |
| 1231 | − </Button> | |
| 1232 | − </DropdownMenuTrigger> | |
| 1233 | − <DropdownMenuContent align="end"> | |
| 1234 | − <DropdownMenuLabel className="text-[11px]"> | |
| 1235 | − Source | |
| 1236 | − </DropdownMenuLabel> | |
| 1237 | − <DropdownMenuSeparator /> | |
| 1238 | − <DropdownMenuCheckboxItem | |
| 1239 | − checked={resolvedActiveSource === null} | |
| 1240 | − onCheckedChange={() => handleSourceSelect(null)} | |
| 1241 | − className="text-xs" | |
| 1242 | − > | |
| 1243 | − All sources | |
| 1244 | − </DropdownMenuCheckboxItem> | |
| 1245 | − {Array.from( | |
| 1246 | − new Set(tools.map((t) => sourceLabel(t.source))), | |
| 1247 | − ).map((src) => ( | |
| 1248 | − <DropdownMenuCheckboxItem | |
| 1249 | − key={src} | |
| 1250 | − checked={resolvedActiveSource === src} | |
| 1251 | − onCheckedChange={() => | |
| 1252 | − handleSourceSelect( | |
| 1253 | − resolvedActiveSource === src ? null : src, | |
| 1254 | − ) | |
| 1255 | − } | |
| 1256 | − className="text-xs font-mono" | |
| 1257 | − > | |
| 1258 | − {src} | |
| 1259 | − </DropdownMenuCheckboxItem> | |
| 1260 | − ))} | |
| 1261 | − </DropdownMenuContent> | |
| 1262 | − </DropdownMenu> | |
| 1263 | − </div> | |
| 1264 | − | |
| 1265 | − {/* Selection bar */} | |
| 1266 | − {selectedToolCount > 0 && ( | |
| 1267 | − <div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-md bg-primary/5 border border-primary/10"> | |
| 1268 | − <span className="text-[12px] font-mono text-primary"> | |
| 1269 | − {selectedToolCount} tool | |
| 1270 | − {selectedToolCount !== 1 ? "s" : ""} selected | |
| 1271 | − </span> | |
| 1272 | − <div className="flex-1" /> | |
| 1273 | − <Button | |
| 1274 | − variant="ghost" | |
| 1275 | − size="sm" | |
| 1276 | − className="h-6 text-[11px] text-muted-foreground" | |
| 1277 | − onClick={selectAll} | |
| 1278 | − > | |
| 1279 | − Select all ({filteredTools.length}) | |
| 1280 | − </Button> | |
| 1281 | − <Button | |
| 1282 | − variant="ghost" | |
| 1283 | − size="sm" | |
| 1284 | − className="h-6 text-[11px] text-muted-foreground" | |
| 1285 | − onClick={clearSelection} | |
| 1286 | − > | |
| 1287 | − Clear | |
| 1288 | − </Button> | |
| 1289 | − <div className="h-4 w-px bg-border/50" /> | |
| 1290 | − <Button | |
| 1291 | − variant="outline" | |
| 1292 | − size="sm" | |
| 1293 | − className="h-6 text-[11px] border-terminal-amber/30 text-terminal-amber hover:bg-terminal-amber/10" | |
| 1294 | − > | |
| 1295 | − <ShieldCheck className="h-3 w-3 mr-1" /> | |
| 1296 | − Set approval | |
| 1297 | − </Button> | |
| 1298 | − </div> | |
| 1299 | − )} | |
| 1300 | − | |
| 1301 | − {/* Warnings */} | |
| 1302 | − {warnings.length > 0 && ( | |
| 1303 | − <div className="mb-2 rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2"> | |
| 1304 | − <p className="text-[11px] text-terminal-amber/80"> | |
| 1305 | − {warnings.length} source warning | |
| 1306 | − {warnings.length !== 1 ? "s" : ""} — inventory may be | |
| 1307 | − incomplete | |
| 1308 | − </p> | |
| 1309 | − </div> | |
| 1310 | − )} | |
| 1311 | − | |
| 1312 | − {/* Results info */} | |
| 1313 | − <div className="flex items-center justify-between pb-1.5"> | |
| 1314 | − <span className="text-[10px] font-mono text-muted-foreground/50"> | |
| 1315 | − {search | |
| 1316 | − ? `${searchedTools.length} results` | |
| 1317 | − : `${filteredTools.length} tools`} | |
| 1318 | − {resolvedActiveSource && ` in ${resolvedActiveSource}`} | |
| 1319 | − </span> | |
| 1320 | − {viewMode === "tree" && ( | |
| 1321 | − <div className="flex gap-1"> | |
| 1322 | − <button | |
| 1323 | − onClick={() => { | |
| 1324 | − setExpandedKeys(collectGroupKeys(treeGroups)); | |
| 1325 | − }} | |
| 1326 | − className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors" | |
| 1327 | − > | |
| 1328 | − Expand all | |
| 1329 | − </button> | |
| 1330 | − <span className="text-[10px] text-muted-foreground/30"> | |
| 1331 | − · | |
| 1332 | − </span> | |
| 1333 | − <button | |
| 1334 | − onClick={() => { | |
| 1335 | − setExpandedKeys(new Set()); | |
| 1336 | − }} | |
| 1337 | − className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors" | |
| 1338 | − > | |
| 1339 | − Collapse all | |
| 1340 | − </button> | |
| 1341 | − </div> | |
| 1342 | − )} | |
| 1343 | − </div> | |
| 1344 | − </div> | |
| 212 | + <div | |
| 213 | + className={cn( | |
| 214 | + "flex-1 min-w-0 flex flex-col", | |
| 215 | + showSourceSidebar ? "pl-0 lg:pl-4" : "pl-0", | |
| 216 | + )} | |
| 217 | + > | |
| 218 | + <ToolExplorerToolbar | |
| 219 | + search={search} | |
| 220 | + filteredToolCount={filteredTools.length} | |
| 221 | + hasSearch={search.length > 0} | |
| 222 | + resultCount={searchedTools.length} | |
| 223 | + viewMode={viewMode} | |
| 224 | + groupBy={groupBy} | |
| 225 | + filterApproval={filterApproval} | |
| 226 | + showSourceSidebar={showSourceSidebar} | |
| 227 | + activeSource={resolvedActiveSource} | |
| 228 | + sourceOptions={sourceOptions} | |
| 229 | + selectedToolCount={selectedToolCount} | |
| 230 | + warningsCount={warnings.length} | |
| 231 | + onSearchChange={setSearch} | |
| 232 | + onClearSearch={() => setSearch("")} | |
| 233 | + onViewModeChange={setViewMode} | |
| 234 | + onGroupByChange={setGroupBy} | |
| 235 | + onFilterApprovalChange={setFilterApproval} | |
| 236 | + onSourceSelect={handleSourceSelect} | |
| 237 | + onSelectAll={selectAll} | |
| 238 | + onClearSelection={clearSelection} | |
| 239 | + onExpandAll={handleExpandAll} | |
| 240 | + onCollapseAll={handleCollapseAll} | |
| 241 | + /> | |
| 1345 | 242 | |
| 1346 | − {/* Scrollable list */} | |
| 1347 | 243 | {viewMode === "flat" ? ( |
| 1348 | 244 | flatTools.length === 0 ? ( |
| 1349 | 245 | <div |
| @@ -1390,52 +286,3 @@ export function ToolExplorer({ · 40 unmodified lines | ||
| 1390 | 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 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useEffect, useMemo, useState } from "react"; | |
| 3 | +import { useState } from "react"; | |
| 4 | 4 | import { |
| 5 | − Wrench, | |
| 6 | − Plus, | |
| 7 | − Trash2, | |
| 8 | 5 | Globe, |
| 9 | 6 | Server, |
| 10 | − ChevronRight, | |
| 11 | − AlertTriangle, | |
| 12 | − KeyRound, | |
| 13 | − Pencil, | |
| 14 | 7 | } from "lucide-react"; |
| 15 | 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 16 | 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 | 10 | import { Skeleton } from "@/components/ui/skeleton"; |
| 42 | 11 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| 43 | 12 | import { PageHeader } from "@/components/page-header"; |
| 44 | 13 | import { McpSetupCard } from "@/components/mcp-setup-card"; |
| 45 | 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 | 17 | import { useSession } from "@/lib/session-context"; |
| 47 | 18 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 48 | −import { useAction, useMutation, useQuery } from "convex/react"; | |
| 19 | +import { useQuery } from "convex/react"; | |
| 49 | 20 | import { convexApi } from "@/lib/convex-api"; |
| 50 | 21 | import type { |
| 51 | 22 | ToolSourceRecord, |
| 52 | 23 | CredentialRecord, |
| 53 | − CredentialScope, | |
| 54 | − SourceAuthProfile, | |
| 55 | − OpenApiSourceQuality, | |
| 56 | 24 | } from "@/lib/types"; |
| 57 | −import { parse as parseDomain } from "tldts"; | |
| 58 | −import { toast } from "sonner"; | |
| 59 | −import { cn } from "@/lib/utils"; | |
| 60 | −import { Streamdown } from "streamdown"; | |
| 61 | − | |
| 62 | −interface CatalogCollectionItem { | |
| 63 | − id: string; | |
| 64 | − name: string; | |
| 65 | − summary: string; | |
| 66 | − specUrl: string; | |
| 67 | − originUrl?: string; | |
| 68 | − providerName: string; | |
| 69 | − logoUrl?: string; | |
| 70 | − categories?: string; | |
| 71 | − version?: string; | |
| 72 | − rank: number; | |
| 73 | − addedAt: string; | |
| 74 | −} | |
| 75 | − | |
| 76 | −const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [ | |
| 77 | − { | |
| 78 | − id: "github-rest", | |
| 79 | − name: "GitHub REST API", | |
| 80 | − summary: "Manage repositories, pull requests, issues, and org settings.", | |
| 81 | − specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | |
| 82 | − originUrl: "https://docs.github.com/en/rest", | |
| 83 | − providerName: "GitHub", | |
| 84 | − categories: "developer-tools", | |
| 85 | − version: "latest", | |
| 86 | − rank: 1, | |
| 87 | − addedAt: "2026-01-10", | |
| 88 | − }, | |
| 89 | − { | |
| 90 | − id: "stripe-api", | |
| 91 | − name: "Stripe API", | |
| 92 | − summary: "Create payments, manage customers, and handle billing workflows.", | |
| 93 | − specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | |
| 94 | − originUrl: "https://docs.stripe.com/api", | |
| 95 | − providerName: "Stripe", | |
| 96 | − categories: "payments", | |
| 97 | − version: "2026-01", | |
| 98 | − rank: 2, | |
| 99 | − addedAt: "2026-01-08", | |
| 100 | − }, | |
| 101 | − { | |
| 102 | − id: "openai-api", | |
| 103 | − name: "OpenAI API", | |
| 104 | − summary: "Generate text, run reasoning models, and process multimodal inputs.", | |
| 105 | − specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | |
| 106 | − originUrl: "https://platform.openai.com/docs/api-reference", | |
| 107 | − providerName: "OpenAI", | |
| 108 | − categories: "ai", | |
| 109 | − version: "latest", | |
| 110 | − rank: 3, | |
| 111 | − addedAt: "2026-01-06", | |
| 112 | − }, | |
| 113 | − { | |
| 114 | − id: "cloudflare-api", | |
| 115 | − name: "Cloudflare API", | |
| 116 | − summary: "Control zones, DNS records, workers, and edge configuration.", | |
| 117 | − specUrl: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml", | |
| 118 | − originUrl: "https://api.cloudflare.com/", | |
| 119 | − providerName: "Cloudflare", | |
| 120 | − categories: "infrastructure", | |
| 121 | − version: "latest", | |
| 122 | − rank: 4, | |
| 123 | − addedAt: "2026-01-04", | |
| 124 | − }, | |
| 125 | − { | |
| 126 | − id: "vercel-api", | |
| 127 | − name: "Vercel API", | |
| 128 | − summary: "Manage deployments, projects, domains, and team resources.", | |
| 129 | − specUrl: "https://openapi.vercel.sh", | |
| 130 | − originUrl: "https://vercel.com/docs/rest-api", | |
| 131 | − providerName: "Vercel", | |
| 132 | − categories: "developer-tools", | |
| 133 | − version: "latest", | |
| 134 | − rank: 5, | |
| 135 | − addedAt: "2025-12-18", | |
| 136 | − }, | |
| 137 | − { | |
| 138 | − id: "slack-api", | |
| 139 | − name: "Slack API", | |
| 140 | − summary: "Work with channels, messages, users, and workspace automation.", | |
| 141 | − specUrl: "https://api.slack.com/specs/openapi/v2/slack_web.json", | |
| 142 | − originUrl: "https://api.slack.com/web", | |
| 143 | − providerName: "Slack", | |
| 144 | − categories: "communications", | |
| 145 | − version: "v2", | |
| 146 | − rank: 6, | |
| 147 | − addedAt: "2025-12-10", | |
| 148 | − }, | |
| 149 | − { | |
| 150 | − id: "sentry-api", | |
| 151 | − name: "Sentry API", | |
| 152 | − summary: "Query issues, releases, projects, and alerting configuration.", | |
| 153 | − specUrl: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json", | |
| 154 | − originUrl: "https://docs.sentry.io/api/", | |
| 155 | − providerName: "Sentry", | |
| 156 | − categories: "observability", | |
| 157 | − version: "latest", | |
| 158 | − rank: 7, | |
| 159 | − addedAt: "2025-11-30", | |
| 160 | − }, | |
| 161 | − { | |
| 162 | − id: "jira-cloud-api", | |
| 163 | − name: "Jira Cloud Platform", | |
| 164 | − summary: "Manage projects, issues, workflows, and Jira metadata.", | |
| 165 | − specUrl: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", | |
| 166 | − originUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/", | |
| 167 | − providerName: "Atlassian", | |
| 168 | − categories: "project-management", | |
| 169 | − version: "v3", | |
| 170 | − rank: 8, | |
| 171 | − addedAt: "2025-11-15", | |
| 172 | − }, | |
| 173 | −]; | |
| 174 | − | |
| 175 | −/** Derive a favicon URL from any URL string via Google's favicon service. */ | |
| 176 | −function faviconForUrl(url: string | undefined | null): string | null { | |
| 177 | − if (!url) return null; | |
| 178 | − try { | |
| 179 | − const domain = new URL(url).hostname; | |
| 180 | − return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; | |
| 181 | − } catch { | |
| 182 | − return null; | |
| 183 | − } | |
| 184 | −} | |
| 185 | − | |
| 186 | −function getSourceFavicon(source: ToolSourceRecord): string | null { | |
| 187 | − if (source.type === "mcp") { | |
| 188 | − return faviconForUrl((source.config.url as string) ?? null); | |
| 189 | − } | |
| 190 | − if (source.type === "graphql") { | |
| 191 | − return faviconForUrl((source.config.endpoint as string) ?? null); | |
| 192 | − } | |
| 193 | − const spec = source.config.spec as string | undefined; | |
| 194 | − if (typeof spec === "string" && spec.startsWith("postman:")) { | |
| 195 | − return null; | |
| 196 | − } | |
| 197 | − const baseUrl = source.config.baseUrl as string | undefined; | |
| 198 | − const collectionUrl = source.config.collectionUrl as string | undefined; | |
| 199 | − const specUrl = typeof spec === "string" && spec.startsWith("http") ? spec : null; | |
| 200 | − return faviconForUrl(baseUrl ?? collectionUrl ?? specUrl); | |
| 201 | −} | |
| 202 | − | |
| 203 | −function sourceEndpointLabel(source: ToolSourceRecord): string { | |
| 204 | − if (source.type === "mcp") return (source.config.url as string) ?? ""; | |
| 205 | − if (source.type === "graphql") return (source.config.endpoint as string) ?? ""; | |
| 206 | − | |
| 207 | − const spec = source.config.spec; | |
| 208 | − if (typeof spec === "string" && spec.startsWith("postman:")) { | |
| 209 | − const uid = spec.slice("postman:".length).trim(); | |
| 210 | − if (uid.length > 0) { | |
| 211 | − return `catalog:${uid}`; | |
| 212 | − } | |
| 213 | − return "catalog:collection"; | |
| 214 | − } | |
| 215 | − | |
| 216 | − return (source.config.spec as string) ?? ""; | |
| 217 | −} | |
| 218 | − | |
| 219 | −function sourceKeyForSource(source: ToolSourceRecord): string | null { | |
| 220 | − if (source.type === "openapi") return `source:${source.id}`; | |
| 221 | − if (source.type === "graphql") return `source:${source.id}`; | |
| 222 | − return null; | |
| 223 | −} | |
| 224 | − | |
| 225 | −function toolSourceLabelForSource(source: ToolSourceRecord): string { | |
| 226 | − return `${source.type}:${source.name}`; | |
| 227 | −} | |
| 228 | − | |
| 229 | −function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): ToolSourceRecord | null { | |
| 230 | − const prefix = "source:"; | |
| 231 | − if (!sourceKey.startsWith(prefix)) return null; | |
| 232 | − const sourceId = sourceKey.slice(prefix.length); | |
| 233 | − if (!sourceId) return null; | |
| 234 | − return sources.find((source) => source.id === sourceId) ?? null; | |
| 235 | −} | |
| 236 | − | |
| 237 | −type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed"; | |
| 238 | −type SourceAuthMode = "workspace" | "actor"; | |
| 239 | − | |
| 240 | −function normalizeSourceAuthProfile(profile: SourceAuthProfile | undefined): { | |
| 241 | − type: SourceAuthType; | |
| 242 | − mode?: SourceAuthMode; | |
| 243 | − header?: string; | |
| 244 | − inferred?: boolean; | |
| 245 | −} { | |
| 246 | − if (!profile) { | |
| 247 | − return { type: "none" }; | |
| 248 | − } | |
| 249 | − | |
| 250 | − const type = profile.type === "mixed" | |
| 251 | − ? "mixed" | |
| 252 | − : profile.type === "basic" | |
| 253 | − ? "basic" | |
| 254 | − : profile.type === "apiKey" | |
| 255 | − ? "apiKey" | |
| 256 | − : profile.type === "bearer" | |
| 257 | − ? "bearer" | |
| 258 | − : "none"; | |
| 259 | − | |
| 260 | − const mode = profile.mode === "actor" ? "actor" : profile.mode === "workspace" ? "workspace" : undefined; | |
| 261 | − const header = typeof profile.header === "string" && profile.header.trim().length > 0 | |
| 262 | − ? profile.header.trim() | |
| 263 | − : undefined; | |
| 264 | − | |
| 265 | − return { | |
| 266 | − type, | |
| 267 | − ...(mode ? { mode } : {}), | |
| 268 | − ...(header ? { header } : {}), | |
| 269 | − inferred: Boolean(profile.inferred), | |
| 270 | − }; | |
| 271 | −} | |
| 272 | − | |
| 273 | −function readSourceAuth( | |
| 274 | − source: ToolSourceRecord, | |
| 275 | − inferredProfile?: SourceAuthProfile, | |
| 276 | −): { | |
| 277 | − type: SourceAuthType; | |
| 278 | − mode?: SourceAuthMode; | |
| 279 | − header?: string; | |
| 280 | − inferred?: boolean; | |
| 281 | −} { | |
| 282 | − if (source.type !== "openapi" && source.type !== "graphql") { | |
| 283 | − return { type: "none" }; | |
| 284 | − } | |
| 285 | − | |
| 286 | − const inferred = normalizeSourceAuthProfile(inferredProfile); | |
| 287 | − | |
| 288 | − const auth = source.config.auth as Record<string, unknown> | undefined; | |
| 289 | − const type = | |
| 290 | − auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic", "mixed"].includes(auth.type) | |
| 291 | − ? (auth.type as SourceAuthType) | |
| 292 | − : inferred.type; | |
| 293 | − | |
| 294 | − const mode = | |
| 295 | − auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor") | |
| 296 | − ? (auth.mode as SourceAuthMode) | |
| 297 | − : inferred.mode; | |
| 298 | − | |
| 299 | − const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0 | |
| 300 | − ? auth.header.trim() | |
| 301 | − : inferred.header; | |
| 302 | − | |
| 303 | − return { | |
| 304 | − type, | |
| 305 | − ...(mode ? { mode } : {}), | |
| 306 | − ...(header ? { header } : {}), | |
| 307 | − inferred: auth?.type === undefined ? inferred.inferred : false, | |
| 308 | − }; | |
| 309 | −} | |
| 310 | − | |
| 311 | −function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null { | |
| 312 | − const auth = readSourceAuth(source, inferredProfile); | |
| 313 | − if (auth.type === "none") return null; | |
| 314 | − if (auth.type === "mixed") return "Mixed auth"; | |
| 315 | − const mode = auth.mode ?? "workspace"; | |
| 316 | − const authLabel = | |
| 317 | − auth.type === "apiKey" | |
| 318 | − ? "API Key" | |
| 319 | − : auth.type === "bearer" | |
| 320 | − ? "Bearer" | |
| 321 | − : auth.type === "basic" | |
| 322 | − ? "Basic" | |
| 323 | − : "Auth"; | |
| 324 | − return `${authLabel} · ${mode === "actor" ? "user" : "workspace"}`; | |
| 325 | −} | |
| 326 | − | |
| 327 | −function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): { | |
| 328 | − workspaceCount: number; | |
| 329 | − actorCount: number; | |
| 330 | −} { | |
| 331 | − const sourceKey = sourceKeyForSource(source); | |
| 332 | − if (!sourceKey) { | |
| 333 | − return { workspaceCount: 0, actorCount: 0 }; | |
| 334 | − } | |
| 335 | − | |
| 336 | − let workspaceCount = 0; | |
| 337 | − let actorCount = 0; | |
| 338 | − for (const credential of credentials) { | |
| 339 | − if (credential.sourceKey !== sourceKey) continue; | |
| 340 | − if (credential.scope === "workspace") workspaceCount += 1; | |
| 341 | − if (credential.scope === "actor") actorCount += 1; | |
| 342 | − } | |
| 343 | − | |
| 344 | − return { workspaceCount, actorCount }; | |
| 345 | −} | |
| 346 | − | |
| 347 | −function formatQualityPercent(value: number): string { | |
| 348 | − return `${Math.round(value * 100)}%`; | |
| 349 | −} | |
| 350 | − | |
| 351 | −function qualityToneClass(quality: OpenApiSourceQuality): string { | |
| 352 | − if (quality.overallQuality >= 0.95) { | |
| 353 | − return "text-terminal-green"; | |
| 354 | − } | |
| 355 | − if (quality.overallQuality >= 0.85) { | |
| 356 | − return "text-terminal-amber"; | |
| 357 | − } | |
| 358 | − return "text-terminal-red"; | |
| 359 | −} | |
| 360 | − | |
| 361 | −function qualitySummaryLabel(quality: OpenApiSourceQuality): string { | |
| 362 | − if (quality.overallQuality >= 0.95) { | |
| 363 | − return "strong typing"; | |
| 364 | − } | |
| 365 | − if (quality.overallQuality >= 0.85) { | |
| 366 | − return "mostly typed"; | |
| 367 | − } | |
| 368 | − return "needs type cleanup"; | |
| 369 | −} | |
| 370 | − | |
| 371 | −function displaySourceName(name: string): string { | |
| 372 | − const parts = name.split(/[-_.]+/).filter(Boolean); | |
| 373 | − if (parts.length === 0) return name; | |
| 374 | − | |
| 375 | − const deduped = parts.filter((part, index, all) => { | |
| 376 | − if (index === 0) return true; | |
| 377 | − return part.toLowerCase() !== all[index - 1]?.toLowerCase(); | |
| 378 | − }); | |
| 379 | − | |
| 380 | − const tokenMap: Record<string, string> = { | |
| 381 | − api: "API", | |
| 382 | − oauth: "OAuth", | |
| 383 | − graphql: "GraphQL", | |
| 384 | − mcp: "MCP", | |
| 385 | − github: "GitHub", | |
| 386 | − }; | |
| 387 | − | |
| 388 | − return deduped | |
| 389 | − .map((token) => { | |
| 390 | − const lower = token.toLowerCase(); | |
| 391 | − if (tokenMap[lower]) return tokenMap[lower]; | |
| 392 | − return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`; | |
| 393 | − }) | |
| 394 | − .join(" "); | |
| 395 | −} | |
| 396 | − | |
| 397 | −function compactEndpointLabel(source: ToolSourceRecord): string { | |
| 398 | − const endpoint = sourceEndpointLabel(source); | |
| 399 | − if (endpoint.startsWith("catalog:")) return endpoint; | |
| 400 | − try { | |
| 401 | − const parsed = new URL(endpoint); | |
| 402 | − const path = parsed.pathname === "/" ? "" : parsed.pathname; | |
| 403 | − return `${parsed.hostname}${path}`; | |
| 404 | − } catch { | |
| 405 | − return endpoint; | |
| 406 | − } | |
| 407 | −} | |
| 408 | − | |
| 409 | −// ── Add Source Dialog ── | |
| 410 | − | |
| 411 | −const RAW_HOSTS = new Set([ | |
| 412 | − "raw.githubusercontent.com", | |
| 413 | − "cdn.jsdelivr.net", | |
| 414 | − "unpkg.com", | |
| 415 | − "raw.github.com", | |
| 416 | −]); | |
| 417 | − | |
| 418 | −function inferNameFromUrl(url: string): string { | |
| 419 | − try { | |
| 420 | − const u = new URL(url); | |
| 421 | − const parsed = parseDomain(url); | |
| 422 | − | |
| 423 | − if (RAW_HOSTS.has(u.hostname)) { | |
| 424 | − const segments = u.pathname.split("/").filter(Boolean); | |
| 425 | − if (segments.length > 0) return segments[0].toLowerCase(); | |
| 426 | − } | |
| 427 | − | |
| 428 | − if (parsed.domainWithoutSuffix) { | |
| 429 | − return parsed.domainWithoutSuffix; | |
| 430 | − } | |
| 431 | − | |
| 432 | − if (parsed.domain) { | |
| 433 | − return parsed.domain.split(".")[0]; | |
| 434 | − } | |
| 435 | − | |
| 436 | − return u.hostname.replace(/\./g, "-"); | |
| 437 | − } catch { | |
| 438 | − return ""; | |
| 439 | − } | |
| 440 | −} | |
| 441 | − | |
| 442 | −function sanitizeSourceName(value: string): string { | |
| 443 | − const slug = value | |
| 444 | − .toLowerCase() | |
| 445 | − .replace(/[^a-z0-9]+/g, "-") | |
| 446 | − .replace(/-+/g, "-") | |
| 447 | − .replace(/^-|-$/g, "") | |
| 448 | − .slice(0, 48); | |
| 449 | − return slug || "source"; | |
| 450 | −} | |
| 451 | − | |
| 452 | −function withUniqueSourceName(baseName: string, takenNames: Set<string>): string { | |
| 453 | − const loweredTaken = new Set([...takenNames].map((name) => name.toLowerCase())); | |
| 454 | − const candidate = sanitizeSourceName(baseName); | |
| 455 | − if (!loweredTaken.has(candidate.toLowerCase())) { | |
| 456 | − return candidate; | |
| 457 | − } | |
| 458 | − | |
| 459 | − let suffix = 2; | |
| 460 | − while (true) { | |
| 461 | − const next = `${candidate}-${suffix}`; | |
| 462 | − if (!loweredTaken.has(next.toLowerCase())) { | |
| 463 | − return next; | |
| 464 | − } | |
| 465 | − suffix += 1; | |
| 466 | − } | |
| 467 | −} | |
| 468 | − | |
| 469 | −function catalogSourceName(item: CatalogCollectionItem): string { | |
| 470 | − const owner = sanitizeSourceName(item.providerName || "catalog"); | |
| 471 | − const title = sanitizeSourceName(item.name); | |
| 472 | − return sanitizeSourceName(`${owner}-${title}`); | |
| 473 | −} | |
| 474 | − | |
| 475 | −function AddSourceDialog({ | |
| 476 | − existingSourceNames, | |
| 477 | − onSourceAdded, | |
| 478 | −}: { | |
| 479 | − existingSourceNames: Set<string>; | |
| 480 | − onSourceAdded?: (source: ToolSourceRecord) => void; | |
| 481 | −}) { | |
| 482 | − const { context } = useSession(); | |
| 483 | − const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | |
| 484 | − const [open, setOpen] = useState(false); | |
| 485 | − const [view, setView] = useState<"catalog" | "custom">("catalog"); | |
| 486 | − const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp"); | |
| 487 | − const [name, setName] = useState(""); | |
| 488 | − const [nameManuallyEdited, setNameManuallyEdited] = useState(false); | |
| 489 | − const [endpoint, setEndpoint] = useState(""); | |
| 490 | − const [baseUrl, setBaseUrl] = useState(""); | |
| 491 | − const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto"); | |
| 492 | − const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId"); | |
| 493 | − const [submitting, setSubmitting] = useState(false); | |
| 494 | − const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]); | |
| 495 | − const [catalogQuery, setCatalogQuery] = useState(""); | |
| 496 | − const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular"); | |
| 497 | − const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null); | |
| 498 | − | |
| 499 | − const visibleCatalogItems = useMemo(() => { | |
| 500 | − const query = catalogQuery.trim().toLowerCase(); | |
| 501 | − const filtered = HARD_CODED_CATALOG_ITEMS.filter((item) => { | |
| 502 | − if (!query) return true; | |
| 503 | − return [ | |
| 504 | − item.name, | |
| 505 | − item.providerName, | |
| 506 | − item.summary, | |
| 507 | − item.categories ?? "", | |
| 508 | − ].some((value) => value.toLowerCase().includes(query)); | |
| 509 | − }); | |
| 510 | − | |
| 511 | − return [...filtered].sort((a, b) => { | |
| 512 | − if (catalogSort === "recent") { | |
| 513 | − return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime(); | |
| 514 | − } | |
| 515 | − return a.rank - b.rank; | |
| 516 | − }); | |
| 517 | − }, [catalogQuery, catalogSort]); | |
| 518 | − | |
| 519 | − const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]); | |
| 520 | − | |
| 521 | − const reserveSourceName = (sourceName: string) => { | |
| 522 | − setLocallyReservedNames((current) => | |
| 523 | − current.includes(sourceName) | |
| 524 | − ? current | |
| 525 | − : [...current, sourceName] | |
| 526 | − ); | |
| 527 | − }; | |
| 528 | − | |
| 529 | − const getUniqueAutoSourceName = (candidate: string) => { | |
| 530 | − return withUniqueSourceName(candidate, getTakenSourceNames()); | |
| 531 | − }; | |
| 532 | − | |
| 533 | − const handleEndpointChange = (value: string) => { | |
| 534 | − setEndpoint(value); | |
| 535 | − if (!nameManuallyEdited) { | |
| 536 | − const inferred = inferNameFromUrl(value); | |
| 537 | − if (inferred) setName(inferred); | |
| 538 | − } | |
| 539 | − }; | |
| 540 | − | |
| 541 | − const handleNameChange = (value: string) => { | |
| 542 | − setName(value); | |
| 543 | − setNameManuallyEdited(true); | |
| 544 | − }; | |
| 545 | − | |
| 546 | − const resetDialogState = () => { | |
| 547 | − setView("catalog"); | |
| 548 | − setType("mcp"); | |
| 549 | − setName(""); | |
| 550 | − setEndpoint(""); | |
| 551 | − setBaseUrl(""); | |
| 552 | − setMcpTransport("auto"); | |
| 553 | − setMcpActorQueryParamKey("userId"); | |
| 554 | − setNameManuallyEdited(false); | |
| 555 | − setLocallyReservedNames([]); | |
| 556 | − setCatalogQuery(""); | |
| 557 | − setCatalogSort("popular"); | |
| 558 | − setAddingCatalogId(null); | |
| 559 | − }; | |
| 560 | − | |
| 561 | − const handleOpenChange = (isOpen: boolean) => { | |
| 562 | − setOpen(isOpen); | |
| 563 | − if (!isOpen) { | |
| 564 | − resetDialogState(); | |
| 565 | − } | |
| 566 | − }; | |
| 567 | − | |
| 568 | − const addSource = async ( | |
| 569 | − sourceName: string, | |
| 570 | − sourceType: "mcp" | "openapi" | "graphql", | |
| 571 | − config: Record<string, unknown>, | |
| 572 | − ) => { | |
| 573 | − if (!context) return; | |
| 574 | − const created = await upsertToolSource({ | |
| 575 | − workspaceId: context.workspaceId, | |
| 576 | − sessionId: context.sessionId, | |
| 577 | − name: sourceName, | |
| 578 | − type: sourceType, | |
| 579 | − config, | |
| 580 | − }); | |
| 581 | − onSourceAdded?.(created as ToolSourceRecord); | |
| 582 | − toast.success(`Source "${sourceName}" added — loading tools…`); | |
| 583 | − }; | |
| 584 | − | |
| 585 | − const handleCatalogAdd = async (item: CatalogCollectionItem) => { | |
| 586 | − if (!item.specUrl.trim()) { | |
| 587 | − toast.error("Missing OpenAPI spec URL for this API source"); | |
| 588 | − return; | |
| 589 | − } | |
| 590 | − | |
| 591 | − setAddingCatalogId(item.id); | |
| 592 | − try { | |
| 593 | − const sourceName = getUniqueAutoSourceName(catalogSourceName(item)); | |
| 594 | − await addSource(sourceName, "openapi", { | |
| 595 | − spec: item.specUrl, | |
| 596 | − }); | |
| 597 | − reserveSourceName(sourceName); | |
| 598 | − } catch (error) { | |
| 599 | − toast.error(error instanceof Error ? error.message : "Failed to add API source"); | |
| 600 | − } finally { | |
| 601 | − setAddingCatalogId(null); | |
| 602 | − } | |
| 603 | − }; | |
| 604 | − | |
| 605 | − const handleCustomSubmit = async () => { | |
| 606 | − if (!context || !name.trim() || !endpoint.trim()) return; | |
| 607 | − | |
| 608 | − const takenNames = [...getTakenSourceNames()].map((entry) => entry.toLowerCase()); | |
| 609 | − if (takenNames.includes(name.trim().toLowerCase())) { | |
| 610 | − toast.error(`Source name "${name.trim()}" already exists`); | |
| 611 | − return; | |
| 612 | − } | |
| 613 | − | |
| 614 | − setSubmitting(true); | |
| 615 | − try { | |
| 616 | − const config: Record<string, unknown> = | |
| 617 | − type === "mcp" | |
| 618 | − ? { | |
| 619 | − url: endpoint, | |
| 620 | − ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}), | |
| 621 | − ...(mcpActorQueryParamKey.trim() && context.actorId | |
| 622 | − ? { queryParams: { [mcpActorQueryParamKey.trim()]: context.actorId } } | |
| 623 | − : {}), | |
| 624 | − } | |
| 625 | − : type === "graphql" | |
| 626 | − ? { endpoint: endpoint } | |
| 627 | − : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; | |
| 628 | − await addSource(name.trim(), type, config); | |
| 629 | − reserveSourceName(name.trim()); | |
| 630 | − resetDialogState(); | |
| 631 | − setOpen(false); | |
| 632 | − } catch (err) { | |
| 633 | − toast.error(err instanceof Error ? err.message : "Failed to add source"); | |
| 634 | − } finally { | |
| 635 | − setSubmitting(false); | |
| 636 | − } | |
| 637 | − }; | |
| 638 | − | |
| 639 | − return ( | |
| 640 | − <Dialog open={open} onOpenChange={handleOpenChange}> | |
| 641 | − <DialogTrigger asChild> | |
| 642 | − <Button size="sm" className="h-8 text-xs"> | |
| 643 | − <Plus className="h-3.5 w-3.5 mr-1.5" /> | |
| 644 | − Add Source | |
| 645 | − </Button> | |
| 646 | − </DialogTrigger> | |
| 647 | − <DialogContent className="bg-card border-border sm:max-w-md p-0 gap-0"> | |
| 648 | − <DialogHeader className="px-5 pt-5 pb-0"> | |
| 649 | − <DialogTitle className="text-sm font-medium"> | |
| 650 | − Add Tool Source | |
| 651 | − </DialogTitle> | |
| 652 | − </DialogHeader> | |
| 653 | − | |
| 654 | − <div className="p-5 space-y-4"> | |
| 655 | − {view === "catalog" ? ( | |
| 656 | − <div className="space-y-3"> | |
| 657 | − <div className="flex items-center gap-2 flex-wrap"> | |
| 658 | − <Input | |
| 659 | − value={catalogQuery} | |
| 660 | − onChange={(event) => setCatalogQuery(event.target.value)} | |
| 661 | − placeholder="Search APIs" | |
| 662 | − className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]" | |
| 663 | − /> | |
| 664 | − <Select | |
| 665 | − value={catalogSort} | |
| 666 | − onValueChange={(value) => setCatalogSort(value as "popular" | "recent")} | |
| 667 | − > | |
| 668 | − <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0"> | |
| 669 | − <SelectValue /> | |
| 670 | − </SelectTrigger> | |
| 671 | − <SelectContent> | |
| 672 | − <SelectItem value="popular" className="text-xs">Popular</SelectItem> | |
| 673 | − <SelectItem value="recent" className="text-xs">Recent</SelectItem> | |
| 674 | − </SelectContent> | |
| 675 | − </Select> | |
| 676 | − </div> | |
| 677 | − | |
| 678 | − <p className="text-[11px] text-muted-foreground"> | |
| 679 | − Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}. | |
| 680 | − </p> | |
| 681 | − | |
| 682 | − <Separator /> | |
| 683 | − | |
| 684 | − <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1"> | |
| 685 | − <button | |
| 686 | − type="button" | |
| 687 | − onClick={() => setView("custom")} | |
| 688 | − className="w-full max-w-full overflow-hidden text-left px-3 py-2 rounded-md border border-border/70 bg-muted/40 hover:bg-accent/30 transition-colors" | |
| 689 | − > | |
| 690 | − <div className="flex items-center gap-2"> | |
| 691 | − <span className="inline-flex h-6 w-6 items-center justify-center rounded bg-muted"> | |
| 692 | − <Plus className="h-3.5 w-3.5 text-muted-foreground" /> | |
| 693 | − </span> | |
| 694 | − <div className="min-w-0 w-0 flex-1 overflow-hidden"> | |
| 695 | − <p className="text-xs font-medium">Add custom source</p> | |
| 696 | − <p className="text-[10px] text-muted-foreground">MCP, OpenAPI, or GraphQL endpoint</p> | |
| 697 | − </div> | |
| 698 | − </div> | |
| 699 | − </button> | |
| 700 | − | |
| 701 | − {visibleCatalogItems.map((item) => ( | |
| 702 | − <div | |
| 703 | − key={item.id} | |
| 704 | − className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50" | |
| 705 | − > | |
| 706 | − {item.logoUrl && ( | |
| 707 | − <img | |
| 708 | − src={item.logoUrl} | |
| 709 | − alt="" | |
| 710 | − className="w-5 h-5 rounded shrink-0 mt-0.5 object-contain" | |
| 711 | − loading="lazy" | |
| 712 | − /> | |
| 713 | − )} | |
| 714 | − <div className="flex-1 min-w-0 w-0 overflow-hidden"> | |
| 715 | − <p className="text-xs font-medium truncate">{item.name}</p> | |
| 716 | − <p className="text-[10px] text-muted-foreground truncate"> | |
| 717 | − {item.providerName} | |
| 718 | − {item.version ? ` · v${item.version}` : ""} | |
| 719 | − </p> | |
| 720 | − {item.summary && ( | |
| 721 | − <div | |
| 722 | − className="mt-0.5 w-full min-w-0 max-w-full overflow-hidden text-[10px] text-muted-foreground/90 leading-relaxed break-words [overflow-wrap:anywhere] [&_*]:max-w-full [&_*]:min-w-0 [&_*]:break-words [&_a]:break-all [&_a]:whitespace-normal [&_code]:break-all [&_code]:whitespace-pre-wrap [&_p]:m-0 [&_pre]:max-w-full [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:break-words line-clamp-2" | |
| 723 | − style={{ overflowWrap: "anywhere", wordBreak: "break-word" }} | |
| 724 | − > | |
| 725 | − <Streamdown controls={false}>{item.summary}</Streamdown> | |
| 726 | − </div> | |
| 727 | − )} | |
| 728 | − </div> | |
| 729 | − <div className="flex items-center gap-1 shrink-0"> | |
| 730 | − <Button | |
| 731 | − type="button" | |
| 732 | − size="sm" | |
| 733 | − className="h-7 px-2 text-[11px]" | |
| 734 | − onClick={() => void handleCatalogAdd(item)} | |
| 735 | − disabled={Boolean(addingCatalogId)} | |
| 736 | − > | |
| 737 | − {addingCatalogId === item.id ? "Adding..." : "Add"} | |
| 738 | − </Button> | |
| 739 | − </div> | |
| 740 | − </div> | |
| 741 | − ))} | |
| 742 | − | |
| 743 | − {visibleCatalogItems.length === 0 && ( | |
| 744 | − <p className="text-[11px] text-muted-foreground px-1 py-1"> | |
| 745 | − No collections found for this query. | |
| 746 | − </p> | |
| 747 | − )} | |
| 748 | − </div> | |
| 749 | − </div> | |
| 750 | − ) : ( | |
| 751 | − <div className="space-y-3"> | |
| 752 | − <Button | |
| 753 | − type="button" | |
| 754 | − variant="ghost" | |
| 755 | − size="sm" | |
| 756 | − className="h-7 px-2 text-xs" | |
| 757 | − onClick={() => setView("catalog")} | |
| 758 | − > | |
| 759 | − <ChevronRight className="h-3.5 w-3.5 mr-1 rotate-180" /> | |
| 760 | − Back to API list | |
| 761 | − </Button> | |
| 762 | − | |
| 763 | − <div className="space-y-1.5"> | |
| 764 | − <Label className="text-xs text-muted-foreground">Type</Label> | |
| 765 | − <Select | |
| 766 | − value={type} | |
| 767 | − onValueChange={(value) => setType(value as "mcp" | "openapi" | "graphql")} | |
| 768 | − > | |
| 769 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 770 | − <SelectValue /> | |
| 771 | − </SelectTrigger> | |
| 772 | − <SelectContent> | |
| 773 | − <SelectItem value="mcp" className="text-xs">MCP Server</SelectItem> | |
| 774 | − <SelectItem value="openapi" className="text-xs">OpenAPI Spec</SelectItem> | |
| 775 | − <SelectItem value="graphql" className="text-xs">GraphQL</SelectItem> | |
| 776 | − </SelectContent> | |
| 777 | − </Select> | |
| 778 | − </div> | |
| 779 | − | |
| 780 | − <div className="space-y-1.5"> | |
| 781 | − <Label className="text-xs text-muted-foreground"> | |
| 782 | − {type === "mcp" ? "Endpoint URL" : type === "graphql" ? "GraphQL Endpoint" : "Spec URL"} | |
| 783 | − </Label> | |
| 784 | − <Input | |
| 785 | − value={endpoint} | |
| 786 | − onChange={(event) => handleEndpointChange(event.target.value)} | |
| 787 | − placeholder={ | |
| 788 | − type === "mcp" | |
| 789 | − ? "https://mcp-server.example.com/sse" | |
| 790 | − : type === "graphql" | |
| 791 | − ? "https://api.example.com/graphql" | |
| 792 | − : "https://api.example.com/openapi.json" | |
| 793 | − } | |
| 794 | − className="h-8 text-xs font-mono bg-background" | |
| 795 | − /> | |
| 796 | − </div> | |
| 797 | − | |
| 798 | − <div className="space-y-1.5"> | |
| 799 | − <Label className="text-xs text-muted-foreground">Name</Label> | |
| 800 | − <Input | |
| 801 | − value={name} | |
| 802 | − onChange={(event) => handleNameChange(event.target.value)} | |
| 803 | − placeholder="e.g. my-service" | |
| 804 | − className="h-8 text-xs font-mono bg-background" | |
| 805 | − /> | |
| 806 | − </div> | |
| 807 | − | |
| 808 | − {type === "openapi" && ( | |
| 809 | − <div className="space-y-1.5"> | |
| 810 | − <Label className="text-xs text-muted-foreground">Base URL (optional)</Label> | |
| 811 | − <Input | |
| 812 | − value={baseUrl} | |
| 813 | − onChange={(event) => setBaseUrl(event.target.value)} | |
| 814 | − placeholder="https://api.example.com" | |
| 815 | − className="h-8 text-xs font-mono bg-background" | |
| 816 | − /> | |
| 817 | − </div> | |
| 818 | − )} | |
| 819 | − | |
| 820 | − {type === "mcp" && ( | |
| 821 | − <> | |
| 822 | − <div className="space-y-1.5"> | |
| 823 | − <Label className="text-xs text-muted-foreground">Transport</Label> | |
| 824 | − <Select | |
| 825 | − value={mcpTransport} | |
| 826 | − onValueChange={(value) => setMcpTransport(value as "auto" | "streamable-http" | "sse")} | |
| 827 | − > | |
| 828 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 829 | − <SelectValue /> | |
| 830 | − </SelectTrigger> | |
| 831 | − <SelectContent> | |
| 832 | − <SelectItem value="auto" className="text-xs">Auto (streamable, then SSE)</SelectItem> | |
| 833 | − <SelectItem value="streamable-http" className="text-xs">Streamable HTTP</SelectItem> | |
| 834 | − <SelectItem value="sse" className="text-xs">SSE</SelectItem> | |
| 835 | − </SelectContent> | |
| 836 | − </Select> | |
| 837 | − </div> | |
| 838 | − | |
| 839 | − <div className="space-y-1.5"> | |
| 840 | − <Label className="text-xs text-muted-foreground">Anon actor query key (optional)</Label> | |
| 841 | − <Input | |
| 842 | − value={mcpActorQueryParamKey} | |
| 843 | − onChange={(event) => setMcpActorQueryParamKey(event.target.value)} | |
| 844 | − placeholder="userId" | |
| 845 | − className="h-8 text-xs font-mono bg-background" | |
| 846 | − /> | |
| 847 | − </div> | |
| 848 | − </> | |
| 849 | − )} | |
| 850 | − | |
| 851 | − <Button | |
| 852 | − onClick={handleCustomSubmit} | |
| 853 | − disabled={submitting || !name.trim() || !endpoint.trim()} | |
| 854 | − className="w-full h-9" | |
| 855 | − size="sm" | |
| 856 | − > | |
| 857 | − {submitting ? "Adding..." : "Add Source"} | |
| 858 | − </Button> | |
| 859 | − </div> | |
| 860 | − )} | |
| 861 | − </div> | |
| 862 | − </DialogContent> | |
| 863 | − </Dialog> | |
| 864 | − ); | |
| 865 | −} | |
| 866 | − | |
| 867 | −// ── Source Card ── | |
| 868 | − | |
| 869 | −function ConfigureSourceAuthDialog({ | |
| 870 | − source, | |
| 871 | − inferredProfile, | |
| 872 | −}: { | |
| 873 | − source: ToolSourceRecord; | |
| 874 | − inferredProfile?: SourceAuthProfile; | |
| 875 | −}) { | |
| 876 | − const { context } = useSession(); | |
| 877 | − const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | |
| 878 | − const [open, setOpen] = useState(false); | |
| 879 | − const [saving, setSaving] = useState(false); | |
| 880 | − | |
| 881 | − const currentAuth = readSourceAuth(source, inferredProfile); | |
| 882 | − const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type; | |
| 883 | − const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>(editableInitialAuthType); | |
| 884 | − const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace"); | |
| 885 | − const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key"); | |
| 886 | − | |
| 887 | − const configurable = source.type === "openapi" || source.type === "graphql"; | |
| 888 | − | |
| 889 | − const resetFromSource = () => { | |
| 890 | − const auth = readSourceAuth(source, inferredProfile); | |
| 891 | − setAuthType(auth.type === "mixed" ? "bearer" : auth.type); | |
| 892 | − setAuthMode(auth.mode ?? "workspace"); | |
| 893 | − setApiKeyHeader(auth.header ?? "x-api-key"); | |
| 894 | − }; | |
| 895 | − | |
| 896 | − const handleOpenChange = (isOpen: boolean) => { | |
| 897 | − setOpen(isOpen); | |
| 898 | − if (isOpen) { | |
| 899 | − resetFromSource(); | |
| 900 | − } | |
| 901 | − }; | |
| 902 | − | |
| 903 | − const handleSave = async () => { | |
| 904 | − if (!context || !configurable) return; | |
| 905 | − setSaving(true); | |
| 906 | − try { | |
| 907 | − const authConfig: Record<string, unknown> = | |
| 908 | − authType === "none" | |
| 909 | − ? { type: "none" } | |
| 910 | − : authType === "apiKey" | |
| 911 | − ? { type: "apiKey", mode: authMode, header: apiKeyHeader.trim() || "x-api-key" } | |
| 912 | − : { type: authType, mode: authMode }; | |
| 913 | − | |
| 914 | − await upsertToolSource({ | |
| 915 | − id: source.id, | |
| 916 | − workspaceId: context.workspaceId, | |
| 917 | − sessionId: context.sessionId, | |
| 918 | − name: source.name, | |
| 919 | − type: source.type, | |
| 920 | − config: { | |
| 921 | − ...source.config, | |
| 922 | − auth: authConfig, | |
| 923 | − }, | |
| 924 | − }); | |
| 925 | − | |
| 926 | − toast.success(`Updated auth for ${source.name}`); | |
| 927 | − setOpen(false); | |
| 928 | − } catch (err) { | |
| 929 | − toast.error(err instanceof Error ? err.message : "Failed to update auth"); | |
| 930 | − } finally { | |
| 931 | − setSaving(false); | |
| 932 | − } | |
| 933 | − }; | |
| 934 | − | |
| 935 | − if (!configurable) { | |
| 936 | − return null; | |
| 937 | − } | |
| 938 | − | |
| 939 | − return ( | |
| 940 | − <Dialog open={open} onOpenChange={handleOpenChange}> | |
| 941 | − <DialogTrigger asChild> | |
| 942 | − <Button variant="outline" size="sm" className="h-7 text-[11px]"> | |
| 943 | − <Pencil className="h-3 w-3 mr-1.5" /> | |
| 944 | − Auth | |
| 945 | − </Button> | |
| 946 | − </DialogTrigger> | |
| 947 | − <DialogContent className="bg-card border-border sm:max-w-md"> | |
| 948 | − <DialogHeader> | |
| 949 | − <DialogTitle className="text-sm font-medium">Configure Source Auth</DialogTitle> | |
| 950 | − </DialogHeader> | |
| 951 | − <div className="space-y-3"> | |
| 952 | − <div className="space-y-1.5"> | |
| 953 | − <Label className="text-xs text-muted-foreground">Source</Label> | |
| 954 | − <Input value={source.name} readOnly className="h-8 text-xs font-mono bg-background" /> | |
| 955 | − </div> | |
| 956 | − | |
| 957 | − <div className="space-y-1.5"> | |
| 958 | − <Label className="text-xs text-muted-foreground">Auth Type</Label> | |
| 959 | − <Select value={authType} onValueChange={(value) => setAuthType(value as Exclude<SourceAuthType, "mixed">)}> | |
| 960 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 961 | − <SelectValue /> | |
| 962 | − </SelectTrigger> | |
| 963 | − <SelectContent> | |
| 964 | − <SelectItem value="none" className="text-xs">None</SelectItem> | |
| 965 | − <SelectItem value="bearer" className="text-xs">Bearer token</SelectItem> | |
| 966 | − <SelectItem value="apiKey" className="text-xs">API key header</SelectItem> | |
| 967 | − <SelectItem value="basic" className="text-xs">Basic auth</SelectItem> | |
| 968 | − </SelectContent> | |
| 969 | − </Select> | |
| 970 | − </div> | |
| 971 | − | |
| 972 | − {currentAuth.inferred && ( | |
| 973 | − <p className="text-[11px] text-muted-foreground"> | |
| 974 | − Suggested from spec inference. Save to pin an explicit auth config. | |
| 975 | − </p> | |
| 976 | − )} | |
| 977 | − | |
| 978 | − {authType !== "none" && ( | |
| 979 | − <> | |
| 980 | − <div className="space-y-1.5"> | |
| 981 | − <Label className="text-xs text-muted-foreground">Credential Scope</Label> | |
| 982 | − <Select value={authMode} onValueChange={(value) => setAuthMode(value as SourceAuthMode)}> | |
| 983 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 984 | − <SelectValue /> | |
| 985 | − </SelectTrigger> | |
| 986 | − <SelectContent> | |
| 987 | − <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | |
| 988 | − <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem> | |
| 989 | − </SelectContent> | |
| 990 | − </Select> | |
| 991 | − </div> | |
| 992 | − | |
| 993 | − {authType === "apiKey" && ( | |
| 994 | − <div className="space-y-1.5"> | |
| 995 | − <Label className="text-xs text-muted-foreground">Header Name</Label> | |
| 996 | − <Input | |
| 997 | − value={apiKeyHeader} | |
| 998 | − onChange={(e) => setApiKeyHeader(e.target.value)} | |
| 999 | − placeholder="x-api-key" | |
| 1000 | − className="h-8 text-xs font-mono bg-background" | |
| 1001 | − /> | |
| 1002 | − </div> | |
| 1003 | − )} | |
| 1004 | − | |
| 1005 | − <p className="text-[11px] text-muted-foreground"> | |
| 1006 | − Save this first, then add a connection in the Connections tab using source key | |
| 1007 | − <code className="ml-1">{sourceKeyForSource(source)}</code>. | |
| 1008 | − </p> | |
| 1009 | − </> | |
| 1010 | − )} | |
| 1011 | − | |
| 1012 | − <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | |
| 1013 | − {saving ? "Saving..." : "Save Auth"} | |
| 1014 | − </Button> | |
| 1015 | − </div> | |
| 1016 | − </DialogContent> | |
| 1017 | − </Dialog> | |
| 1018 | − ); | |
| 1019 | −} | |
| 1020 | − | |
| 1021 | −function SourceCard({ | |
| 1022 | − source, | |
| 1023 | − quality, | |
| 1024 | − qualityLoading, | |
| 1025 | − credentialStats, | |
| 1026 | − sourceAuthProfiles, | |
| 1027 | − selected = false, | |
| 1028 | − onFocusSource, | |
| 1029 | −}: { | |
| 1030 | − source: ToolSourceRecord; | |
| 1031 | − quality?: OpenApiSourceQuality; | |
| 1032 | − qualityLoading?: boolean; | |
| 1033 | − credentialStats: { workspaceCount: number; actorCount: number }; | |
| 1034 | − sourceAuthProfiles: Record<string, SourceAuthProfile>; | |
| 1035 | − selected?: boolean; | |
| 1036 | − onFocusSource?: (sourceName: string) => void; | |
| 1037 | −}) { | |
| 1038 | − const { context } = useSession(); | |
| 1039 | − const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); | |
| 1040 | − const [deleting, setDeleting] = useState(false); | |
| 1041 | − const [detailsOpen, setDetailsOpen] = useState(false); | |
| 1042 | − | |
| 1043 | − const handleDelete = async () => { | |
| 1044 | − if (!context) return; | |
| 1045 | − setDeleting(true); | |
| 1046 | − try { | |
| 1047 | − await deleteToolSource({ | |
| 1048 | − workspaceId: context.workspaceId, | |
| 1049 | − sessionId: context.sessionId, | |
| 1050 | − sourceId: source.id, | |
| 1051 | − }); | |
| 1052 | − toast.success(`Removed "${source.name}"`); | |
| 1053 | − } catch (err) { | |
| 1054 | − toast.error(err instanceof Error ? err.message : "Failed to delete"); | |
| 1055 | − } finally { | |
| 1056 | − setDeleting(false); | |
| 1057 | − } | |
| 1058 | − }; | |
| 1059 | − | |
| 1060 | − const TypeIcon = source.type === "mcp" ? Server : Globe; | |
| 1061 | − const favicon = getSourceFavicon(source); | |
| 1062 | − const sourceKey = sourceKeyForSource(source) ?? ""; | |
| 1063 | − const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]); | |
| 1064 | − const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]); | |
| 1065 | − const hasAuthConfigured = auth.type !== "none"; | |
| 1066 | − const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount; | |
| 1067 | − const prettyName = displaySourceName(source.name); | |
| 1068 | − const compactEndpoint = compactEndpointLabel(source); | |
| 1069 | − const showTypeSummary = source.type === "openapi" && (quality || qualityLoading); | |
| 1070 | − | |
| 1071 | − return ( | |
| 1072 | − <div | |
| 1073 | − className={cn( | |
| 1074 | − "group flex items-start gap-3 rounded-lg border border-border/60 bg-gradient-to-b from-muted/45 to-muted/20 px-3 py-3", | |
| 1075 | − selected && "border-primary/35 bg-primary/5", | |
| 1076 | − )} | |
| 1077 | − > | |
| 1078 | − <div className="mt-0.5 h-9 w-9 rounded-md bg-muted/80 flex items-center justify-center shrink-0 overflow-hidden"> | |
| 1079 | − {favicon ? ( | |
| 1080 | − <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> | |
| 1081 | − ) : ( | |
| 1082 | − <TypeIcon className="h-4 w-4 text-muted-foreground" /> | |
| 1083 | − )} | |
| 1084 | − </div> | |
| 1085 | − <div className="flex-1 min-w-0"> | |
| 1086 | − <div className="flex items-center gap-2 min-w-0"> | |
| 1087 | − <span className="text-sm font-medium truncate" title={source.name}> | |
| 1088 | − {prettyName} | |
| 1089 | − </span> | |
| 1090 | − <Badge | |
| 1091 | − variant="outline" | |
| 1092 | − className="text-[9px] uppercase tracking-wide" | |
| 1093 | − > | |
| 1094 | − {source.type} | |
| 1095 | − </Badge> | |
| 1096 | − {!source.enabled && ( | |
| 1097 | − <Badge | |
| 1098 | − variant="outline" | |
| 1099 | − className="text-[9px] uppercase tracking-wide text-terminal-red border-terminal-red/30" | |
| 1100 | − > | |
| 1101 | − disabled | |
| 1102 | − </Badge> | |
| 1103 | − )} | |
| 1104 | − {authBadge && ( | |
| 1105 | − <Badge | |
| 1106 | − variant="outline" | |
| 1107 | − className="text-[9px] uppercase tracking-wide text-primary border-primary/30" | |
| 1108 | − > | |
| 1109 | − {authBadge} | |
| 1110 | − </Badge> | |
| 1111 | − )} | |
| 1112 | − {hasAuthConfigured && ( | |
| 1113 | − <Badge | |
| 1114 | − variant="outline" | |
| 1115 | − className={cn( | |
| 1116 | − "text-[9px] uppercase tracking-wide", | |
| 1117 | − totalCredentials > 0 | |
| 1118 | − ? "text-terminal-green border-terminal-green/30" | |
| 1119 | − : "text-terminal-amber border-terminal-amber/30", | |
| 1120 | − )} | |
| 1121 | − > | |
| 1122 | − {totalCredentials > 0 ? "connections ready" : "connection needed"} | |
| 1123 | − </Badge> | |
| 1124 | − )} | |
| 1125 | − </div> | |
| 1126 | − <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}> | |
| 1127 | − {compactEndpoint} | |
| 1128 | − </span> | |
| 1129 | − {showTypeSummary && ( | |
| 1130 | − <div className="mt-1.5 flex items-center gap-2"> | |
| 1131 | − {quality ? ( | |
| 1132 | − <Badge | |
| 1133 | − variant="outline" | |
| 1134 | − className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))} | |
| 1135 | − > | |
| 1136 | − {formatQualityPercent(quality.overallQuality)} {qualitySummaryLabel(quality)} | |
| 1137 | − </Badge> | |
| 1138 | − ) : ( | |
| 1139 | − <Badge variant="outline" className="text-[9px] uppercase tracking-wide text-muted-foreground"> | |
| 1140 | − analyzing type quality | |
| 1141 | − </Badge> | |
| 1142 | − )} | |
| 1143 | − </div> | |
| 1144 | − )} | |
| 1145 | − {source.type === "openapi" && ( | |
| 1146 | − <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}> | |
| 1147 | − <div className="mt-1.5"> | |
| 1148 | − <CollapsibleTrigger asChild> | |
| 1149 | − <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground"> | |
| 1150 | − <ChevronRight | |
| 1151 | − className={cn("mr-1 h-3 w-3 transition-transform", detailsOpen && "rotate-90")} | |
| 1152 | − /> | |
| 1153 | − {detailsOpen ? "Hide details" : "View details"} | |
| 1154 | − </Button> | |
| 1155 | − </CollapsibleTrigger> | |
| 1156 | − </div> | |
| 1157 | − <CollapsibleContent className="mt-1.5"> | |
| 1158 | − <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2"> | |
| 1159 | − {quality && ( | |
| 1160 | − <div className="space-y-1.5 text-[10px]"> | |
| 1161 | − <div className="flex items-center justify-between"> | |
| 1162 | − <span className="text-muted-foreground">Type quality</span> | |
| 1163 | − <span className={cn("font-medium", qualityToneClass(quality))}> | |
| 1164 | − {formatQualityPercent(quality.overallQuality)} | |
| 1165 | − </span> | |
| 1166 | − </div> | |
| 1167 | − <div className="flex items-center justify-between text-muted-foreground"> | |
| 1168 | − <span>Args quality</span> | |
| 1169 | − <span>{formatQualityPercent(quality.argsQuality)}</span> | |
| 1170 | − </div> | |
| 1171 | − <div className="flex items-center justify-between text-muted-foreground"> | |
| 1172 | − <span>Returns quality</span> | |
| 1173 | − <span>{formatQualityPercent(quality.returnsQuality)}</span> | |
| 1174 | − </div> | |
| 1175 | − <div className="flex items-center justify-between text-muted-foreground"> | |
| 1176 | − <span>Inferred returns</span> | |
| 1177 | − <span>{quality.unknownReturnsCount}</span> | |
| 1178 | − </div> | |
| 1179 | − </div> | |
| 1180 | − )} | |
| 1181 | − {!quality && qualityLoading && ( | |
| 1182 | − <div className="flex items-center justify-between text-[10px] text-muted-foreground"> | |
| 1183 | − <span>Analyzing OpenAPI typing</span> | |
| 1184 | − <span className="inline-flex items-center gap-1 text-[10px]"> | |
| 1185 | − <span className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-pulse" /> | |
| 1186 | − in progress | |
| 1187 | − </span> | |
| 1188 | − </div> | |
| 1189 | − )} | |
| 1190 | − {!quality && !qualityLoading && ( | |
| 1191 | − <div className="text-[10px] text-muted-foreground">Type quality data unavailable.</div> | |
| 1192 | − )} | |
| 1193 | − </div> | |
| 1194 | − </CollapsibleContent> | |
| 1195 | − </Collapsible> | |
| 1196 | − )} | |
| 1197 | − </div> | |
| 1198 | − <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> | |
| 1199 | − {onFocusSource ? ( | |
| 1200 | − <Button | |
| 1201 | − variant={selected ? "default" : "outline"} | |
| 1202 | − size="sm" | |
| 1203 | − className="h-7 text-[11px]" | |
| 1204 | − onClick={() => onFocusSource(source.name)} | |
| 1205 | − > | |
| 1206 | − {selected ? "Viewing" : "View tools"} | |
| 1207 | − </Button> | |
| 1208 | − ) : null} | |
| 1209 | − <ConfigureSourceAuthDialog source={source} inferredProfile={sourceAuthProfiles[sourceKey]} /> | |
| 1210 | − <Button | |
| 1211 | − variant="ghost" | |
| 1212 | − size="icon" | |
| 1213 | − className="h-7 w-7 text-muted-foreground hover:text-terminal-red" | |
| 1214 | − onClick={handleDelete} | |
| 1215 | − disabled={deleting} | |
| 1216 | − > | |
| 1217 | − <Trash2 className="h-3.5 w-3.5" /> | |
| 1218 | − </Button> | |
| 1219 | − </div> | |
| 1220 | − </div> | |
| 1221 | − ); | |
| 1222 | −} | |
| 1223 | − | |
| 1224 | −type SourceOption = { source: ToolSourceRecord; key: string; label: string }; | |
| 1225 | − | |
| 1226 | −function sourceAuthForKey( | |
| 1227 | − sourceOptions: SourceOption[], | |
| 1228 | − key: string, | |
| 1229 | − inferredProfiles: Record<string, SourceAuthProfile> = {}, | |
| 1230 | −): { | |
| 1231 | − type: SourceAuthType; | |
| 1232 | − mode?: SourceAuthMode; | |
| 1233 | − header?: string; | |
| 1234 | − inferred?: boolean; | |
| 1235 | −} { | |
| 1236 | − const match = sourceOptions.find((entry) => entry.key === key); | |
| 1237 | − if (!match) { | |
| 1238 | − return { type: "bearer" }; | |
| 1239 | − } | |
| 1240 | − return readSourceAuth(match.source, inferredProfiles[key]); | |
| 1241 | −} | |
| 1242 | − | |
| 1243 | −function sourceOptionLabel(source: ToolSourceRecord): string { | |
| 1244 | − return `${source.name} (${source.type})`; | |
| 1245 | −} | |
| 1246 | − | |
| 1247 | −function providerLabel(provider: "local-convex" | "workos-vault"): string { | |
| 1248 | − return provider === "workos-vault" ? "encrypted" : "local"; | |
| 1249 | −} | |
| 1250 | − | |
| 1251 | −function connectionDisplayName( | |
| 1252 | − sources: ToolSourceRecord[], | |
| 1253 | − connection: { | |
| 1254 | − scope: CredentialScope; | |
| 1255 | − sourceKeys: Set<string>; | |
| 1256 | − actorId?: string; | |
| 1257 | − }, | |
| 1258 | −): string { | |
| 1259 | − const sourceNames = [...connection.sourceKeys] | |
| 1260 | − .map((sourceKey) => sourceForCredentialKey(sources, sourceKey)) | |
| 1261 | − .filter((source): source is ToolSourceRecord => Boolean(source)) | |
| 1262 | − .map((source) => displaySourceName(source.name)); | |
| 1263 | − | |
| 1264 | − const primary = sourceNames[0] ?? "API"; | |
| 1265 | − const extraCount = Math.max(sourceNames.length - 1, 0); | |
| 1266 | − const base = extraCount > 0 ? `${primary} +${extraCount}` : primary; | |
| 1267 | − | |
| 1268 | − if (connection.scope === "actor") { | |
| 1269 | − if (connection.actorId) { | |
| 1270 | − return `${base} personal (${connection.actorId})`; | |
| 1271 | − } | |
| 1272 | − return `${base} personal`; | |
| 1273 | − } | |
| 1274 | − | |
| 1275 | − return `${base} workspace`; | |
| 1276 | −} | |
| 1277 | − | |
| 1278 | −function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } { | |
| 1279 | − const lines = text | |
| 1280 | − .split("\n") | |
| 1281 | − .map((line) => line.trim()) | |
| 1282 | − .filter((line) => line.length > 0); | |
| 1283 | − if (lines.length === 0) { | |
| 1284 | − return { value: {} }; | |
| 1285 | − } | |
| 1286 | − | |
| 1287 | − const headers: Record<string, string> = {}; | |
| 1288 | − for (const line of lines) { | |
| 1289 | − const separator = line.indexOf(":"); | |
| 1290 | − if (separator <= 0) { | |
| 1291 | − return { error: `Invalid header line: ${line}` }; | |
| 1292 | − } | |
| 1293 | − const name = line.slice(0, separator).trim(); | |
| 1294 | − const value = line.slice(separator + 1).trim(); | |
| 1295 | − if (!name || !value) { | |
| 1296 | − return { error: `Invalid header line: ${line}` }; | |
| 1297 | − } | |
| 1298 | − headers[name] = value; | |
| 1299 | − } | |
| 1300 | − | |
| 1301 | − return { value: headers }; | |
| 1302 | −} | |
| 1303 | − | |
| 1304 | −function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { | |
| 1305 | − const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; | |
| 1306 | − return Object.entries(headers) | |
| 1307 | − .map(([key, value]) => `${key}: ${String(value)}`) | |
| 1308 | − .join("\n"); | |
| 1309 | −} | |
| 1310 | − | |
| 1311 | −function CredentialsPanel({ | |
| 1312 | − sources, | |
| 1313 | − credentials, | |
| 1314 | − sourceAuthProfiles, | |
| 1315 | − loading, | |
| 1316 | − focusSourceKey, | |
| 1317 | − onFocusHandled, | |
| 1318 | −}: { | |
| 1319 | − sources: ToolSourceRecord[]; | |
| 1320 | − credentials: CredentialRecord[]; | |
| 1321 | − sourceAuthProfiles: Record<string, SourceAuthProfile>; | |
| 1322 | − loading: boolean; | |
| 1323 | − focusSourceKey?: string | null; | |
| 1324 | − onFocusHandled?: () => void; | |
| 1325 | −}) { | |
| 1326 | − const { context, clientConfig } = useSession(); | |
| 1327 | − const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | |
| 1328 | − const [open, setOpen] = useState(false); | |
| 1329 | − const [saving, setSaving] = useState(false); | |
| 1330 | − const [editing, setEditing] = useState<CredentialRecord | null>(null); | |
| 1331 | − const [sourceKey, setSourceKey] = useState(""); | |
| 1332 | − const [scope, setScope] = useState<CredentialScope>("workspace"); | |
| 1333 | − const [actorId, setActorId] = useState(""); | |
| 1334 | − const [connectionMode, setConnectionMode] = useState<"new" | "existing">("new"); | |
| 1335 | − const [existingConnectionId, setExistingConnectionId] = useState(""); | |
| 1336 | − const [tokenValue, setTokenValue] = useState(""); | |
| 1337 | − const [apiKeyValue, setApiKeyValue] = useState(""); | |
| 1338 | − const [basicUsername, setBasicUsername] = useState(""); | |
| 1339 | − const [basicPassword, setBasicPassword] = useState(""); | |
| 1340 | − const [customHeadersText, setCustomHeadersText] = useState(""); | |
| 1341 | − | |
| 1342 | − const storageCopy = clientConfig?.authProviderMode === "workos" | |
| 1343 | − ? "Stored encrypted" | |
| 1344 | − : "Stored locally on this machine"; | |
| 1345 | − | |
| 1346 | − const sourceOptions = sources | |
| 1347 | − .map((source) => { | |
| 1348 | − const key = sourceKeyForSource(source); | |
| 1349 | − return { | |
| 1350 | − source, | |
| 1351 | − key, | |
| 1352 | − label: sourceOptionLabel(source), | |
| 1353 | − }; | |
| 1354 | − }) | |
| 1355 | − .filter((entry): entry is SourceOption => entry.key !== null); | |
| 1356 | − | |
| 1357 | − const connectionOptions = useMemo(() => { | |
| 1358 | − const grouped = new Map<string, { | |
| 1359 | − id: string; | |
| 1360 | − scope: CredentialScope; | |
| 1361 | − actorId?: string; | |
| 1362 | − provider: "local-convex" | "workos-vault"; | |
| 1363 | − sourceKeys: Set<string>; | |
| 1364 | − updatedAt: number; | |
| 1365 | − }>(); | |
| 1366 | − | |
| 1367 | − for (const credential of credentials) { | |
| 1368 | − const existing = grouped.get(credential.id); | |
| 1369 | − if (existing) { | |
| 1370 | − existing.sourceKeys.add(credential.sourceKey); | |
| 1371 | − existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt); | |
| 1372 | − } else { | |
| 1373 | − grouped.set(credential.id, { | |
| 1374 | − id: credential.id, | |
| 1375 | − scope: credential.scope, | |
| 1376 | − actorId: credential.actorId, | |
| 1377 | − provider: credential.provider, | |
| 1378 | − sourceKeys: new Set([credential.sourceKey]), | |
| 1379 | − updatedAt: credential.updatedAt, | |
| 1380 | − }); | |
| 1381 | − } | |
| 1382 | − } | |
| 1383 | − | |
| 1384 | − return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt); | |
| 1385 | − }, [credentials]); | |
| 1386 | − | |
| 1387 | − const representativeCredentialByConnection = useMemo(() => { | |
| 1388 | − const map = new Map<string, CredentialRecord>(); | |
| 1389 | − for (const credential of credentials) { | |
| 1390 | − if (!map.has(credential.id)) { | |
| 1391 | − map.set(credential.id, credential); | |
| 1392 | − } | |
| 1393 | − } | |
| 1394 | − return map; | |
| 1395 | − }, [credentials]); | |
| 1396 | − | |
| 1397 | − const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey, sourceAuthProfiles); | |
| 1398 | − const compatibleConnectionOptions = connectionOptions.filter((connection) => { | |
| 1399 | − if (connection.scope !== scope) { | |
| 1400 | − return false; | |
| 1401 | − } | |
| 1402 | − if (scope === "actor") { | |
| 1403 | − return connection.actorId === actorId.trim(); | |
| 1404 | − } | |
| 1405 | − return true; | |
| 1406 | − }); | |
| 1407 | − const selectedAuthBadge = selectedAuth.type === "none" | |
| 1408 | − ? "none" | |
| 1409 | − : selectedAuth.type === "mixed" | |
| 1410 | − ? "mixed" | |
| 1411 | − : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`; | |
| 1412 | − | |
| 1413 | − useEffect(() => { | |
| 1414 | − if (!existingConnectionId) { | |
| 1415 | − return; | |
| 1416 | − } | |
| 1417 | − if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { | |
| 1418 | − setExistingConnectionId(""); | |
| 1419 | − } | |
| 1420 | − }, [compatibleConnectionOptions, existingConnectionId]); | |
| 1421 | − | |
| 1422 | − const resetForm = () => { | |
| 1423 | − const defaultSourceKey = sourceOptions[0]?.key ?? ""; | |
| 1424 | − setSourceKey(defaultSourceKey); | |
| 1425 | − const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey, sourceAuthProfiles); | |
| 1426 | − setScope(defaultAuth.mode ?? "workspace"); | |
| 1427 | − setActorId(context?.actorId ?? ""); | |
| 1428 | − setConnectionMode("new"); | |
| 1429 | − setExistingConnectionId(""); | |
| 1430 | − setTokenValue(""); | |
| 1431 | − setApiKeyValue(""); | |
| 1432 | − setBasicUsername(""); | |
| 1433 | − setBasicPassword(""); | |
| 1434 | − setCustomHeadersText(""); | |
| 1435 | − setEditing(null); | |
| 1436 | − }; | |
| 1437 | − | |
| 1438 | − useEffect(() => { | |
| 1439 | − if (!focusSourceKey) { | |
| 1440 | − return; | |
| 1441 | − } | |
| 1442 | − resetForm(); | |
| 1443 | − setSourceKey(focusSourceKey); | |
| 1444 | − const auth = sourceAuthForKey(sourceOptions, focusSourceKey, sourceAuthProfiles); | |
| 1445 | − setScope(auth.mode ?? "workspace"); | |
| 1446 | − setOpen(true); | |
| 1447 | − onFocusHandled?.(); | |
| 1448 | − }, [focusSourceKey, onFocusHandled, sourceAuthProfiles, sourceOptions]); | |
| 1449 | − | |
| 1450 | − const openForCreate = () => { | |
| 1451 | − resetForm(); | |
| 1452 | − setOpen(true); | |
| 1453 | − }; | |
| 1454 | − | |
| 1455 | − const openForEdit = (credential: CredentialRecord) => { | |
| 1456 | − setEditing(credential); | |
| 1457 | − setSourceKey(credential.sourceKey); | |
| 1458 | − setScope(credential.scope); | |
| 1459 | − setActorId(credential.actorId ?? context?.actorId ?? ""); | |
| 1460 | − setConnectionMode("new"); | |
| 1461 | − setExistingConnectionId(credential.id); | |
| 1462 | − setTokenValue(""); | |
| 1463 | − setApiKeyValue(""); | |
| 1464 | − setBasicUsername(""); | |
| 1465 | − setBasicPassword(""); | |
| 1466 | − setCustomHeadersText(formatHeaderOverrides(credential.overridesJson)); | |
| 1467 | − setOpen(true); | |
| 1468 | − }; | |
| 1469 | − | |
| 1470 | − const handleSourceKeyChange = (nextSourceKey: string) => { | |
| 1471 | − setSourceKey(nextSourceKey); | |
| 1472 | − const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles); | |
| 1473 | − if (!editing) { | |
| 1474 | − setScope(auth.mode ?? "workspace"); | |
| 1475 | − } | |
| 1476 | − }; | |
| 1477 | − | |
| 1478 | − const handleSave = async () => { | |
| 1479 | − if (!context) return; | |
| 1480 | − if (!sourceKey.trim()) { | |
| 1481 | − toast.error("Source key is required"); | |
| 1482 | − return; | |
| 1483 | − } | |
| 1484 | − if (scope === "actor" && !actorId.trim()) { | |
| 1485 | − toast.error("Actor ID is required for actor-scoped credentials"); | |
| 1486 | − return; | |
| 1487 | − } | |
| 1488 | − | |
| 1489 | − const parsedHeaders = parseHeaderOverrides(customHeadersText); | |
| 1490 | − if (!parsedHeaders.value) { | |
| 1491 | − toast.error(parsedHeaders.error ?? "Invalid header overrides"); | |
| 1492 | − return; | |
| 1493 | − } | |
| 1494 | − | |
| 1495 | − const linkExisting = !editing && connectionMode === "existing"; | |
| 1496 | − if (linkExisting && !existingConnectionId) { | |
| 1497 | − toast.error("Select an existing connection to link"); | |
| 1498 | − return; | |
| 1499 | − } | |
| 1500 | − if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { | |
| 1501 | − toast.error("Selected connection does not match this scope"); | |
| 1502 | − return; | |
| 1503 | − } | |
| 1504 | − | |
| 1505 | − if (selectedAuth.type === "none") { | |
| 1506 | − toast.error("This source does not require auth"); | |
| 1507 | − return; | |
| 1508 | − } | |
| 1509 | − | |
| 1510 | − if (selectedAuth.type === "mixed" && !linkExisting && !editing) { | |
| 1511 | − toast.error("Mixed-auth sources must link to an existing connection"); | |
| 1512 | − return; | |
| 1513 | − } | |
| 1514 | − | |
| 1515 | − const secretJson: Record<string, unknown> = {}; | |
| 1516 | − if (!linkExisting) { | |
| 1517 | − if (selectedAuth.type === "basic") { | |
| 1518 | − const hasUsername = basicUsername.trim().length > 0; | |
| 1519 | − const hasPassword = basicPassword.trim().length > 0; | |
| 1520 | − if (hasUsername || hasPassword) { | |
| 1521 | − if (!hasUsername || !hasPassword) { | |
| 1522 | − toast.error("Username and password are required for basic auth"); | |
| 1523 | − return; | |
| 1524 | − } | |
| 1525 | − secretJson.username = basicUsername; | |
| 1526 | − secretJson.password = basicPassword; | |
| 1527 | − } | |
| 1528 | − } else if (selectedAuth.type === "apiKey") { | |
| 1529 | − if (apiKeyValue.trim()) { | |
| 1530 | − secretJson.value = apiKeyValue.trim(); | |
| 1531 | − } | |
| 1532 | − } else if (selectedAuth.type === "bearer") { | |
| 1533 | − if (tokenValue.trim()) { | |
| 1534 | − secretJson.token = tokenValue.trim(); | |
| 1535 | − } | |
| 1536 | − } | |
| 1537 | − } | |
| 1538 | − | |
| 1539 | − if (Object.keys(parsedHeaders.value).length > 0) { | |
| 1540 | − secretJson.__headers = parsedHeaders.value; | |
| 1541 | − } | |
| 1542 | − | |
| 1543 | − if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) { | |
| 1544 | − if (selectedAuth.type === "basic") { | |
| 1545 | − toast.error("Username and password are required"); | |
| 1546 | − } else if (selectedAuth.type === "apiKey") { | |
| 1547 | − toast.error("API key value is required"); | |
| 1548 | − } else { | |
| 1549 | − toast.error("Token is required"); | |
| 1550 | − } | |
| 1551 | − return; | |
| 1552 | − } | |
| 1553 | − | |
| 1554 | − setSaving(true); | |
| 1555 | − try { | |
| 1556 | − await upsertCredential({ | |
| 1557 | − ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}), | |
| 1558 | − workspaceId: context.workspaceId, | |
| 1559 | − sessionId: context.sessionId, | |
| 1560 | − sourceKey: sourceKey.trim(), | |
| 1561 | − scope, | |
| 1562 | − ...(scope === "actor" ? { actorId: actorId.trim() } : {}), | |
| 1563 | − secretJson, | |
| 1564 | − }); | |
| 1565 | − | |
| 1566 | − toast.success(editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved"); | |
| 1567 | − setOpen(false); | |
| 1568 | − resetForm(); | |
| 1569 | − } catch (err) { | |
| 1570 | − toast.error(err instanceof Error ? err.message : "Failed to save connection"); | |
| 1571 | − } finally { | |
| 1572 | − setSaving(false); | |
| 1573 | − } | |
| 1574 | − }; | |
| 1575 | − | |
| 1576 | − return ( | |
| 1577 | − <Card className="bg-card border-border"> | |
| 1578 | − <CardHeader className="pb-3"> | |
| 1579 | − <div className="flex items-center justify-between"> | |
| 1580 | − <CardTitle className="text-sm font-medium flex items-center gap-2"> | |
| 1581 | − <KeyRound className="h-4 w-4 text-muted-foreground" /> | |
| 1582 | − Connections | |
| 1583 | − </CardTitle> | |
| 1584 | − <Button size="sm" className="h-8 text-xs" onClick={openForCreate}> | |
| 1585 | − <Plus className="h-3.5 w-3.5 mr-1.5" /> | |
| 1586 | − Add Connection | |
| 1587 | − </Button> | |
| 1588 | − </div> | |
| 1589 | − </CardHeader> | |
| 1590 | − <CardContent className="pt-0 space-y-3"> | |
| 1591 | − {loading ? ( | |
| 1592 | − <div className="space-y-2"> | |
| 1593 | − {Array.from({ length: 3 }).map((_, i) => ( | |
| 1594 | − <Skeleton key={i} className="h-14" /> | |
| 1595 | − ))} | |
| 1596 | − </div> | |
| 1597 | − ) : connectionOptions.length === 0 ? ( | |
| 1598 | − <div className="flex flex-col items-center justify-center py-10 gap-2"> | |
| 1599 | − <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center"> | |
| 1600 | − <KeyRound className="h-5 w-5 text-muted-foreground/50" /> | |
| 1601 | − </div> | |
| 1602 | − <p className="text-sm text-muted-foreground">No connections configured</p> | |
| 1603 | − <p className="text-[11px] text-muted-foreground/70 text-center max-w-md"> | |
| 1604 | − Add a source, then create or link a reusable connection. | |
| 1605 | − </p> | |
| 1606 | − </div> | |
| 1607 | − ) : ( | |
| 1608 | − <div className="space-y-2"> | |
| 1609 | − {connectionOptions.map((connection) => { | |
| 1610 | − const representative = representativeCredentialByConnection.get(connection.id); | |
| 1611 | − if (!representative) return null; | |
| 1612 | − const firstSource = sourceForCredentialKey(sources, representative.sourceKey); | |
| 1613 | − const favicon = firstSource ? getSourceFavicon(firstSource) : null; | |
| 1614 | − | |
| 1615 | − return ( | |
| 1616 | − <div | |
| 1617 | − key={connection.id} | |
| 1618 | − className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" | |
| 1619 | − > | |
| 1620 | − <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> | |
| 1621 | − {favicon ? ( | |
| 1622 | − <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> | |
| 1623 | − ) : ( | |
| 1624 | − <KeyRound className="h-4 w-4 text-muted-foreground" /> | |
| 1625 | − )} | |
| 1626 | − </div> | |
| 1627 | − <div className="flex-1 min-w-0"> | |
| 1628 | − <div className="flex items-center gap-1.5 mb-1 flex-wrap"> | |
| 1629 | − <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span> | |
| 1630 | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 1631 | − {connection.scope} | |
| 1632 | − </Badge> | |
| 1633 | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 1634 | − {providerLabel(connection.provider)} | |
| 1635 | − </Badge> | |
| 1636 | − {connection.scope === "actor" && connection.actorId && ( | |
| 1637 | − <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | |
| 1638 | − {connection.actorId} | |
| 1639 | − </span> | |
| 1640 | − )} | |
| 1641 | − </div> | |
| 1642 | − <p className="text-[11px] text-muted-foreground"> | |
| 1643 | − Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} • {storageCopy} | |
| 1644 | − </p> | |
| 1645 | − <p className="text-[11px] text-muted-foreground mt-0.5"> | |
| 1646 | − ID: <span className="font-mono">{connection.id}</span> • | |
| 1647 | − {" "} | |
| 1648 | − Updated {new Date(connection.updatedAt).toLocaleString()} | |
| 1649 | − </p> | |
| 1650 | − </div> | |
| 1651 | − <Button | |
| 1652 | − variant="outline" | |
| 1653 | − size="sm" | |
| 1654 | − className="h-7 text-[11px]" | |
| 1655 | − onClick={() => openForEdit(representative)} | |
| 1656 | − > | |
| 1657 | − Edit | |
| 1658 | − </Button> | |
| 1659 | − </div> | |
| 1660 | − ); | |
| 1661 | − })} | |
| 1662 | − </div> | |
| 1663 | − )} | |
| 1664 | − </CardContent> | |
| 1665 | − | |
| 1666 | − <Dialog open={open} onOpenChange={setOpen}> | |
| 1667 | − <DialogContent className="bg-card border-border sm:max-w-lg"> | |
| 1668 | − <DialogHeader> | |
| 1669 | − <DialogTitle className="text-sm font-medium"> | |
| 1670 | − {editing ? "Edit Connection" : "Add Connection"} | |
| 1671 | − </DialogTitle> | |
| 1672 | − </DialogHeader> | |
| 1673 | − <div className="space-y-3"> | |
| 1674 | − <div className="space-y-1.5"> | |
| 1675 | − <Label className="text-xs text-muted-foreground">Source</Label> | |
| 1676 | − {sourceOptions.length > 0 ? ( | |
| 1677 | − <Select value={sourceKey} onValueChange={handleSourceKeyChange}> | |
| 1678 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 1679 | − <SelectValue /> | |
| 1680 | − </SelectTrigger> | |
| 1681 | − <SelectContent> | |
| 1682 | − {sourceOptions.map((entry) => ( | |
| 1683 | − <SelectItem key={entry.key} value={entry.key} className="text-xs"> | |
| 1684 | − {entry.label} | |
| 1685 | − </SelectItem> | |
| 1686 | − ))} | |
| 1687 | − </SelectContent> | |
| 1688 | − </Select> | |
| 1689 | − ) : ( | |
| 1690 | − <Input | |
| 1691 | − value={sourceKey} | |
| 1692 | − onChange={(e) => handleSourceKeyChange(e.target.value)} | |
| 1693 | − placeholder="source:<source-id>" | |
| 1694 | − className="h-8 text-xs font-mono bg-background" | |
| 1695 | − /> | |
| 1696 | − )} | |
| 1697 | − {sourceKey && ( | |
| 1698 | − <p className="text-[10px] text-muted-foreground font-mono">key: {sourceKey}</p> | |
| 1699 | − )} | |
| 1700 | − </div> | |
| 1701 | − | |
| 1702 | − <div className="flex items-center gap-2 flex-wrap"> | |
| 1703 | − <span className="text-[11px] text-muted-foreground">Detected auth</span> | |
| 1704 | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 1705 | − {selectedAuthBadge} | |
| 1706 | − </Badge> | |
| 1707 | − {selectedAuth.inferred && ( | |
| 1708 | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 1709 | − inferred | |
| 1710 | − </Badge> | |
| 1711 | − )} | |
| 1712 | − {selectedAuth.type === "apiKey" && selectedAuth.header && ( | |
| 1713 | − <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | |
| 1714 | − header: {selectedAuth.header} | |
| 1715 | − </span> | |
| 1716 | − )} | |
| 1717 | − </div> | |
| 1718 | − | |
| 1719 | − <div className="grid grid-cols-2 gap-3"> | |
| 1720 | − <div className="space-y-1.5"> | |
| 1721 | − <Label className="text-xs text-muted-foreground">Scope</Label> | |
| 1722 | − <Select value={scope} onValueChange={(value) => setScope(value as CredentialScope)}> | |
| 1723 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 1724 | − <SelectValue /> | |
| 1725 | − </SelectTrigger> | |
| 1726 | − <SelectContent> | |
| 1727 | − <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | |
| 1728 | − <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem> | |
| 1729 | − </SelectContent> | |
| 1730 | − </Select> | |
| 1731 | − </div> | |
| 1732 | − <div className="space-y-1.5"> | |
| 1733 | − <Label className="text-xs text-muted-foreground">Storage</Label> | |
| 1734 | − <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" /> | |
| 1735 | − </div> | |
| 1736 | − </div> | |
| 1737 | − | |
| 1738 | − {scope === "actor" && ( | |
| 1739 | − <div className="space-y-1.5"> | |
| 1740 | − <Label className="text-xs text-muted-foreground">Actor ID</Label> | |
| 1741 | − <Input | |
| 1742 | − value={actorId} | |
| 1743 | − onChange={(e) => setActorId(e.target.value)} | |
| 1744 | − placeholder="actor_123" | |
| 1745 | − className="h-8 text-xs font-mono bg-background" | |
| 1746 | − /> | |
| 1747 | − </div> | |
| 1748 | − )} | |
| 1749 | − | |
| 1750 | − {!editing && connectionOptions.length > 0 && ( | |
| 1751 | − <div className="space-y-1.5"> | |
| 1752 | − <Label className="text-xs text-muted-foreground">Connection Mode</Label> | |
| 1753 | − <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as "new" | "existing") }> | |
| 1754 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 1755 | − <SelectValue /> | |
| 1756 | − </SelectTrigger> | |
| 1757 | − <SelectContent> | |
| 1758 | − <SelectItem value="new" className="text-xs">Create new connection</SelectItem> | |
| 1759 | − <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem> | |
| 1760 | − </SelectContent> | |
| 1761 | − </Select> | |
| 1762 | − </div> | |
| 1763 | − )} | |
| 1764 | − | |
| 1765 | − {!editing && connectionMode === "existing" && ( | |
| 1766 | − <div className="space-y-1.5"> | |
| 1767 | − <Label className="text-xs text-muted-foreground">Existing Connection</Label> | |
| 1768 | − <Select value={existingConnectionId} onValueChange={setExistingConnectionId}> | |
| 1769 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 1770 | − <SelectValue placeholder="Select a connection" /> | |
| 1771 | − </SelectTrigger> | |
| 1772 | − <SelectContent> | |
| 1773 | − {compatibleConnectionOptions.map((connection) => ( | |
| 1774 | − <SelectItem key={connection.id} value={connection.id} className="text-xs"> | |
| 1775 | − {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"}) | |
| 1776 | − </SelectItem> | |
| 1777 | − ))} | |
| 1778 | − </SelectContent> | |
| 1779 | − </Select> | |
| 1780 | − {compatibleConnectionOptions.length === 0 && ( | |
| 1781 | − <p className="text-[10px] text-muted-foreground">No compatible existing connections for this scope.</p> | |
| 1782 | − )} | |
| 1783 | − </div> | |
| 1784 | − )} | |
| 1785 | − | |
| 1786 | − {(editing || connectionMode === "new") && ( | |
| 1787 | − <> | |
| 1788 | − {editing && ( | |
| 1789 | − <p className="text-[11px] text-muted-foreground"> | |
| 1790 | − Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it. | |
| 1791 | − </p> | |
| 1792 | − )} | |
| 1793 | − | |
| 1794 | − {selectedAuth.type === "none" ? ( | |
| 1795 | − <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p> | |
| 1796 | − ) : selectedAuth.type === "mixed" ? ( | |
| 1797 | − <p className="text-[11px] text-terminal-amber"> | |
| 1798 | − This source has mixed auth requirements. Link an existing connection for now. | |
| 1799 | − </p> | |
| 1800 | − ) : selectedAuth.type === "apiKey" ? ( | |
| 1801 | − <div className="space-y-1.5"> | |
| 1802 | − <Label className="text-xs text-muted-foreground">API Key Value</Label> | |
| 1803 | − <Input | |
| 1804 | − value={apiKeyValue} | |
| 1805 | − onChange={(e) => setApiKeyValue(e.target.value)} | |
| 1806 | − placeholder="sk_live_..." | |
| 1807 | − className="h-8 text-xs font-mono bg-background" | |
| 1808 | − /> | |
| 1809 | − </div> | |
| 1810 | − ) : selectedAuth.type === "basic" ? ( | |
| 1811 | − <div className="grid grid-cols-2 gap-3"> | |
| 1812 | − <div className="space-y-1.5"> | |
| 1813 | − <Label className="text-xs text-muted-foreground">Username</Label> | |
| 1814 | − <Input | |
| 1815 | − value={basicUsername} | |
| 1816 | − onChange={(e) => setBasicUsername(e.target.value)} | |
| 1817 | − className="h-8 text-xs font-mono bg-background" | |
| 1818 | − /> | |
| 1819 | − </div> | |
| 1820 | − <div className="space-y-1.5"> | |
| 1821 | − <Label className="text-xs text-muted-foreground">Password</Label> | |
| 1822 | − <Input | |
| 1823 | − type="password" | |
| 1824 | − value={basicPassword} | |
| 1825 | − onChange={(e) => setBasicPassword(e.target.value)} | |
| 1826 | − className="h-8 text-xs font-mono bg-background" | |
| 1827 | − /> | |
| 1828 | − </div> | |
| 1829 | − </div> | |
| 1830 | − ) : ( | |
| 1831 | − <div className="space-y-1.5"> | |
| 1832 | − <Label className="text-xs text-muted-foreground">Bearer Token</Label> | |
| 1833 | − <Input | |
| 1834 | − type="password" | |
| 1835 | − value={tokenValue} | |
| 1836 | − onChange={(e) => setTokenValue(e.target.value)} | |
| 1837 | − placeholder="ghp_..." | |
| 1838 | − className="h-8 text-xs font-mono bg-background" | |
| 1839 | − /> | |
| 1840 | − </div> | |
| 1841 | − )} | |
| 1842 | − </> | |
| 1843 | − )} | |
| 1844 | − | |
| 1845 | − <div className="space-y-1.5"> | |
| 1846 | − <Label className="text-xs text-muted-foreground">Custom Headers (optional)</Label> | |
| 1847 | − <Textarea | |
| 1848 | − value={customHeadersText} | |
| 1849 | − onChange={(e) => setCustomHeadersText(e.target.value)} | |
| 1850 | − rows={4} | |
| 1851 | − placeholder="x-tenant-id: acme\nx-env: staging" | |
| 1852 | − className="text-xs font-mono bg-background" | |
| 1853 | − /> | |
| 1854 | − </div> | |
| 1855 | − | |
| 1856 | − <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | |
| 1857 | − {saving ? "Saving..." : editing ? "Update Connection" : connectionMode === "existing" ? "Link Connection" : "Save Connection"} | |
| 1858 | − </Button> | |
| 1859 | − </div> | |
| 1860 | − </DialogContent> | |
| 1861 | − </Dialog> | |
| 1862 | − </Card> | |
| 1863 | − ); | |
| 1864 | −} | |
| 25 | +import { | |
| 26 | + credentialStatsForSource, | |
| 27 | + sourceKeyForSource, | |
| 28 | + toolSourceLabelForSource, | |
| 29 | +} from "@/lib/tools-source-helpers"; | |
| 30 | +import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | |
| 1865 | 31 | |
| 1866 | 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 | 42 | const sources = useQuery( |
| 1877 | 43 | convexApi.workspace.listToolSources, |
| 1878 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 44 | + workspaceQueryArgs(context), | |
| 1879 | 45 | ); |
| 1880 | 46 | const sourceItems: ToolSourceRecord[] = sources ?? []; |
| 1881 | 47 | const sourcesLoading = !!context && sources === undefined; |
| 1882 | 48 | |
| 1883 | 49 | const credentials = useQuery( |
| 1884 | 50 | convexApi.workspace.listCredentials, |
| 1885 | − context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 51 | + workspaceQueryArgs(context), | |
| 1886 | 52 | ); |
| 1887 | 53 | const credentialItems: CredentialRecord[] = credentials ?? []; |
| 1888 | 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 | 13 | import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities"; |
| 14 | 14 | import { useWorkosAuthState } from "@/lib/convex-provider"; |
| 15 | 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 | 33 | import type { AnonymousContext } from "./types"; |
| 17 | 34 | import type { Id } from "@executor/convex/_generated/dataModel"; |
| 18 | 35 | |
| @@ -65,26 +82,6 @@ interface SessionState { · 46 unmodified lines | ||
| 65 | 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 | 85 | const SessionContext = createContext<SessionState>({ |
| 89 | 86 | context: null, |
| 90 | 87 | loading: true, |
| @@ -104,41 +101,13 @@ const SessionContext = createContext<SessionState>({ · 13 unmodified lines | ||
| 104 | 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 | 104 | export function SessionProvider({ children }: { children: ReactNode }) { |
| 127 | 105 | const { loading: workosAuthLoading, authenticated: workosAuthenticated } = useWorkosAuthState(); |
| 128 | 106 | const bootstrapAnonymousSession = useMutation(convexApi.workspace.bootstrapAnonymousSession); |
| 129 | − const [storedSessionId, setStoredSessionId] = useState<string | null>(() => { | |
| 130 | − if (typeof window === "undefined") { | |
| 131 | − return null; | |
| 132 | − } | |
| 133 | − return localStorage.getItem(SESSION_KEY); | |
| 134 | − }); | |
| 135 | − const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>(() => { | |
| 136 | − if (typeof window === "undefined") { | |
| 137 | − return null; | |
| 138 | − } | |
| 139 | − const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY); | |
| 140 | − return stored as Id<"workspaces"> | null; | |
| 141 | − }); | |
| 107 | + const [storedSessionId, setStoredSessionId] = useState<string | null>(readStoredSessionId); | |
| 108 | + const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>( | |
| 109 | + readStoredActiveWorkspaceId, | |
| 110 | + ); | |
| 142 | 111 | const [runtimeError, setRuntimeError] = useState<string | null>(null); |
| 143 | 112 | |
| 144 | 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 | 132 | const context = await bootstrapAnonymousSession({ sessionId: storedSessionId }); |
| 164 | − localStorage.setItem(SESSION_KEY, context.sessionId); | |
| 133 | + persistSessionId(context.sessionId); | |
| 165 | 134 | if (context.sessionId !== storedSessionId) { |
| 166 | 135 | setStoredSessionId(context.sessionId); |
| 167 | 136 | } |
| @@ -185,21 +154,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 17 unmodified lines | ||
| 185 | 154 | ); |
| 186 | 155 | |
| 187 | 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 | 157 | const accountId = account?._id ?? null; |
| 197 | 158 | const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null; |
| 198 | − if (accountStoredWorkspace && workspaces.some((workspace) => workspace.id === accountStoredWorkspace)) { | |
| 199 | − return accountStoredWorkspace; | |
| 200 | − } | |
| 201 | − | |
| 202 | − return workspaces[0]?.id ?? null; | |
| 159 | + return resolveActiveWorkspaceId(workspaces, activeWorkspaceId, accountStoredWorkspace); | |
| 203 | 160 | }, [workspaces, activeWorkspaceId, account]); |
| 204 | 161 | |
| 205 | 162 | const bootstrapWorkosAccountQuery = useTanstackQuery({ |
| @@ -219,8 +176,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 13 unmodified lines | ||
| 219 | 176 | }); |
| 220 | 177 | |
| 221 | 178 | const resetWorkspace = useCallback(async () => { |
| 222 | − localStorage.removeItem(SESSION_KEY); | |
| 223 | − localStorage.removeItem(ACTIVE_WORKSPACE_KEY); | |
| 179 | + clearSessionStorage(); | |
| 224 | 180 | setStoredSessionId(null); |
| 225 | 181 | setActiveWorkspaceId(null); |
| 226 | 182 | setManualGuestContext(null); |
| @@ -236,8 +192,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 9 unmodified lines | ||
| 236 | 192 | setCreatingAnonymousOrganization(true); |
| 237 | 193 | try { |
| 238 | 194 | const context = await bootstrapAnonymousSession({}); |
| 239 | − localStorage.setItem(SESSION_KEY, context.sessionId); | |
| 240 | − localStorage.setItem(ACTIVE_WORKSPACE_KEY, context.workspaceId); | |
| 195 | + persistSessionId(context.sessionId); | |
| 196 | + persistActiveWorkspaceId(context.workspaceId); | |
| 241 | 197 | setStoredSessionId(context.sessionId); |
| 242 | 198 | setActiveWorkspaceId(context.workspaceId); |
| 243 | 199 | setManualGuestContext(context); |
| @@ -252,15 +208,10 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 8 unmodified lines | ||
| 252 | 208 | |
| 253 | 209 | const switchWorkspace = useCallback((workspaceId: Id<"workspaces">) => { |
| 254 | 210 | setActiveWorkspaceId(workspaceId); |
| 255 | − localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId); | |
| 211 | + persistActiveWorkspaceId(workspaceId); | |
| 256 | 212 | |
| 257 | 213 | if (account) { |
| 258 | − const accountId = account._id; | |
| 259 | − const byAccount = readWorkspaceByAccount(); | |
| 260 | − writeWorkspaceByAccount({ | |
| 261 | − ...byAccount, | |
| 262 | − [accountId]: workspaceId, | |
| 263 | − }); | |
| 214 | + persistWorkspaceForAccount(account._id, workspaceId); | |
| 264 | 215 | } |
| 265 | 216 | }, [account]); |
| 266 | 217 | |
| @@ -323,37 +274,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 56 unmodified lines | ||
| 323 | 274 | ]); |
| 324 | 275 | |
| 325 | 276 | const accountWorkspaceContext = useMemo<AnonymousContext | null>(() => { |
| 326 | − if (!account || !workspaces || workspaces.length === 0) { | |
| 327 | − return null; | |
| 328 | − } | |
| 329 | − | |
| 330 | − const activeWorkspace = | |
| 331 | − workspaces.find((workspace) => workspace.id === resolvedActiveWorkspaceId) | |
| 332 | − ?? workspaces[0] | |
| 333 | − ?? null; | |
| 334 | − if (!activeWorkspace) { | |
| 335 | − return null; | |
| 336 | − } | |
| 337 | − | |
| 338 | − const sessionId = account.provider === "workos" | |
| 339 | − ? `workos_${account._id}` | |
| 340 | − : (storedSessionId ?? guestContext?.sessionId ?? null); | |
| 341 | − if (!sessionId) { | |
| 342 | − return null; | |
| 343 | − } | |
| 344 | − | |
| 345 | − const actorId = account.provider === "workos" ? account._id : account.providerAccountId; | |
| 346 | − | |
| 347 | − return { | |
| 348 | − sessionId, | |
| 349 | − workspaceId: activeWorkspace.id, | |
| 350 | − actorId, | |
| 351 | − clientId: "web", | |
| 352 | − accountId: account._id, | |
| 353 | − userId: account._id, | |
| 354 | − createdAt: Date.now(), | |
| 355 | − lastSeenAt: Date.now(), | |
| 356 | − }; | |
| 277 | + return buildAccountWorkspaceContext( | |
| 278 | + account, | |
| 279 | + workspaces, | |
| 280 | + resolvedActiveWorkspaceId, | |
| 281 | + storedSessionId, | |
| 282 | + guestContext?.sessionId ?? null, | |
| 283 | + ); | |
| 357 | 284 | }, [account, guestContext?.sessionId, resolvedActiveWorkspaceId, storedSessionId, workspaces]); |
| 358 | 285 | |
| 359 | 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 | 299 | const context = accountWorkspaceContext ?? ((workosStillLoading || shouldBlockGuestFallback) ? null : guestContext); |
| 373 | 300 | |
| 374 | 301 | const bootstrapSessionError = |
| 375 | − storedSessionId && bootstrapSessionQuery.error instanceof Error | |
| 376 | − ? bootstrapSessionQuery.error.message | |
| 377 | − : storedSessionId && bootstrapSessionQuery.error | |
| 378 | − ? "Failed to bootstrap session" | |
| 379 | − : null; | |
| 380 | − const bootstrapWorkosError = | |
| 381 | − bootstrapWorkosAccountQuery.error instanceof Error | |
| 382 | − ? bootstrapWorkosAccountQuery.error.message | |
| 383 | − : bootstrapWorkosAccountQuery.error | |
| 384 | − ? "Failed to bootstrap WorkOS account" | |
| 385 | − : null; | |
| 302 | + storedSessionId | |
| 303 | + ? queryErrorMessage(bootstrapSessionQuery.error, "Failed to bootstrap session") | |
| 304 | + : null; | |
| 305 | + const bootstrapWorkosError = queryErrorMessage( | |
| 306 | + bootstrapWorkosAccountQuery.error, | |
| 307 | + "Failed to bootstrap WorkOS account", | |
| 308 | + ); | |
| 386 | 309 | const error = runtimeError ?? bootstrapSessionError ?? bootstrapWorkosError; |
| 387 | 310 | |
| 388 | 311 | const effectiveLoading = !context && !error && ( |
| @@ -392,34 +315,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 3 unmodified lines | ||
| 392 | 315 | || bootstrapWorkosAccountQuery.isFetching |
| 393 | 316 | ); |
| 394 | 317 | const workspaceOptions = useMemo(() => { |
| 395 | − if (mode !== "guest" && workspaces) { | |
| 396 | − return workspaces.map((workspace): SessionState["workspaces"][number] => { | |
| 397 | − return { | |
| 398 | − id: workspace.id, | |
| 399 | − docId: workspace.id, | |
| 400 | − name: workspace.name, | |
| 401 | − organizationId: workspace.organizationId, | |
| 402 | − organizationName: workspace.organizationName, | |
| 403 | − organizationSlug: workspace.organizationSlug, | |
| 404 | − iconUrl: workspace.iconUrl, | |
| 405 | − }; | |
| 406 | − }); | |
| 407 | − } | |
| 408 | − | |
| 409 | − if (guestContext) { | |
| 410 | − return [ | |
| 411 | − { | |
| 412 | − id: guestContext.workspaceId, | |
| 413 | − docId: null, | |
| 414 | − name: "Anonymous Workspace", | |
| 415 | − organizationId: null, | |
| 416 | − organizationName: "Anonymous Organization", | |
| 417 | − organizationSlug: "anonymous-organization", | |
| 418 | − }, | |
| 419 | − ]; | |
| 420 | − } | |
| 421 | − | |
| 422 | − return []; | |
| 318 | + return buildWorkspaceOptions(mode, workspaces, guestContext); | |
| 423 | 319 | }, [mode, workspaces, guestContext]); |
| 424 | 320 | |
| 425 | 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 | 1 | import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit"; |
| 2 | 2 | import { components, internal } from "./_generated/api"; |
| 3 | −import type { DataModel, Doc, Id } from "./_generated/dataModel"; | |
| 4 | −import type { MutationCtx } from "./_generated/server"; | |
| 3 | +import type { DataModel } from "./_generated/dataModel"; | |
| 5 | 4 | import { internalMutation, mutation } from "./_generated/server"; |
| 6 | −import { slugify } from "../core/src/identity"; | |
| 7 | −import { ensureUniqueSlug } from "../core/src/slug"; | |
| 8 | − | |
| 9 | −type DbCtx = Pick<MutationCtx, "db">; | |
| 10 | −type RunQueryCtx = Pick<MutationCtx, "runQuery">; | |
| 11 | −type WorkosEventCtx = Pick<MutationCtx, "db" | "runQuery">; | |
| 12 | −type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | |
| 13 | −type OrganizationMemberStatus = "active" | "pending" | "removed"; | |
| 14 | − | |
| 15 | −function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): Doc<"workspaceMembers">["role"] { | |
| 16 | − if (role === "owner") { | |
| 17 | − return "owner"; | |
| 18 | − } | |
| 19 | − if (role === "admin") { | |
| 20 | − return "admin"; | |
| 21 | − } | |
| 22 | − return "member"; | |
| 23 | −} | |
| 5 | +import { bootstrapCurrentWorkosAccountImpl } from "./auth-bootstrap"; | |
| 6 | +import { workosEventHandlers } from "./auth-event-handlers"; | |
| 24 | 7 | |
| 25 | 8 | const workosEnabled = Boolean( |
| 26 | 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 | 32 | registerRoutes: () => {}, |
| 50 | 33 | } as Pick<AuthKit<DataModel>, "registerRoutes">); |
| 51 | 34 | |
| 52 | −function titleCaseWords(input: string): string { | |
| 53 | − return input | |
| 54 | − .split(" ") | |
| 55 | − .filter((segment) => segment.length > 0) | |
| 56 | − .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()) | |
| 57 | − .join(" "); | |
| 58 | −} | |
| 59 | − | |
| 60 | −function escapeRegex(value: string): string { | |
| 61 | − return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 62 | −} | |
| 63 | − | |
| 64 | −function isGeneratedPersonalOrganizationName(name: string, workosUserId: string): boolean { | |
| 65 | − if (/workspace$/i.test(name)) { | |
| 66 | − return true; | |
| 67 | − } | |
| 68 | − | |
| 69 | − if (/^user(?:[\s_][a-z0-9]+)?'s organization$/i.test(name)) { | |
| 70 | − return true; | |
| 71 | − } | |
| 72 | − | |
| 73 | − return new RegExp(`^${escapeRegex(workosUserId)}'s organization$`, "i").test(name); | |
| 74 | −} | |
| 75 | − | |
| 76 | −function isGeneratedPersonalWorkspaceName(name: string, workosUserId: string): boolean { | |
| 77 | − if (/^my'?s workspace$/i.test(name)) { | |
| 78 | − return true; | |
| 79 | − } | |
| 80 | − | |
| 81 | − if (/^user(?:[\s_][a-z0-9]+)?'s workspace$/i.test(name)) { | |
| 82 | − return true; | |
| 83 | − } | |
| 84 | − | |
| 85 | − return new RegExp(`^${escapeRegex(workosUserId)}'s workspace$`, "i").test(name); | |
| 86 | −} | |
| 87 | − | |
| 88 | −function deriveOwnerLabel(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }): string { | |
| 89 | − const firstName = args.firstName?.trim(); | |
| 90 | − if (firstName && !/^my$/i.test(firstName)) { | |
| 91 | − return firstName; | |
| 92 | − } | |
| 93 | − | |
| 94 | − const fullName = args.fullName?.trim(); | |
| 95 | − if (fullName && !fullName.includes("@")) { | |
| 96 | − return fullName; | |
| 97 | − } | |
| 98 | − | |
| 99 | − const emailLocalPart = args.email.split("@")[0]?.trim(); | |
| 100 | − if (emailLocalPart) { | |
| 101 | − const normalized = emailLocalPart | |
| 102 | − .replace(/[._-]+/g, " ") | |
| 103 | − .replace(/[^a-zA-Z0-9 ]+/g, " ") | |
| 104 | − .replace(/\s+/g, " ") | |
| 105 | − .trim(); | |
| 106 | − if (normalized.length > 0) { | |
| 107 | − return titleCaseWords(normalized); | |
| 108 | − } | |
| 109 | − } | |
| 110 | − | |
| 111 | − return `User ${args.workosUserId.slice(-6)}`; | |
| 112 | −} | |
| 113 | − | |
| 114 | −function derivePersonalNames(args: { firstName?: string; fullName?: string; email: string; workosUserId: string }) { | |
| 115 | − const ownerLabel = deriveOwnerLabel(args); | |
| 116 | − return { | |
| 117 | − organizationName: `${ownerLabel}'s Organization`, | |
| 118 | − workspaceName: `${ownerLabel}'s Workspace`, | |
| 119 | − }; | |
| 120 | −} | |
| 121 | − | |
| 122 | −async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) { | |
| 123 | − return await ctx.db | |
| 124 | − .query("accounts") | |
| 125 | − .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", workosUserId)) | |
| 126 | − .unique(); | |
| 127 | −} | |
| 128 | − | |
| 129 | −async function getWorkspaceByWorkosOrgId(ctx: DbCtx, workosOrgId: string) { | |
| 130 | − return await ctx.db | |
| 131 | − .query("workspaces") | |
| 132 | − .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId)) | |
| 133 | − .unique(); | |
| 134 | −} | |
| 135 | − | |
| 136 | −async function getOrganizationByWorkosOrgId(ctx: DbCtx, workosOrgId: string) { | |
| 137 | − return await ctx.db | |
| 138 | − .query("organizations") | |
| 139 | − .withIndex("by_workos_org_id", (q) => q.eq("workosOrgId", workosOrgId)) | |
| 140 | − .unique(); | |
| 141 | −} | |
| 142 | − | |
| 143 | −async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: Id<"organizations">) { | |
| 144 | − return await ctx.db | |
| 145 | − .query("workspaces") | |
| 146 | − .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) | |
| 147 | − .first(); | |
| 148 | −} | |
| 149 | − | |
| 150 | −async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> { | |
| 151 | − const baseSlug = slugify(baseName, "workspace"); | |
| 152 | − return await ensureUniqueSlug(baseSlug, async (candidate) => { | |
| 153 | − const collision = await ctx.db | |
| 154 | − .query("organizations") | |
| 155 | − .withIndex("by_slug", (q) => q.eq("slug", candidate)) | |
| 156 | − .unique(); | |
| 157 | − return collision !== null; | |
| 158 | − }); | |
| 159 | −} | |
| 160 | − | |
| 161 | −async function upsertOrganizationMembership( | |
| 162 | − ctx: DbCtx, | |
| 163 | − args: { | |
| 164 | − organizationId: Id<"organizations">; | |
| 165 | − accountId: Id<"accounts">; | |
| 166 | − role: OrganizationRole; | |
| 167 | − status: OrganizationMemberStatus; | |
| 168 | − billable: boolean; | |
| 169 | − invitedByAccountId?: Id<"accounts">; | |
| 170 | − now: number; | |
| 171 | − }, | |
| 172 | −) { | |
| 173 | − const existing = await ctx.db | |
| 174 | − .query("organizationMembers") | |
| 175 | − .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId)) | |
| 176 | − .unique(); | |
| 177 | − | |
| 178 | − if (existing) { | |
| 179 | − await ctx.db.patch(existing._id, { | |
| 180 | − role: args.role, | |
| 181 | − status: args.status, | |
| 182 | − billable: args.billable, | |
| 183 | − invitedByAccountId: args.invitedByAccountId, | |
| 184 | − joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt, | |
| 185 | − updatedAt: args.now, | |
| 186 | − }); | |
| 187 | − return; | |
| 188 | − } | |
| 189 | − | |
| 190 | − await ctx.db.insert("organizationMembers", { | |
| 191 | − organizationId: args.organizationId, | |
| 192 | − accountId: args.accountId, | |
| 193 | − role: args.role, | |
| 194 | − status: args.status, | |
| 195 | − billable: args.billable, | |
| 196 | − invitedByAccountId: args.invitedByAccountId, | |
| 197 | − joinedAt: args.status === "active" ? args.now : undefined, | |
| 198 | − createdAt: args.now, | |
| 199 | − updatedAt: args.now, | |
| 200 | − }); | |
| 201 | −} | |
| 202 | − | |
| 203 | −async function ensureWorkspaceMembership( | |
| 204 | − ctx: DbCtx, | |
| 205 | − args: { | |
| 206 | − workspaceId: Id<"workspaces">; | |
| 207 | − accountId: Id<"accounts">; | |
| 208 | − role: Doc<"workspaceMembers">["role"]; | |
| 209 | − now: number; | |
| 210 | − }, | |
| 211 | −) { | |
| 212 | − const existing = await ctx.db | |
| 213 | − .query("workspaceMembers") | |
| 214 | − .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId)) | |
| 215 | − .unique(); | |
| 216 | − | |
| 217 | − if (existing) { | |
| 218 | − if (existing.status === "active") { | |
| 219 | − await ctx.db.patch(existing._id, { | |
| 220 | − role: args.role, | |
| 221 | − updatedAt: args.now, | |
| 222 | − }); | |
| 223 | − } | |
| 224 | − return; | |
| 225 | − } | |
| 226 | − | |
| 227 | − await ctx.db.insert("workspaceMembers", { | |
| 228 | − workspaceId: args.workspaceId, | |
| 229 | − accountId: args.accountId, | |
| 230 | − role: args.role, | |
| 231 | − status: "active", | |
| 232 | − createdAt: args.now, | |
| 233 | − updatedAt: args.now, | |
| 234 | − }); | |
| 235 | −} | |
| 236 | − | |
| 237 | −async function markPendingInvitesAcceptedByEmail( | |
| 238 | − ctx: DbCtx, | |
| 239 | − args: { | |
| 240 | − organizationId: Id<"organizations">; | |
| 241 | − email?: string; | |
| 242 | − acceptedAt: number; | |
| 243 | − }, | |
| 244 | −) { | |
| 245 | − if (!args.email) { | |
| 246 | − return; | |
| 247 | − } | |
| 248 | − | |
| 249 | − const normalizedEmail = args.email.toLowerCase(); | |
| 250 | − const pendingInvites = await ctx.db | |
| 251 | − .query("invites") | |
| 252 | − .withIndex("by_org_email_status", (q) => | |
| 253 | − q.eq("organizationId", args.organizationId).eq("email", normalizedEmail).eq("status", "pending"), | |
| 254 | − ) | |
| 255 | − .collect(); | |
| 256 | − | |
| 257 | − for (const invite of pendingInvites) { | |
| 258 | − await ctx.db.patch(invite._id, { | |
| 259 | − status: "accepted", | |
| 260 | − acceptedAt: args.acceptedAt, | |
| 261 | − updatedAt: args.acceptedAt, | |
| 262 | − }); | |
| 263 | − } | |
| 264 | −} | |
| 265 | − | |
| 266 | −async function ensurePersonalWorkspace( | |
| 267 | − ctx: DbCtx, | |
| 268 | − accountId: Id<"accounts">, | |
| 269 | − opts: { email: string; firstName?: string; fullName?: string; workosUserId: string; now: number }, | |
| 270 | −) { | |
| 271 | − const personalNames = derivePersonalNames({ | |
| 272 | − firstName: opts.firstName, | |
| 273 | − fullName: opts.fullName, | |
| 274 | − email: opts.email, | |
| 275 | − workosUserId: opts.workosUserId, | |
| 276 | − }); | |
| 277 | − | |
| 278 | − const memberships = await ctx.db | |
| 279 | − .query("workspaceMembers") | |
| 280 | − .withIndex("by_account", (q) => q.eq("accountId", accountId)) | |
| 281 | − .collect(); | |
| 282 | − | |
| 283 | − for (const membership of memberships) { | |
| 284 | − const workspace = await ctx.db.get(membership.workspaceId); | |
| 285 | − if (workspace && workspace.createdByAccountId === accountId) { | |
| 286 | − const organization = await ctx.db.get(workspace.organizationId); | |
| 287 | − | |
| 288 | − if (organization && organization.createdByAccountId === accountId) { | |
| 289 | − const shouldRenameOrganization = isGeneratedPersonalOrganizationName(organization.name, opts.workosUserId); | |
| 290 | − if (shouldRenameOrganization && organization.name !== personalNames.organizationName) { | |
| 291 | − await ctx.db.patch(organization._id, { | |
| 292 | − name: personalNames.organizationName, | |
| 293 | − updatedAt: opts.now, | |
| 294 | − }); | |
| 295 | − } | |
| 296 | − } | |
| 297 | − | |
| 298 | − const shouldRenameWorkspace = isGeneratedPersonalWorkspaceName(workspace.name, opts.workosUserId); | |
| 299 | − if (shouldRenameWorkspace && workspace.name !== personalNames.workspaceName) { | |
| 300 | − await ctx.db.patch(workspace._id, { | |
| 301 | − name: personalNames.workspaceName, | |
| 302 | − updatedAt: opts.now, | |
| 303 | − }); | |
| 304 | − } | |
| 305 | − | |
| 306 | − await upsertOrganizationMembership(ctx, { | |
| 307 | − organizationId: workspace.organizationId, | |
| 308 | − accountId, | |
| 309 | − role: "owner", | |
| 310 | − status: "active", | |
| 311 | − billable: true, | |
| 312 | − now: opts.now, | |
| 313 | − }); | |
| 314 | − const refreshedWorkspace = await ctx.db.get(workspace._id); | |
| 315 | − return { workspace: refreshedWorkspace, membership }; | |
| 316 | − } | |
| 317 | − } | |
| 318 | − | |
| 319 | − const organizationSlug = await ensureUniqueOrganizationSlug(ctx, personalNames.organizationName); | |
| 320 | − const organizationId = await ctx.db.insert("organizations", { | |
| 321 | − slug: organizationSlug, | |
| 322 | − name: personalNames.organizationName, | |
| 323 | − status: "active", | |
| 324 | − createdByAccountId: accountId, | |
| 325 | − createdAt: opts.now, | |
| 326 | − updatedAt: opts.now, | |
| 327 | − }); | |
| 328 | − | |
| 329 | − const baseSlug = slugify(opts.email.split("@")[0] ?? opts.workosUserId, "workspace"); | |
| 330 | − const workspaceId = await ctx.db.insert("workspaces", { | |
| 331 | − organizationId, | |
| 332 | − slug: `${baseSlug}-${opts.workosUserId.slice(-6)}`, | |
| 333 | − name: personalNames.workspaceName, | |
| 334 | − createdByAccountId: accountId, | |
| 335 | − createdAt: opts.now, | |
| 336 | − updatedAt: opts.now, | |
| 337 | − }); | |
| 338 | − | |
| 339 | − await upsertOrganizationMembership(ctx, { | |
| 340 | − organizationId, | |
| 341 | − accountId, | |
| 342 | − role: "owner", | |
| 343 | − status: "active", | |
| 344 | − billable: true, | |
| 345 | − now: opts.now, | |
| 346 | − }); | |
| 347 | − | |
| 348 | − const userId = await ctx.db.insert("workspaceMembers", { | |
| 349 | − workspaceId, | |
| 350 | − accountId, | |
| 351 | − role: "owner", | |
| 352 | − status: "active", | |
| 353 | − createdAt: opts.now, | |
| 354 | − updatedAt: opts.now, | |
| 355 | − }); | |
| 356 | − | |
| 357 | − return { | |
| 358 | − workspace: await ctx.db.get(workspaceId), | |
| 359 | − membership: await ctx.db.get(userId), | |
| 360 | − }; | |
| 361 | −} | |
| 362 | − | |
| 363 | −async function refreshGeneratedPersonalWorkspaceNames( | |
| 364 | − ctx: DbCtx, | |
| 365 | − accountId: Id<"accounts">, | |
| 366 | − opts: { email: string; firstName?: string; fullName?: string; workosUserId: string; now: number }, | |
| 367 | −) { | |
| 368 | − const personalNames = derivePersonalNames({ | |
| 369 | − firstName: opts.firstName, | |
| 370 | − fullName: opts.fullName, | |
| 371 | − email: opts.email, | |
| 372 | − workosUserId: opts.workosUserId, | |
| 373 | − }); | |
| 374 | − | |
| 375 | − const memberships = await ctx.db | |
| 376 | − .query("workspaceMembers") | |
| 377 | − .withIndex("by_account", (q) => q.eq("accountId", accountId)) | |
| 378 | − .collect(); | |
| 379 | − | |
| 380 | − for (const membership of memberships) { | |
| 381 | − const workspace = await ctx.db.get(membership.workspaceId); | |
| 382 | − if (!workspace || workspace.createdByAccountId !== accountId) { | |
| 383 | − continue; | |
| 384 | − } | |
| 385 | − | |
| 386 | − const organization = await ctx.db.get(workspace.organizationId); | |
| 387 | − if (!organization || organization.createdByAccountId !== accountId) { | |
| 388 | − continue; | |
| 389 | − } | |
| 390 | − | |
| 391 | − if ( | |
| 392 | − isGeneratedPersonalOrganizationName(organization.name, opts.workosUserId) | |
| 393 | − && organization.name !== personalNames.organizationName | |
| 394 | − ) { | |
| 395 | − await ctx.db.patch(organization._id, { | |
| 396 | − name: personalNames.organizationName, | |
| 397 | − updatedAt: opts.now, | |
| 398 | − }); | |
| 399 | − } | |
| 400 | − | |
| 401 | − if ( | |
| 402 | − isGeneratedPersonalWorkspaceName(workspace.name, opts.workosUserId) | |
| 403 | − && workspace.name !== personalNames.workspaceName | |
| 404 | − ) { | |
| 405 | − await ctx.db.patch(workspace._id, { | |
| 406 | − name: personalNames.workspaceName, | |
| 407 | − updatedAt: opts.now, | |
| 408 | − }); | |
| 409 | − } | |
| 410 | − } | |
| 411 | −} | |
| 412 | − | |
| 413 | −function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { | |
| 414 | − for (const key of keys) { | |
| 415 | − const value = identity[key]; | |
| 416 | − if (typeof value === "string" && value.trim().length > 0) { | |
| 417 | − return value.trim(); | |
| 418 | − } | |
| 419 | − } | |
| 420 | − return undefined; | |
| 421 | −} | |
| 422 | − | |
| 423 | −async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { | |
| 424 | − try { | |
| 425 | − return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, { | |
| 426 | − id: workosUserId, | |
| 427 | − }); | |
| 428 | − } catch { | |
| 429 | − return null; | |
| 430 | − } | |
| 431 | −} | |
| 432 | − | |
| 433 | −const workosEventHandlers = { | |
| 434 | − "user.created": async (ctx, event) => { | |
| 435 | − const now = Date.now(); | |
| 436 | − const data = event.data; | |
| 437 | − const fullName = [data.firstName, data.lastName].filter(Boolean).join(" ") || data.email; | |
| 438 | − | |
| 439 | − let account = await getAccountByWorkosId(ctx, data.id); | |
| 440 | − if (account) { | |
| 441 | − await ctx.db.patch(account._id, { | |
| 442 | − email: data.email, | |
| 443 | − name: fullName, | |
| 444 | − firstName: data.firstName ?? undefined, | |
| 445 | − lastName: data.lastName ?? undefined, | |
| 446 | − avatarUrl: data.profilePictureUrl ?? undefined, | |
| 447 | − status: "active", | |
| 448 | − updatedAt: now, | |
| 449 | − lastLoginAt: now, | |
| 450 | − }); | |
| 451 | − account = await ctx.db.get(account._id); | |
| 452 | − } else { | |
| 453 | − const accountId = await ctx.db.insert("accounts", { | |
| 454 | − provider: "workos", | |
| 455 | − providerAccountId: data.id, | |
| 456 | − email: data.email, | |
| 457 | − name: fullName, | |
| 458 | − firstName: data.firstName ?? undefined, | |
| 459 | − lastName: data.lastName ?? undefined, | |
| 460 | − avatarUrl: data.profilePictureUrl ?? undefined, | |
| 461 | − status: "active", | |
| 462 | − createdAt: now, | |
| 463 | − updatedAt: now, | |
| 464 | − lastLoginAt: now, | |
| 465 | − }); | |
| 466 | − account = await ctx.db.get(accountId); | |
| 467 | − } | |
| 468 | − | |
| 469 | − if (!account) return; | |
| 470 | − }, | |
| 471 | − | |
| 472 | − "user.updated": async (ctx, event) => { | |
| 473 | − const account = await getAccountByWorkosId(ctx, event.data.id); | |
| 474 | − if (!account) return; | |
| 475 | − | |
| 476 | − const fullName = [event.data.firstName, event.data.lastName].filter(Boolean).join(" ") || event.data.email; | |
| 477 | − await ctx.db.patch(account._id, { | |
| 478 | − email: event.data.email, | |
| 479 | − name: fullName, | |
| 480 | − firstName: event.data.firstName ?? undefined, | |
| 481 | − lastName: event.data.lastName ?? undefined, | |
| 482 | − avatarUrl: event.data.profilePictureUrl ?? undefined, | |
| 483 | − status: "active", | |
| 484 | − updatedAt: Date.now(), | |
| 485 | − }); | |
| 486 | − }, | |
| 487 | − | |
| 488 | − "user.deleted": async (ctx, event) => { | |
| 489 | − const account = await getAccountByWorkosId(ctx, event.data.id); | |
| 490 | − if (!account) return; | |
| 491 | − | |
| 492 | − const memberships = await ctx.db | |
| 493 | − .query("workspaceMembers") | |
| 494 | − .withIndex("by_account", (q) => q.eq("accountId", account._id)) | |
| 495 | − .collect(); | |
| 496 | − for (const membership of memberships) { | |
| 497 | − await ctx.db.delete(membership._id); | |
| 498 | − } | |
| 499 | − | |
| 500 | − await ctx.db.delete(account._id); | |
| 501 | − }, | |
| 502 | − | |
| 503 | − "organization.created": async (ctx, event) => { | |
| 504 | − const now = Date.now(); | |
| 505 | − let organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | |
| 506 | − if (organization) { | |
| 507 | − await ctx.db.patch(organization._id, { | |
| 508 | − name: event.data.name, | |
| 509 | − status: "active", | |
| 510 | − updatedAt: now, | |
| 511 | − }); | |
| 512 | − organization = await ctx.db.get(organization._id); | |
| 513 | − } else { | |
| 514 | − const slug = await ensureUniqueOrganizationSlug(ctx, event.data.name); | |
| 515 | − const organizationId = await ctx.db.insert("organizations", { | |
| 516 | − workosOrgId: event.data.id, | |
| 517 | − slug, | |
| 518 | − name: event.data.name, | |
| 519 | − status: "active", | |
| 520 | − createdAt: now, | |
| 521 | − updatedAt: now, | |
| 522 | − }); | |
| 523 | − organization = await ctx.db.get(organizationId); | |
| 524 | − } | |
| 525 | − | |
| 526 | − if (!organization) { | |
| 527 | − return; | |
| 528 | − } | |
| 529 | − | |
| 530 | − const existingWorkspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id); | |
| 531 | − if (existingWorkspace) { | |
| 532 | − await ctx.db.patch(existingWorkspace._id, { | |
| 533 | − organizationId: organization._id, | |
| 534 | − workosOrgId: event.data.id, | |
| 535 | − updatedAt: now, | |
| 536 | − }); | |
| 537 | − return; | |
| 538 | − } | |
| 539 | − | |
| 540 | − const organizationWorkspace = await getFirstWorkspaceByOrganizationId(ctx, organization._id); | |
| 541 | − if (organizationWorkspace) { | |
| 542 | − await ctx.db.patch(organizationWorkspace._id, { | |
| 543 | − workosOrgId: event.data.id, | |
| 544 | − updatedAt: now, | |
| 545 | − }); | |
| 546 | − return; | |
| 547 | − } | |
| 548 | − | |
| 549 | − await ctx.db.insert("workspaces", { | |
| 550 | − workosOrgId: event.data.id, | |
| 551 | − organizationId: organization._id, | |
| 552 | − slug: "default", | |
| 553 | − name: "Default Workspace", | |
| 554 | − createdByAccountId: organization.createdByAccountId, | |
| 555 | − createdAt: now, | |
| 556 | − updatedAt: now, | |
| 557 | − }); | |
| 558 | − }, | |
| 559 | − | |
| 560 | − "organization.updated": async (ctx, event) => { | |
| 561 | − const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | |
| 562 | − if (organization) { | |
| 563 | − await ctx.db.patch(organization._id, { | |
| 564 | − name: event.data.name, | |
| 565 | − updatedAt: Date.now(), | |
| 566 | − }); | |
| 567 | − } | |
| 568 | − }, | |
| 569 | − | |
| 570 | − "organization.deleted": async (ctx, event) => { | |
| 571 | − const organization = await getOrganizationByWorkosOrgId(ctx, event.data.id); | |
| 572 | − if (organization) { | |
| 573 | − await ctx.db.patch(organization._id, { | |
| 574 | − status: "deleted", | |
| 575 | − updatedAt: Date.now(), | |
| 576 | − }); | |
| 577 | − } | |
| 578 | − | |
| 579 | − const workspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id); | |
| 580 | − if (!workspace) return; | |
| 581 | − | |
| 582 | − const members = await ctx.db | |
| 583 | − .query("workspaceMembers") | |
| 584 | − .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id)) | |
| 585 | − .collect(); | |
| 586 | − for (const member of members) { | |
| 587 | − await ctx.db.delete(member._id); | |
| 588 | − } | |
| 589 | − | |
| 590 | − await ctx.db.delete(workspace._id); | |
| 591 | − }, | |
| 592 | − | |
| 593 | − "organization_membership.created": async (ctx, event) => { | |
| 594 | − const now = Date.now(); | |
| 595 | − const data = event.data as { | |
| 596 | − id: string; | |
| 597 | − user_id?: string; | |
| 598 | − userId?: string; | |
| 599 | − organization_id?: string; | |
| 600 | − organizationId?: string; | |
| 601 | − role?: { slug?: string }; | |
| 602 | − status?: string; | |
| 603 | − }; | |
| 604 | − const workosUserId = data.user_id ?? data.userId; | |
| 605 | − const workosOrgId = data.organization_id ?? data.organizationId; | |
| 606 | − if (!workosUserId || !workosOrgId) return; | |
| 607 | − | |
| 608 | − const [account, workspace] = await Promise.all([ | |
| 609 | − getAccountByWorkosId(ctx, workosUserId), | |
| 610 | − getWorkspaceByWorkosOrgId(ctx, workosOrgId), | |
| 611 | − ]); | |
| 612 | − if (!account || !workspace) return; | |
| 613 | − | |
| 614 | − const organizationId = workspace.organizationId; | |
| 615 | − | |
| 616 | − const existing = await ctx.db | |
| 617 | − .query("workspaceMembers") | |
| 618 | − .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) | |
| 619 | − .unique(); | |
| 620 | − | |
| 621 | − const workosRole = data.role?.slug ?? "member"; | |
| 622 | − const role = workosRole === "admin" ? "admin" : "member"; | |
| 623 | − const status = data.status === "active" ? "active" : "pending"; | |
| 624 | − | |
| 625 | − await upsertOrganizationMembership(ctx, { | |
| 626 | − organizationId, | |
| 627 | − accountId: account._id, | |
| 628 | − role, | |
| 629 | − status, | |
| 630 | − billable: status === "active", | |
| 631 | − now, | |
| 632 | − }); | |
| 633 | − | |
| 634 | − if (status === "active") { | |
| 635 | − await markPendingInvitesAcceptedByEmail(ctx, { | |
| 636 | − organizationId, | |
| 637 | − email: account.email, | |
| 638 | − acceptedAt: now, | |
| 639 | − }); | |
| 640 | − } | |
| 641 | − | |
| 642 | − if (existing) { | |
| 643 | − await ctx.db.patch(existing._id, { | |
| 644 | − workosOrgMembershipId: event.data.id, | |
| 645 | − role, | |
| 646 | − status, | |
| 647 | − updatedAt: now, | |
| 648 | − }); | |
| 649 | − return; | |
| 650 | − } | |
| 651 | − | |
| 652 | − await ctx.db.insert("workspaceMembers", { | |
| 653 | − workspaceId: workspace._id, | |
| 654 | − accountId: account._id, | |
| 655 | − workosOrgMembershipId: event.data.id, | |
| 656 | − role, | |
| 657 | − status, | |
| 658 | − createdAt: now, | |
| 659 | − updatedAt: now, | |
| 660 | − }); | |
| 661 | − }, | |
| 662 | − | |
| 663 | − "organization_membership.updated": async (ctx, event) => { | |
| 664 | − const now = Date.now(); | |
| 665 | − const data = event.data as { | |
| 666 | − id: string; | |
| 667 | − user_id?: string; | |
| 668 | − userId?: string; | |
| 669 | − organization_id?: string; | |
| 670 | − organizationId?: string; | |
| 671 | − role?: { slug?: string }; | |
| 672 | − status?: string; | |
| 673 | − }; | |
| 674 | − | |
| 675 | − let membership = await ctx.db | |
| 676 | − .query("workspaceMembers") | |
| 677 | − .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id)) | |
| 678 | − .unique(); | |
| 679 | − | |
| 680 | − let account: Doc<"accounts"> | null = null; | |
| 681 | − let workspace: Doc<"workspaces"> | null = null; | |
| 682 | − | |
| 683 | − if (!membership) { | |
| 684 | − const workosUserId = data.user_id ?? data.userId; | |
| 685 | − const workosOrgId = data.organization_id ?? data.organizationId; | |
| 686 | − if (!workosUserId || !workosOrgId) return; | |
| 687 | − [account, workspace] = await Promise.all([ | |
| 688 | − getAccountByWorkosId(ctx, workosUserId), | |
| 689 | − getWorkspaceByWorkosOrgId(ctx, workosOrgId), | |
| 690 | − ]); | |
| 691 | − if (!account || !workspace) return; | |
| 692 | − const workspaceId = workspace._id; | |
| 693 | − const accountId = account._id; | |
| 694 | − membership = await ctx.db | |
| 695 | − .query("workspaceMembers") | |
| 696 | − .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId)) | |
| 697 | − .unique(); | |
| 698 | − if (!membership) return; | |
| 699 | − } else { | |
| 700 | − account = await ctx.db.get(membership.accountId); | |
| 701 | − workspace = await ctx.db.get(membership.workspaceId); | |
| 702 | − } | |
| 703 | − | |
| 704 | − if (!account || !workspace) { | |
| 705 | − return; | |
| 706 | − } | |
| 707 | − | |
| 708 | − const organizationId = workspace.organizationId; | |
| 709 | − | |
| 710 | − const workosRole = data.role?.slug ?? "member"; | |
| 711 | − const status = data.status === "active" ? "active" : "pending"; | |
| 712 | − await upsertOrganizationMembership(ctx, { | |
| 713 | − organizationId, | |
| 714 | − accountId: account._id, | |
| 715 | − role: workosRole === "admin" ? "admin" : "member", | |
| 716 | − status, | |
| 717 | − billable: status === "active", | |
| 718 | − now, | |
| 719 | − }); | |
| 720 | − | |
| 721 | − if (status === "active") { | |
| 722 | − await markPendingInvitesAcceptedByEmail(ctx, { | |
| 723 | − organizationId, | |
| 724 | − email: account.email, | |
| 725 | − acceptedAt: now, | |
| 726 | − }); | |
| 727 | − } | |
| 728 | − | |
| 729 | − await ctx.db.patch(membership._id, { | |
| 730 | − workosOrgMembershipId: data.id, | |
| 731 | − role: workosRole === "admin" ? "admin" : "member", | |
| 732 | − status, | |
| 733 | − updatedAt: now, | |
| 734 | − }); | |
| 735 | − }, | |
| 736 | − | |
| 737 | − "organization_membership.deleted": async (ctx, event) => { | |
| 738 | − const now = Date.now(); | |
| 739 | − const membership = await ctx.db | |
| 740 | − .query("workspaceMembers") | |
| 741 | − .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id)) | |
| 742 | − .unique(); | |
| 743 | − if (!membership) return; | |
| 744 | − | |
| 745 | − const workspace = await ctx.db.get(membership.workspaceId); | |
| 746 | − if (workspace?.organizationId) { | |
| 747 | − await upsertOrganizationMembership(ctx, { | |
| 748 | − organizationId: workspace.organizationId, | |
| 749 | − accountId: membership.accountId, | |
| 750 | − role: membership.role, | |
| 751 | − status: "removed", | |
| 752 | − billable: false, | |
| 753 | − now, | |
| 754 | − }); | |
| 755 | − } | |
| 756 | − | |
| 757 | − await ctx.db.delete(membership._id); | |
| 758 | − }, | |
| 759 | −} satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>; | |
| 760 | − | |
| 761 | −const authKitEvents = workosEnabled && authKitInstance | |
| 762 | − ? authKitInstance.events(workosEventHandlers) | |
| 763 | − : null; | |
| 35 | +const authKitEvents = workosEnabled && authKitInstance ? authKitInstance.events(workosEventHandlers) : null; | |
| 764 | 36 | |
| 765 | 37 | export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ |
| 766 | 38 | args: {}, |
| @@ -769,166 +41,5 @@ export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ · 2 unmodified lines | ||
| 769 | 41 | |
| 770 | 42 | export const bootstrapCurrentWorkosAccount = mutation({ |
| 771 | 43 | args: {}, |
| 772 | − handler: async (ctx) => { | |
| 773 | − const identity = await ctx.auth.getUserIdentity(); | |
| 774 | − if (!identity) return null; | |
| 775 | − | |
| 776 | − const now = Date.now(); | |
| 777 | − const identityRecord = identity as Record<string, unknown>; | |
| 778 | − const subject = identity.subject; | |
| 779 | − const authKitProfile = await getAuthKitUserProfile(ctx, subject); | |
| 780 | − const email = | |
| 781 | − authKitProfile?.email | |
| 782 | − ?? | |
| 783 | − getIdentityString(identityRecord, [ | |
| 784 | − "email", | |
| 785 | − "https://workos.com/email", | |
| 786 | − "upn", | |
| 787 | − ]) ?? `${subject}@workos.executor.local`; | |
| 788 | − | |
| 789 | − const firstName = | |
| 790 | − authKitProfile?.firstName | |
| 791 | − ?? getIdentityString(identityRecord, [ | |
| 792 | − "given_name", | |
| 793 | − "first_name", | |
| 794 | − "https://workos.com/first_name", | |
| 795 | − ]); | |
| 796 | − const lastName = | |
| 797 | − authKitProfile?.lastName | |
| 798 | − ?? getIdentityString(identityRecord, [ | |
| 799 | − "family_name", | |
| 800 | − "last_name", | |
| 801 | − "https://workos.com/last_name", | |
| 802 | − ]); | |
| 803 | − const fullName = | |
| 804 | − (getIdentityString(identityRecord, [ | |
| 805 | − "name", | |
| 806 | − "https://workos.com/name", | |
| 807 | − ]) ?? [firstName, lastName].filter(Boolean).join(" ")) | |
| 808 | − || email; | |
| 809 | − const avatarUrl = | |
| 810 | − (authKitProfile?.profilePictureUrl ?? undefined) | |
| 811 | − ?? getIdentityString(identityRecord, [ | |
| 812 | − "picture", | |
| 813 | − "avatar_url", | |
| 814 | − "https://workos.com/profile_picture_url", | |
| 815 | − ]); | |
| 816 | − | |
| 817 | − let account = await ctx.db | |
| 818 | − .query("accounts") | |
| 819 | − .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", subject)) | |
| 820 | − .unique(); | |
| 821 | − | |
| 822 | − if (account) { | |
| 823 | − await ctx.db.patch(account._id, { | |
| 824 | − email, | |
| 825 | − name: fullName, | |
| 826 | − firstName, | |
| 827 | − lastName, | |
| 828 | − avatarUrl, | |
| 829 | − status: "active", | |
| 830 | − updatedAt: now, | |
| 831 | − lastLoginAt: now, | |
| 832 | − }); | |
| 833 | − account = await ctx.db.get(account._id); | |
| 834 | − } else { | |
| 835 | − const accountId = await ctx.db.insert("accounts", { | |
| 836 | − provider: "workos", | |
| 837 | − providerAccountId: subject, | |
| 838 | − email, | |
| 839 | − name: fullName, | |
| 840 | − firstName, | |
| 841 | − lastName, | |
| 842 | − avatarUrl, | |
| 843 | − status: "active", | |
| 844 | − createdAt: now, | |
| 845 | − updatedAt: now, | |
| 846 | − lastLoginAt: now, | |
| 847 | − }); | |
| 848 | − account = await ctx.db.get(accountId); | |
| 849 | − } | |
| 850 | − | |
| 851 | − if (!account) return null; | |
| 852 | − | |
| 853 | − await refreshGeneratedPersonalWorkspaceNames(ctx, account._id, { | |
| 854 | − email, | |
| 855 | − firstName, | |
| 856 | − fullName, | |
| 857 | − workosUserId: subject, | |
| 858 | − now, | |
| 859 | − }); | |
| 860 | − | |
| 861 | − const activeOrgMembership = await ctx.db | |
| 862 | − .query("organizationMembers") | |
| 863 | − .withIndex("by_account", (q) => q.eq("accountId", account._id)) | |
| 864 | − .filter((q) => q.eq(q.field("status"), "active")) | |
| 865 | − .first(); | |
| 866 | − | |
| 867 | − const hintedWorkosOrgId = getIdentityString(identityRecord, [ | |
| 868 | − "org_id", | |
| 869 | − "organization_id", | |
| 870 | − "https://workos.com/organization_id", | |
| 871 | − ]); | |
| 872 | − | |
| 873 | − if (!activeOrgMembership && hintedWorkosOrgId) { | |
| 874 | − const hintedOrganization = await getOrganizationByWorkosOrgId(ctx, hintedWorkosOrgId); | |
| 875 | − if (hintedOrganization) { | |
| 876 | − await upsertOrganizationMembership(ctx, { | |
| 877 | − organizationId: hintedOrganization._id, | |
| 878 | − accountId: account._id, | |
| 879 | − role: "member", | |
| 880 | − status: "active", | |
| 881 | − billable: true, | |
| 882 | − now, | |
| 883 | − }); | |
| 884 | − | |
| 885 | − await markPendingInvitesAcceptedByEmail(ctx, { | |
| 886 | − organizationId: hintedOrganization._id, | |
| 887 | − email, | |
| 888 | − acceptedAt: now, | |
| 889 | − }); | |
| 890 | − } | |
| 891 | − } | |
| 892 | − | |
| 893 | − const activeOrganizationMemberships = await ctx.db | |
| 894 | − .query("organizationMembers") | |
| 895 | − .withIndex("by_account", (q) => q.eq("accountId", account._id)) | |
| 896 | − .filter((q) => q.eq(q.field("status"), "active")) | |
| 897 | − .collect(); | |
| 898 | − | |
| 899 | − for (const membership of activeOrganizationMemberships) { | |
| 900 | − const orgWorkspaces = await ctx.db | |
| 901 | − .query("workspaces") | |
| 902 | − .withIndex("by_organization_created", (q) => q.eq("organizationId", membership.organizationId)) | |
| 903 | − .collect(); | |
| 904 | − | |
| 905 | − const workspaceRole = mapOrganizationRoleToWorkspaceRole(membership.role); | |
| 906 | − for (const workspace of orgWorkspaces) { | |
| 907 | − await ensureWorkspaceMembership(ctx, { | |
| 908 | − workspaceId: workspace._id, | |
| 909 | − accountId: account._id, | |
| 910 | − role: workspaceRole, | |
| 911 | − now, | |
| 912 | − }); | |
| 913 | − } | |
| 914 | − } | |
| 915 | − | |
| 916 | − const activeWorkspaceMembership = await ctx.db | |
| 917 | − .query("workspaceMembers") | |
| 918 | − .withIndex("by_account", (q) => q.eq("accountId", account._id)) | |
| 919 | − .filter((q) => q.eq(q.field("status"), "active")) | |
| 920 | − .first(); | |
| 921 | − | |
| 922 | − if (!activeWorkspaceMembership) { | |
| 923 | − await ensurePersonalWorkspace(ctx, account._id, { | |
| 924 | − email, | |
| 925 | − firstName, | |
| 926 | − fullName, | |
| 927 | − workosUserId: subject, | |
| 928 | − now, | |
| 929 | − }); | |
| 930 | − } | |
| 931 | − | |
| 932 | − return account; | |
| 933 | − }, | |
| 44 | + handler: bootstrapCurrentWorkosAccountImpl, | |
| 934 | 45 | }); |
| @@ -4,19 +4,13 @@ import { WorkOS } from "@workos-inc/node"; · 3 unmodified lines | ||
| 4 | 4 | import { v } from "convex/values"; |
| 5 | 5 | import { internal } from "./_generated/api"; |
| 6 | 6 | import { action } from "./_generated/server"; |
| 7 | +import { asRecord } from "./lib/object"; | |
| 7 | 8 | |
| 8 | 9 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 9 | 10 | const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault")); |
| 10 | 11 | |
| 11 | 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 | 14 | function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string { |
| 21 | 15 | if (scope !== "actor") return ""; |
| 22 | 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"; | |
| 2 | −import type { Doc } from "./_generated/dataModel"; | |
| 3 | −import { type MutationCtx, type QueryCtx } from "./_generated/server"; | |
| 4 | −import { internalMutation, internalQuery } from "./_generated/server"; | |
| 5 | −import { slugify } from "../core/src/identity"; | |
| 6 | −import { ensureUniqueSlug } from "../core/src/slug"; | |
| 7 | −import type { TaskStatus } from "../core/src/types"; | |
| 8 | − | |
| 9 | −const DEFAULT_TIMEOUT_MS = 300_000; | |
| 10 | −type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | |
| 11 | −type OrganizationMemberStatus = "active" | "pending" | "removed"; | |
| 12 | − | |
| 13 | −const completedTaskStatusValidator = v.union( | |
| 14 | − v.literal("completed"), | |
| 15 | − v.literal("failed"), | |
| 16 | − v.literal("timed_out"), | |
| 17 | − v.literal("denied"), | |
| 18 | −); | |
| 19 | −const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); | |
| 20 | −const terminalToolCallStatusValidator = v.union( | |
| 21 | − v.literal("completed"), | |
| 22 | − v.literal("failed"), | |
| 23 | − v.literal("denied"), | |
| 24 | −); | |
| 25 | −const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | |
| 26 | −const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | |
| 27 | −const credentialProviderValidator = v.union( | |
| 28 | − v.literal("local-convex"), | |
| 29 | − v.literal("workos-vault"), | |
| 30 | −); | |
| 31 | −const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | |
| 32 | − | |
| 33 | −async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> { | |
| 34 | − const baseSlug = slugify(baseName, "workspace"); | |
| 35 | − return await ensureUniqueSlug(baseSlug, async (candidate) => { | |
| 36 | − const collision = await ctx.db | |
| 37 | − .query("organizations") | |
| 38 | − .withIndex("by_slug", (q) => q.eq("slug", candidate)) | |
| 39 | − .unique(); | |
| 40 | − return collision !== null; | |
| 41 | − }); | |
| 42 | −} | |
| 43 | − | |
| 44 | −async function upsertOrganizationMembership( | |
| 45 | − ctx: Pick<MutationCtx, "db">, | |
| 46 | − args: { | |
| 47 | − organizationId: Doc<"organizations">["_id"]; | |
| 48 | − accountId: Doc<"accounts">["_id"]; | |
| 49 | − role: OrganizationRole; | |
| 50 | − status: OrganizationMemberStatus; | |
| 51 | − billable: boolean; | |
| 52 | − now: number; | |
| 53 | − }, | |
| 54 | −) { | |
| 55 | − const existing = await ctx.db | |
| 56 | − .query("organizationMembers") | |
| 57 | − .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId)) | |
| 58 | − .unique(); | |
| 59 | − | |
| 60 | − if (existing) { | |
| 61 | − await ctx.db.patch(existing._id, { | |
| 62 | − role: args.role, | |
| 63 | − status: args.status, | |
| 64 | − billable: args.billable, | |
| 65 | − joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt, | |
| 66 | − updatedAt: args.now, | |
| 67 | − }); | |
| 68 | − return; | |
| 69 | − } | |
| 70 | − | |
| 71 | − await ctx.db.insert("organizationMembers", { | |
| 72 | − organizationId: args.organizationId, | |
| 73 | − accountId: args.accountId, | |
| 74 | − role: args.role, | |
| 75 | − status: args.status, | |
| 76 | − billable: args.billable, | |
| 77 | − joinedAt: args.status === "active" ? args.now : undefined, | |
| 78 | − createdAt: args.now, | |
| 79 | − updatedAt: args.now, | |
| 80 | − }); | |
| 81 | −} | |
| 82 | − | |
| 83 | −// NOTE: Canonical version lives in convex/lib/utils.ts. | |
| 84 | −// Convex can't import from the server, so this is a local copy. | |
| 85 | −function asRecord(value: unknown): Record<string, unknown> { | |
| 86 | − if (value && typeof value === "object" && !Array.isArray(value)) { | |
| 87 | − return value as Record<string, unknown>; | |
| 88 | − } | |
| 89 | − return {}; | |
| 90 | −} | |
| 91 | − | |
| 92 | −function stableStringify(value: unknown): string { | |
| 93 | − if (Array.isArray(value)) { | |
| 94 | − return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; | |
| 95 | − } | |
| 96 | − if (value && typeof value === "object") { | |
| 97 | − const record = value as Record<string, unknown>; | |
| 98 | − const keys = Object.keys(record).sort(); | |
| 99 | − return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`; | |
| 100 | − } | |
| 101 | − return JSON.stringify(value); | |
| 102 | −} | |
| 103 | − | |
| 104 | −function normalizeSourceAuthFingerprint(value: unknown): string { | |
| 105 | − const auth = asRecord(value); | |
| 106 | − const type = typeof auth.type === "string" ? auth.type.trim() : "none"; | |
| 107 | − const mode = auth.mode === "actor" ? "actor" : "workspace"; | |
| 108 | − const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : ""; | |
| 109 | − return stableStringify({ | |
| 110 | − type: type || "none", | |
| 111 | − mode, | |
| 112 | − ...(header ? { header } : {}), | |
| 113 | − }); | |
| 114 | −} | |
| 115 | − | |
| 116 | −function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", config: Record<string, unknown>): string { | |
| 117 | − if (type === "openapi") { | |
| 118 | − const spec = config.spec; | |
| 119 | − if (typeof spec === "string") { | |
| 120 | − return `openapi:${spec.trim()}`; | |
| 121 | − } | |
| 122 | − return `openapi:${stableStringify(spec)}`; | |
| 123 | − } | |
| 124 | − if (type === "graphql") { | |
| 125 | − const endpoint = typeof config.endpoint === "string" ? config.endpoint.trim() : ""; | |
| 126 | − return `graphql:${endpoint}`; | |
| 127 | − } | |
| 128 | − const url = typeof config.url === "string" ? config.url.trim() : ""; | |
| 129 | − return `mcp:${url}`; | |
| 130 | −} | |
| 131 | − | |
| 132 | −function mapTask(doc: Doc<"tasks">) { | |
| 133 | − return { | |
| 134 | − id: doc.taskId, | |
| 135 | − code: doc.code, | |
| 136 | − runtimeId: doc.runtimeId, | |
| 137 | − status: doc.status, | |
| 138 | − timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TIMEOUT_MS, | |
| 139 | − metadata: asRecord(doc.metadata), | |
| 140 | − workspaceId: doc.workspaceId, | |
| 141 | − actorId: doc.actorId, | |
| 142 | − clientId: doc.clientId, | |
| 143 | − createdAt: doc.createdAt, | |
| 144 | − updatedAt: doc.updatedAt, | |
| 145 | − startedAt: doc.startedAt, | |
| 146 | − completedAt: doc.completedAt, | |
| 147 | − error: doc.error, | |
| 148 | − result: doc.result, | |
| 149 | − exitCode: doc.exitCode, | |
| 150 | − }; | |
| 151 | −} | |
| 152 | − | |
| 153 | −function mapApproval(doc: Doc<"approvals">) { | |
| 154 | − return { | |
| 155 | − id: doc.approvalId, | |
| 156 | − taskId: doc.taskId, | |
| 157 | − toolPath: doc.toolPath, | |
| 158 | − input: doc.input, | |
| 159 | − status: doc.status, | |
| 160 | − reason: doc.reason, | |
| 161 | − reviewerId: doc.reviewerId, | |
| 162 | − createdAt: doc.createdAt, | |
| 163 | − resolvedAt: doc.resolvedAt, | |
| 164 | − }; | |
| 165 | −} | |
| 166 | − | |
| 167 | −function mapToolCall(doc: Doc<"toolCalls">) { | |
| 168 | − return { | |
| 169 | − taskId: doc.taskId, | |
| 170 | − callId: doc.callId, | |
| 171 | − workspaceId: doc.workspaceId, | |
| 172 | − toolPath: doc.toolPath, | |
| 173 | − status: doc.status, | |
| 174 | − approvalId: doc.approvalId, | |
| 175 | − error: doc.error, | |
| 176 | − createdAt: doc.createdAt, | |
| 177 | − updatedAt: doc.updatedAt, | |
| 178 | − completedAt: doc.completedAt, | |
| 179 | − }; | |
| 180 | −} | |
| 181 | − | |
| 182 | −function mapPolicy(doc: Doc<"accessPolicies">) { | |
| 183 | − return { | |
| 184 | − id: doc.policyId, | |
| 185 | − workspaceId: doc.workspaceId, | |
| 186 | − actorId: doc.actorId, | |
| 187 | − clientId: doc.clientId, | |
| 188 | − toolPathPattern: doc.toolPathPattern, | |
| 189 | − decision: doc.decision, | |
| 190 | − priority: doc.priority, | |
| 191 | − createdAt: doc.createdAt, | |
| 192 | − updatedAt: doc.updatedAt, | |
| 193 | − }; | |
| 194 | −} | |
| 195 | − | |
| 196 | −function mapCredential(doc: Doc<"sourceCredentials">) { | |
| 197 | − return { | |
| 198 | − id: doc.credentialId, | |
| 199 | − bindingId: doc.bindingId, | |
| 200 | − workspaceId: doc.workspaceId, | |
| 201 | − sourceKey: doc.sourceKey, | |
| 202 | − scope: doc.scope, | |
| 203 | − actorId: doc.actorId || undefined, | |
| 204 | − provider: doc.provider, | |
| 205 | − secretJson: asRecord(doc.secretJson), | |
| 206 | − overridesJson: asRecord(doc.overridesJson), | |
| 207 | − boundAuthFingerprint: doc.boundAuthFingerprint, | |
| 208 | − createdAt: doc.createdAt, | |
| 209 | − updatedAt: doc.updatedAt, | |
| 210 | − }; | |
| 211 | −} | |
| 212 | − | |
| 213 | −function mapSource(doc: Doc<"toolSources">) { | |
| 214 | − return { | |
| 215 | − id: doc.sourceId, | |
| 216 | − workspaceId: doc.workspaceId, | |
| 217 | − name: doc.name, | |
| 218 | − type: doc.type, | |
| 219 | − config: asRecord(doc.config), | |
| 220 | − specHash: doc.specHash, | |
| 221 | − authFingerprint: doc.authFingerprint, | |
| 222 | − enabled: Boolean(doc.enabled), | |
| 223 | − createdAt: doc.createdAt, | |
| 224 | − updatedAt: doc.updatedAt, | |
| 225 | − }; | |
| 226 | −} | |
| 227 | − | |
| 228 | −async function ensureAnonymousIdentity( | |
| 229 | − ctx: MutationCtx, | |
| 230 | − params: { | |
| 231 | − sessionId: string; | |
| 232 | − workspaceId?: Doc<"workspaces">["_id"]; | |
| 233 | − actorId: string; | |
| 234 | − timestamp: number; | |
| 235 | − }, | |
| 236 | −) { | |
| 237 | − const anonymousOrganizationName = "Anonymous Organization"; | |
| 238 | − const anonymousWorkspaceName = "Anonymous Workspace"; | |
| 239 | − const now = params.timestamp; | |
| 240 | − | |
| 241 | − let account = await ctx.db | |
| 242 | − .query("accounts") | |
| 243 | − .withIndex("by_provider", (q) => q.eq("provider", "anonymous").eq("providerAccountId", params.actorId)) | |
| 244 | − .unique(); | |
| 245 | − | |
| 246 | − if (!account) { | |
| 247 | − const accountId = await ctx.db.insert("accounts", { | |
| 248 | − provider: "anonymous", | |
| 249 | − providerAccountId: params.actorId, | |
| 250 | − email: `${params.actorId}@guest.executor.local`, | |
| 251 | − name: "Guest User", | |
| 252 | − status: "active", | |
| 253 | − createdAt: now, | |
| 254 | − updatedAt: now, | |
| 255 | − lastLoginAt: now, | |
| 256 | − }); | |
| 257 | − account = await ctx.db.get(accountId); | |
| 258 | − if (!account) { | |
| 259 | − throw new Error("Failed to create anonymous account"); | |
| 260 | − } | |
| 261 | − } else { | |
| 262 | − await ctx.db.patch(account._id, { updatedAt: now, lastLoginAt: now }); | |
| 263 | − } | |
| 264 | − | |
| 265 | − let workspace = params.workspaceId ? await ctx.db.get(params.workspaceId) : null; | |
| 266 | − | |
| 267 | − let organizationId: Doc<"organizations">["_id"]; | |
| 268 | − | |
| 269 | − if (!workspace) { | |
| 270 | − const organizationSlug = await ensureUniqueOrganizationSlug(ctx, anonymousOrganizationName); | |
| 271 | − organizationId = await ctx.db.insert("organizations", { | |
| 272 | − slug: organizationSlug, | |
| 273 | − name: anonymousOrganizationName, | |
| 274 | − status: "active", | |
| 275 | − createdByAccountId: account._id, | |
| 276 | − createdAt: now, | |
| 277 | − updatedAt: now, | |
| 278 | − }); | |
| 279 | − | |
| 280 | − const workspaceId = await ctx.db.insert("workspaces", { | |
| 281 | − organizationId, | |
| 282 | − slug: `anonymous-${crypto.randomUUID().slice(0, 8)}`, | |
| 283 | − name: anonymousWorkspaceName, | |
| 284 | − createdByAccountId: account._id, | |
| 285 | − createdAt: now, | |
| 286 | − updatedAt: now, | |
| 287 | − }); | |
| 288 | − workspace = await ctx.db.get(workspaceId); | |
| 289 | − if (!workspace) { | |
| 290 | − throw new Error("Failed to create anonymous workspace"); | |
| 291 | − } | |
| 292 | − } else { | |
| 293 | − organizationId = workspace.organizationId; | |
| 294 | − } | |
| 295 | − | |
| 296 | − await upsertOrganizationMembership(ctx, { | |
| 297 | − organizationId, | |
| 298 | − accountId: account._id, | |
| 299 | − role: "owner", | |
| 300 | − status: "active", | |
| 301 | − billable: true, | |
| 302 | − now, | |
| 303 | − }); | |
| 304 | − | |
| 305 | − let user = await ctx.db | |
| 306 | − .query("workspaceMembers") | |
| 307 | − .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) | |
| 308 | − .unique(); | |
| 309 | − | |
| 310 | − if (!user) { | |
| 311 | − const userId = await ctx.db.insert("workspaceMembers", { | |
| 312 | − workspaceId: workspace._id, | |
| 313 | − accountId: account._id, | |
| 314 | − role: "owner", | |
| 315 | − status: "active", | |
| 316 | − createdAt: now, | |
| 317 | − updatedAt: now, | |
| 318 | − }); | |
| 319 | − user = await ctx.db.get(userId); | |
| 320 | − if (!user) { | |
| 321 | − throw new Error("Failed to create anonymous user membership"); | |
| 322 | − } | |
| 323 | − } else { | |
| 324 | − await ctx.db.patch(user._id, { updatedAt: now }); | |
| 325 | − } | |
| 326 | − | |
| 327 | − return { | |
| 328 | − accountId: account._id, | |
| 329 | − workspaceId: workspace._id, | |
| 330 | − userId: user._id, | |
| 331 | − }; | |
| 332 | −} | |
| 333 | − | |
| 334 | −function mapAnonymousContext(doc: Doc<"anonymousSessions">) { | |
| 335 | − return { | |
| 336 | − sessionId: doc.sessionId, | |
| 337 | − workspaceId: doc.workspaceId, | |
| 338 | − actorId: doc.actorId, | |
| 339 | − clientId: doc.clientId, | |
| 340 | − accountId: doc.accountId, | |
| 341 | − userId: doc.userId, | |
| 342 | − createdAt: doc.createdAt, | |
| 343 | − lastSeenAt: doc.lastSeenAt, | |
| 344 | − }; | |
| 345 | −} | |
| 346 | − | |
| 347 | −function mapTaskEvent(doc: Doc<"taskEvents">) { | |
| 348 | − return { | |
| 349 | − id: doc.sequence, | |
| 350 | − taskId: doc.taskId, | |
| 351 | − eventName: doc.eventName, | |
| 352 | − type: doc.type, | |
| 353 | − payload: doc.payload, | |
| 354 | − createdAt: doc.createdAt, | |
| 355 | − }; | |
| 356 | −} | |
| 357 | − | |
| 358 | −async function getTaskDoc(ctx: { db: QueryCtx["db"] }, taskId: string) { | |
| 359 | − return await ctx.db.query("tasks").withIndex("by_task_id", (q) => q.eq("taskId", taskId)).unique(); | |
| 360 | −} | |
| 361 | − | |
| 362 | −async function getApprovalDoc(ctx: { db: QueryCtx["db"] }, approvalId: string) { | |
| 363 | − return await ctx.db | |
| 364 | − .query("approvals") | |
| 365 | − .withIndex("by_approval_id", (q) => q.eq("approvalId", approvalId)) | |
| 366 | − .unique(); | |
| 367 | −} | |
| 368 | − | |
| 369 | −async function getToolCallDoc( | |
| 370 | − ctx: { db: QueryCtx["db"] }, | |
| 371 | − taskId: string, | |
| 372 | − callId: string, | |
| 373 | −) { | |
| 374 | − return await ctx.db | |
| 375 | − .query("toolCalls") | |
| 376 | − .withIndex("by_task_call", (q) => q.eq("taskId", taskId).eq("callId", callId)) | |
| 377 | − .unique(); | |
| 378 | −} | |
| 379 | − | |
| 380 | −export const createTask = internalMutation({ | |
| 381 | − args: { | |
| 382 | − id: v.string(), | |
| 383 | − code: v.string(), | |
| 384 | − runtimeId: v.string(), | |
| 385 | − timeoutMs: v.optional(v.number()), | |
| 386 | − metadata: v.optional(v.any()), | |
| 387 | − workspaceId: v.id("workspaces"), | |
| 388 | − actorId: v.string(), | |
| 389 | − clientId: v.optional(v.string()), | |
| 390 | − }, | |
| 391 | − handler: async (ctx, args) => { | |
| 392 | − const existing = await getTaskDoc(ctx, args.id); | |
| 393 | − if (existing) { | |
| 394 | − throw new Error(`Task already exists: ${args.id}`); | |
| 395 | − } | |
| 396 | − | |
| 397 | − const now = Date.now(); | |
| 398 | − await ctx.db.insert("tasks", { | |
| 399 | − taskId: args.id, | |
| 400 | − code: args.code, | |
| 401 | − runtimeId: args.runtimeId, | |
| 402 | − workspaceId: args.workspaceId, | |
| 403 | − actorId: args.actorId?.trim() || undefined, | |
| 404 | − clientId: args.clientId?.trim() || undefined, | |
| 405 | − status: "queued", | |
| 406 | − timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS, | |
| 407 | − metadata: asRecord(args.metadata), | |
| 408 | − createdAt: now, | |
| 409 | − updatedAt: now, | |
| 410 | − }); | |
| 411 | − | |
| 412 | − const created = await getTaskDoc(ctx, args.id); | |
| 413 | − if (!created) { | |
| 414 | − throw new Error(`Failed to fetch created task ${args.id}`); | |
| 415 | − } | |
| 416 | − return mapTask(created); | |
| 417 | − }, | |
| 418 | −}); | |
| 419 | − | |
| 420 | −export const getTask = internalQuery({ | |
| 421 | − args: { taskId: v.string() }, | |
| 422 | − handler: async (ctx, args) => { | |
| 423 | − const doc = await getTaskDoc(ctx, args.taskId); | |
| 424 | − return doc ? mapTask(doc) : null; | |
| 425 | − }, | |
| 426 | −}); | |
| 427 | − | |
| 428 | −export const listTasks = internalQuery({ | |
| 429 | − args: { workspaceId: v.id("workspaces") }, | |
| 430 | − handler: async (ctx, args) => { | |
| 431 | − const docs = await ctx.db | |
| 432 | − .query("tasks") | |
| 433 | − .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 434 | − .order("desc") | |
| 435 | − .take(500); | |
| 436 | − return docs.map(mapTask); | |
| 437 | − }, | |
| 438 | −}); | |
| 439 | − | |
| 440 | −export const listQueuedTaskIds = internalQuery({ | |
| 441 | − args: { limit: v.optional(v.number()) }, | |
| 442 | − handler: async (ctx, args) => { | |
| 443 | − const docs = await ctx.db | |
| 444 | − .query("tasks") | |
| 445 | − .withIndex("by_status_created", (q) => q.eq("status", "queued")) | |
| 446 | − .order("asc") | |
| 447 | − .take(args.limit ?? 20); | |
| 448 | − | |
| 449 | − return docs.map((doc) => doc.taskId); | |
| 450 | − }, | |
| 451 | −}); | |
| 452 | − | |
| 453 | −export const listRuntimeTargets = internalQuery({ | |
| 454 | − args: {}, | |
| 455 | − handler: async () => { | |
| 456 | − return [ | |
| 457 | − { | |
| 458 | − id: "local-bun", | |
| 459 | − label: "Local JS Runtime", | |
| 460 | − description: "Runs generated code in-process using Bun", | |
| 461 | − }, | |
| 462 | − { | |
| 463 | − id: "cloudflare-worker-loader", | |
| 464 | − label: "Cloudflare Worker Loader", | |
| 465 | − description: "Runs generated code in a Cloudflare Worker", | |
| 466 | − } | |
| 467 | − ]; | |
| 468 | − }, | |
| 469 | −}); | |
| 470 | − | |
| 471 | −export const getTaskInWorkspace = internalQuery({ | |
| 472 | − args: { taskId: v.string(), workspaceId: v.id("workspaces") }, | |
| 473 | − handler: async (ctx, args) => { | |
| 474 | − const doc = await getTaskDoc(ctx, args.taskId); | |
| 475 | − if (!doc || doc.workspaceId !== args.workspaceId) { | |
| 476 | − return null; | |
| 477 | − } | |
| 478 | − return mapTask(doc); | |
| 479 | − }, | |
| 480 | −}); | |
| 481 | − | |
| 482 | −export const markTaskRunning = internalMutation({ | |
| 483 | − args: { taskId: v.string() }, | |
| 484 | − handler: async (ctx, args) => { | |
| 485 | − const doc = await getTaskDoc(ctx, args.taskId); | |
| 486 | − if (!doc || doc.status !== "queued") { | |
| 487 | − return null; | |
| 488 | − } | |
| 489 | − | |
| 490 | − const now = Date.now(); | |
| 491 | − await ctx.db.patch(doc._id, { | |
| 492 | − status: "running", | |
| 493 | − startedAt: doc.startedAt ?? now, | |
| 494 | − updatedAt: now, | |
| 495 | − }); | |
| 496 | − | |
| 497 | − const updated = await getTaskDoc(ctx, args.taskId); | |
| 498 | − return updated ? mapTask(updated) : null; | |
| 499 | − }, | |
| 500 | −}); | |
| 501 | − | |
| 502 | −export const markTaskFinished = internalMutation({ | |
| 503 | − args: { | |
| 504 | − taskId: v.string(), | |
| 505 | − status: completedTaskStatusValidator, | |
| 506 | − result: v.optional(v.any()), | |
| 507 | − exitCode: v.optional(v.number()), | |
| 508 | − error: v.optional(v.string()), | |
| 509 | − }, | |
| 510 | − handler: async (ctx, args) => { | |
| 511 | − const doc = await getTaskDoc(ctx, args.taskId); | |
| 512 | − if (!doc) { | |
| 513 | − return null; | |
| 514 | − } | |
| 515 | − | |
| 516 | − if (doc.status === "completed" || doc.status === "failed" || doc.status === "timed_out" || doc.status === "denied") { | |
| 517 | − return mapTask(doc); | |
| 518 | − } | |
| 519 | − | |
| 520 | − const now = Date.now(); | |
| 521 | − await ctx.db.patch(doc._id, { | |
| 522 | − status: args.status, | |
| 523 | − result: args.result, | |
| 524 | − exitCode: args.exitCode, | |
| 525 | − error: args.error, | |
| 526 | − completedAt: now, | |
| 527 | − updatedAt: now, | |
| 528 | − }); | |
| 529 | − | |
| 530 | − const updated = await getTaskDoc(ctx, args.taskId); | |
| 531 | − return updated ? mapTask(updated) : null; | |
| 532 | − }, | |
| 533 | −}); | |
| 534 | − | |
| 535 | −export const createApproval = internalMutation({ | |
| 536 | − args: { | |
| 537 | − id: v.string(), | |
| 538 | − taskId: v.string(), | |
| 539 | − toolPath: v.string(), | |
| 540 | − input: v.optional(v.any()), | |
| 541 | − }, | |
| 542 | − handler: async (ctx, args) => { | |
| 543 | − const existing = await getApprovalDoc(ctx, args.id); | |
| 544 | − if (existing) { | |
| 545 | − throw new Error(`Approval already exists: ${args.id}`); | |
| 546 | − } | |
| 547 | − | |
| 548 | − const task = await getTaskDoc(ctx, args.taskId); | |
| 549 | − if (!task) { | |
| 550 | − throw new Error(`Task not found for approval: ${args.taskId}`); | |
| 551 | − } | |
| 552 | − | |
| 553 | − const now = Date.now(); | |
| 554 | − await ctx.db.insert("approvals", { | |
| 555 | − approvalId: args.id, | |
| 556 | − taskId: args.taskId, | |
| 557 | − workspaceId: task.workspaceId, | |
| 558 | − toolPath: args.toolPath, | |
| 559 | − input: args.input ?? {}, | |
| 560 | − status: "pending", | |
| 561 | − createdAt: now, | |
| 562 | − }); | |
| 563 | − | |
| 564 | − const created = await getApprovalDoc(ctx, args.id); | |
| 565 | − if (!created) { | |
| 566 | − throw new Error(`Failed to fetch approval ${args.id}`); | |
| 567 | − } | |
| 568 | − return mapApproval(created); | |
| 569 | − }, | |
| 570 | −}); | |
| 571 | − | |
| 572 | −export const getApproval = internalQuery({ | |
| 573 | − args: { approvalId: v.string() }, | |
| 574 | − handler: async (ctx, args) => { | |
| 575 | − const doc = await getApprovalDoc(ctx, args.approvalId); | |
| 576 | − return doc ? mapApproval(doc) : null; | |
| 577 | − }, | |
| 578 | −}); | |
| 579 | − | |
| 580 | −export const listApprovals = internalQuery({ | |
| 581 | − args: { | |
| 582 | − workspaceId: v.id("workspaces"), | |
| 583 | − status: v.optional(approvalStatusValidator), | |
| 584 | − }, | |
| 585 | − handler: async (ctx, args) => { | |
| 586 | − if (args.status) { | |
| 587 | − const status = args.status; | |
| 588 | − const docs = await ctx.db | |
| 589 | − .query("approvals") | |
| 590 | − .withIndex("by_workspace_status_created", (q) => | |
| 591 | − q.eq("workspaceId", args.workspaceId).eq("status", status), | |
| 592 | − ) | |
| 593 | − .order("desc") | |
| 594 | − .collect(); | |
| 595 | − return docs.map(mapApproval); | |
| 596 | − } | |
| 597 | − | |
| 598 | − const docs = await ctx.db | |
| 599 | − .query("approvals") | |
| 600 | − .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 601 | − .order("desc") | |
| 602 | − .take(500); | |
| 603 | − return docs.map(mapApproval); | |
| 604 | − }, | |
| 605 | −}); | |
| 606 | − | |
| 607 | −export const listPendingApprovals = internalQuery({ | |
| 608 | − args: { workspaceId: v.id("workspaces") }, | |
| 609 | − handler: async (ctx, args) => { | |
| 610 | − const docs = await ctx.db | |
| 611 | − .query("approvals") | |
| 612 | − .withIndex("by_workspace_status_created", (q) => | |
| 613 | − q.eq("workspaceId", args.workspaceId).eq("status", "pending"), | |
| 614 | − ) | |
| 615 | − .order("asc") | |
| 616 | − .collect(); | |
| 617 | − | |
| 618 | − const tasks = await Promise.all(docs.map((approval) => getTaskDoc(ctx, approval.taskId))); | |
| 619 | − | |
| 620 | − const results: Array< | |
| 621 | − ReturnType<typeof mapApproval> & { | |
| 622 | − task: { id: string; status: TaskStatus; runtimeId: string; timeoutMs: number; createdAt: number }; | |
| 623 | − } | |
| 624 | − > = []; | |
| 625 | − for (let i = 0; i < docs.length; i++) { | |
| 626 | − const approval = docs[i]!; | |
| 627 | − const task = tasks[i]; | |
| 628 | − if (!task) { | |
| 629 | − continue; | |
| 630 | − } | |
| 631 | − | |
| 632 | − results.push({ | |
| 633 | − ...mapApproval(approval), | |
| 634 | − task: { | |
| 635 | − id: task.taskId, | |
| 636 | − status: task.status, | |
| 637 | − runtimeId: task.runtimeId, | |
| 638 | − timeoutMs: task.timeoutMs, | |
| 639 | − createdAt: task.createdAt, | |
| 640 | − }, | |
| 641 | − }); | |
| 642 | − } | |
| 643 | − | |
| 644 | − return results; | |
| 645 | − }, | |
| 646 | −}); | |
| 647 | − | |
| 648 | −export const resolveApproval = internalMutation({ | |
| 649 | − args: { | |
| 650 | − approvalId: v.string(), | |
| 651 | − decision: v.union(v.literal("approved"), v.literal("denied")), | |
| 652 | − reviewerId: v.optional(v.string()), | |
| 653 | − reason: v.optional(v.string()), | |
| 654 | − }, | |
| 655 | − handler: async (ctx, args) => { | |
| 656 | − const doc = await getApprovalDoc(ctx, args.approvalId); | |
| 657 | − if (!doc || doc.status !== "pending") { | |
| 658 | − return null; | |
| 659 | − } | |
| 660 | − | |
| 661 | − const now = Date.now(); | |
| 662 | − await ctx.db.patch(doc._id, { | |
| 663 | − status: args.decision, | |
| 664 | − reason: args.reason, | |
| 665 | − reviewerId: args.reviewerId, | |
| 666 | − resolvedAt: now, | |
| 667 | − }); | |
| 668 | − | |
| 669 | − const updated = await getApprovalDoc(ctx, args.approvalId); | |
| 670 | − return updated ? mapApproval(updated) : null; | |
| 671 | − }, | |
| 672 | −}); | |
| 673 | − | |
| 674 | −export const getApprovalInWorkspace = internalQuery({ | |
| 675 | − args: { approvalId: v.string(), workspaceId: v.id("workspaces") }, | |
| 676 | − handler: async (ctx, args) => { | |
| 677 | − const doc = await getApprovalDoc(ctx, args.approvalId); | |
| 678 | − if (!doc || doc.workspaceId !== args.workspaceId) { | |
| 679 | − return null; | |
| 680 | − } | |
| 681 | − return mapApproval(doc); | |
| 682 | − }, | |
| 683 | −}); | |
| 684 | − | |
| 685 | −export const upsertToolCallRequested = internalMutation({ | |
| 686 | − args: { | |
| 687 | − taskId: v.string(), | |
| 688 | − callId: v.string(), | |
| 689 | − workspaceId: v.id("workspaces"), | |
| 690 | − toolPath: v.string(), | |
| 691 | − }, | |
| 692 | − handler: async (ctx, args) => { | |
| 693 | − const existing = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 694 | − if (existing) { | |
| 695 | − return mapToolCall(existing); | |
| 696 | − } | |
| 697 | − | |
| 698 | − const now = Date.now(); | |
| 699 | − await ctx.db.insert("toolCalls", { | |
| 700 | − taskId: args.taskId, | |
| 701 | − callId: args.callId, | |
| 702 | − workspaceId: args.workspaceId, | |
| 703 | − toolPath: args.toolPath, | |
| 704 | − status: "requested", | |
| 705 | − createdAt: now, | |
| 706 | − updatedAt: now, | |
| 707 | − }); | |
| 708 | − | |
| 709 | − const created = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 710 | − if (!created) { | |
| 711 | − throw new Error(`Failed to create tool call ${args.taskId}/${args.callId}`); | |
| 712 | − } | |
| 713 | − return mapToolCall(created); | |
| 714 | − }, | |
| 715 | −}); | |
| 716 | − | |
| 717 | −export const getToolCall = internalQuery({ | |
| 718 | − args: { | |
| 719 | − taskId: v.string(), | |
| 720 | − callId: v.string(), | |
| 721 | − }, | |
| 722 | − handler: async (ctx, args) => { | |
| 723 | − const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 724 | − return doc ? mapToolCall(doc) : null; | |
| 725 | − }, | |
| 726 | −}); | |
| 727 | − | |
| 728 | −export const setToolCallPendingApproval = internalMutation({ | |
| 729 | − args: { | |
| 730 | − taskId: v.string(), | |
| 731 | − callId: v.string(), | |
| 732 | − approvalId: v.string(), | |
| 733 | − }, | |
| 734 | − handler: async (ctx, args) => { | |
| 735 | − const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 736 | − if (!doc) { | |
| 737 | − throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); | |
| 738 | − } | |
| 739 | − | |
| 740 | − if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { | |
| 741 | − return mapToolCall(doc); | |
| 742 | − } | |
| 743 | − | |
| 744 | − const now = Date.now(); | |
| 745 | − await ctx.db.patch(doc._id, { | |
| 746 | − status: "pending_approval", | |
| 747 | − approvalId: args.approvalId, | |
| 748 | − updatedAt: now, | |
| 749 | − }); | |
| 750 | − | |
| 751 | − const updated = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 752 | − if (!updated) { | |
| 753 | − throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); | |
| 754 | − } | |
| 755 | − return mapToolCall(updated); | |
| 756 | − }, | |
| 757 | −}); | |
| 758 | − | |
| 759 | −export const finishToolCall = internalMutation({ | |
| 760 | − args: { | |
| 761 | − taskId: v.string(), | |
| 762 | − callId: v.string(), | |
| 763 | − status: terminalToolCallStatusValidator, | |
| 764 | − error: v.optional(v.string()), | |
| 765 | − }, | |
| 766 | − handler: async (ctx, args) => { | |
| 767 | − const doc = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 768 | − if (!doc) { | |
| 769 | − throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); | |
| 770 | − } | |
| 771 | − | |
| 772 | − if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { | |
| 773 | − return mapToolCall(doc); | |
| 774 | − } | |
| 775 | − | |
| 776 | − const now = Date.now(); | |
| 777 | − await ctx.db.patch(doc._id, { | |
| 778 | − status: args.status, | |
| 779 | − error: args.error, | |
| 780 | − updatedAt: now, | |
| 781 | − completedAt: now, | |
| 782 | − }); | |
| 783 | − | |
| 784 | − const updated = await getToolCallDoc(ctx, args.taskId, args.callId); | |
| 785 | − if (!updated) { | |
| 786 | − throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); | |
| 787 | − } | |
| 788 | − return mapToolCall(updated); | |
| 789 | − }, | |
| 790 | −}); | |
| 791 | − | |
| 792 | −export const listToolCalls = internalQuery({ | |
| 793 | − args: { | |
| 794 | − taskId: v.string(), | |
| 795 | − }, | |
| 796 | − handler: async (ctx, args) => { | |
| 797 | − const docs = await ctx.db | |
| 798 | − .query("toolCalls") | |
| 799 | − .withIndex("by_task_created", (q) => q.eq("taskId", args.taskId)) | |
| 800 | − .order("asc") | |
| 801 | − .collect(); | |
| 802 | − | |
| 803 | − return docs.map(mapToolCall); | |
| 804 | − }, | |
| 805 | −}); | |
| 806 | − | |
| 807 | −export const bootstrapAnonymousSession = internalMutation({ | |
| 808 | − args: { sessionId: v.optional(v.string()) }, | |
| 809 | − handler: async (ctx, args) => { | |
| 810 | − const now = Date.now(); | |
| 811 | − const requestedSessionId = args.sessionId?.trim() || ""; | |
| 812 | − const allowRequestedSessionId = requestedSessionId?.startsWith("mcp_") ?? false; | |
| 813 | − | |
| 814 | − if (requestedSessionId) { | |
| 815 | − const sessionId = requestedSessionId; | |
| 816 | − const existing = await ctx.db | |
| 817 | − .query("anonymousSessions") | |
| 818 | − .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | |
| 819 | − .unique(); | |
| 820 | − if (existing) { | |
| 821 | − const identity = await ensureAnonymousIdentity(ctx, { | |
| 822 | − sessionId, | |
| 823 | − workspaceId: existing.workspaceId, | |
| 824 | − actorId: existing.actorId, | |
| 825 | − timestamp: now, | |
| 826 | − }); | |
| 827 | − | |
| 828 | − await ctx.db.patch(existing._id, { | |
| 829 | − workspaceId: identity.workspaceId, | |
| 830 | − accountId: identity.accountId, | |
| 831 | − userId: identity.userId, | |
| 832 | − lastSeenAt: now, | |
| 833 | − }); | |
| 834 | − | |
| 835 | − const refreshed = await ctx.db | |
| 836 | − .query("anonymousSessions") | |
| 837 | − .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | |
| 838 | − .unique(); | |
| 839 | − if (!refreshed) { | |
| 840 | − throw new Error("Failed to refresh anonymous session"); | |
| 841 | − } | |
| 842 | − return mapAnonymousContext(refreshed); | |
| 843 | − } | |
| 844 | − } | |
| 845 | − | |
| 846 | − const generatedSessionId = allowRequestedSessionId | |
| 847 | − ? `mcp_${crypto.randomUUID()}` | |
| 848 | − : `anon_session_${crypto.randomUUID()}`; | |
| 849 | − const sessionId = allowRequestedSessionId | |
| 850 | − ? requestedSessionId as string | |
| 851 | − : generatedSessionId; | |
| 852 | − const actorId = `anon_${crypto.randomUUID()}`; | |
| 853 | − const clientId = "web"; | |
| 854 | − | |
| 855 | − const identity = await ensureAnonymousIdentity(ctx, { | |
| 856 | − sessionId, | |
| 857 | − actorId, | |
| 858 | − timestamp: now, | |
| 859 | − }); | |
| 860 | − | |
| 861 | − await ctx.db.insert("anonymousSessions", { | |
| 862 | − sessionId, | |
| 863 | − workspaceId: identity.workspaceId, | |
| 864 | − actorId, | |
| 865 | − clientId, | |
| 866 | − accountId: identity.accountId, | |
| 867 | − userId: identity.userId, | |
| 868 | − createdAt: now, | |
| 869 | − lastSeenAt: now, | |
| 870 | − }); | |
| 871 | − | |
| 872 | − const created = await ctx.db | |
| 873 | − .query("anonymousSessions") | |
| 874 | − .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | |
| 875 | − .unique(); | |
| 876 | − if (!created) { | |
| 877 | − throw new Error("Failed to create anonymous session"); | |
| 878 | − } | |
| 879 | − | |
| 880 | − return mapAnonymousContext(created); | |
| 881 | − }, | |
| 882 | −}); | |
| 883 | − | |
| 884 | −export const upsertAccessPolicy = internalMutation({ | |
| 885 | − args: { | |
| 886 | − id: v.optional(v.string()), | |
| 887 | − workspaceId: v.id("workspaces"), | |
| 888 | − actorId: v.optional(v.string()), | |
| 889 | − clientId: v.optional(v.string()), | |
| 890 | − toolPathPattern: v.string(), | |
| 891 | − decision: policyDecisionValidator, | |
| 892 | − priority: v.optional(v.number()), | |
| 893 | − }, | |
| 894 | − handler: async (ctx, args) => { | |
| 895 | − const now = Date.now(); | |
| 896 | − const policyId = args.id ?? `policy_${crypto.randomUUID()}`; | |
| 897 | − const existing = await ctx.db | |
| 898 | − .query("accessPolicies") | |
| 899 | − .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) | |
| 900 | − .unique(); | |
| 901 | − | |
| 902 | − if (existing) { | |
| 903 | − await ctx.db.patch(existing._id, { | |
| 904 | − workspaceId: args.workspaceId, | |
| 905 | − actorId: args.actorId?.trim() || undefined, | |
| 906 | − clientId: args.clientId?.trim() || undefined, | |
| 907 | − toolPathPattern: args.toolPathPattern, | |
| 908 | − decision: args.decision, | |
| 909 | − priority: args.priority ?? 100, | |
| 910 | − updatedAt: now, | |
| 911 | − }); | |
| 912 | − } else { | |
| 913 | − await ctx.db.insert("accessPolicies", { | |
| 914 | − policyId, | |
| 915 | − workspaceId: args.workspaceId, | |
| 916 | − actorId: args.actorId?.trim() || undefined, | |
| 917 | − clientId: args.clientId?.trim() || undefined, | |
| 918 | − toolPathPattern: args.toolPathPattern, | |
| 919 | − decision: args.decision, | |
| 920 | − priority: args.priority ?? 100, | |
| 921 | − createdAt: now, | |
| 922 | − updatedAt: now, | |
| 923 | − }); | |
| 924 | − } | |
| 925 | − | |
| 926 | − const updated = await ctx.db | |
| 927 | − .query("accessPolicies") | |
| 928 | − .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) | |
| 929 | − .unique(); | |
| 930 | − if (!updated) { | |
| 931 | − throw new Error(`Failed to read policy ${policyId}`); | |
| 932 | − } | |
| 933 | − return mapPolicy(updated); | |
| 934 | − }, | |
| 935 | −}); | |
| 936 | − | |
| 937 | −export const listAccessPolicies = internalQuery({ | |
| 938 | − args: { workspaceId: v.id("workspaces") }, | |
| 939 | − handler: async (ctx, args) => { | |
| 940 | − const docs = await ctx.db | |
| 941 | − .query("accessPolicies") | |
| 942 | − .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 943 | − .collect(); | |
| 944 | − | |
| 945 | − return docs | |
| 946 | − .sort((a, b) => { | |
| 947 | − if (a.priority !== b.priority) { | |
| 948 | − return b.priority - a.priority; | |
| 949 | − } | |
| 950 | − return a.createdAt - b.createdAt; | |
| 951 | − }) | |
| 952 | − .map(mapPolicy); | |
| 953 | − }, | |
| 954 | −}); | |
| 955 | − | |
| 956 | −async function computeBoundAuthFingerprint( | |
| 957 | − ctx: Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">, | |
| 958 | − workspaceId: Doc<"workspaces">["_id"], | |
| 959 | − sourceKey: string, | |
| 960 | −): Promise<string> { | |
| 961 | − const prefix = "source:"; | |
| 962 | − if (!sourceKey.startsWith(prefix)) { | |
| 963 | − return normalizeSourceAuthFingerprint({ type: "none" }); | |
| 964 | − } | |
| 965 | − | |
| 966 | − const sourceId = sourceKey.slice(prefix.length).trim(); | |
| 967 | − if (!sourceId) { | |
| 968 | − return normalizeSourceAuthFingerprint({ type: "none" }); | |
| 969 | − } | |
| 970 | − | |
| 971 | − const source = await ctx.db | |
| 972 | − .query("toolSources") | |
| 973 | − .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | |
| 974 | − .unique(); | |
| 975 | − | |
| 976 | − if (!source || source.workspaceId !== workspaceId) { | |
| 977 | − return normalizeSourceAuthFingerprint({ type: "none" }); | |
| 978 | − } | |
| 979 | − | |
| 980 | − return normalizeSourceAuthFingerprint(asRecord(source.config).auth); | |
| 981 | −} | |
| 982 | − | |
| 983 | −export const upsertCredential = internalMutation({ | |
| 984 | − args: { | |
| 985 | − id: v.optional(v.string()), | |
| 986 | − workspaceId: v.id("workspaces"), | |
| 987 | − sourceKey: v.string(), | |
| 988 | − scope: credentialScopeValidator, | |
| 989 | − actorId: v.optional(v.string()), | |
| 990 | − provider: v.optional(credentialProviderValidator), | |
| 991 | − secretJson: v.any(), | |
| 992 | − overridesJson: v.optional(v.any()), | |
| 993 | − }, | |
| 994 | − handler: async (ctx, args) => { | |
| 995 | − const now = Date.now(); | |
| 996 | − const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : ""; | |
| 997 | − const submittedSecret = asRecord(args.secretJson); | |
| 998 | − const hasSubmittedSecret = Object.keys(submittedSecret).length > 0; | |
| 999 | − | |
| 1000 | − const existing = await ctx.db | |
| 1001 | − .query("sourceCredentials") | |
| 1002 | − .withIndex("by_workspace_source_scope_actor", (q) => | |
| 1003 | − q | |
| 1004 | − .eq("workspaceId", args.workspaceId) | |
| 1005 | − .eq("sourceKey", args.sourceKey) | |
| 1006 | − .eq("scope", args.scope) | |
| 1007 | − .eq("actorId", actorId), | |
| 1008 | − ) | |
| 1009 | − .unique(); | |
| 1010 | − | |
| 1011 | − let requestedId = args.id?.trim() || ""; | |
| 1012 | − if (requestedId.startsWith("bind_")) { | |
| 1013 | − const binding = await ctx.db | |
| 1014 | − .query("sourceCredentials") | |
| 1015 | − .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId)) | |
| 1016 | − .unique(); | |
| 1017 | − if (binding && binding.workspaceId === args.workspaceId) { | |
| 1018 | − requestedId = binding.credentialId; | |
| 1019 | − } | |
| 1020 | − } | |
| 1021 | − | |
| 1022 | − const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`; | |
| 1023 | − | |
| 1024 | − const linkedRows = await ctx.db | |
| 1025 | − .query("sourceCredentials") | |
| 1026 | − .withIndex("by_workspace_credential", (q) => | |
| 1027 | − q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId), | |
| 1028 | − ) | |
| 1029 | − .collect(); | |
| 1030 | − const exemplar = linkedRows[0] ?? existing ?? null; | |
| 1031 | − | |
| 1032 | − const provider = args.provider ?? exemplar?.provider ?? "local-convex"; | |
| 1033 | − const fallbackSecret = asRecord(exemplar?.secretJson); | |
| 1034 | − const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; | |
| 1035 | − if (Object.keys(finalSecret).length === 0) { | |
| 1036 | − throw new Error("Credential values are required"); | |
| 1037 | − } | |
| 1038 | − | |
| 1039 | − const overridesJson = args.overridesJson === undefined | |
| 1040 | − ? asRecord(existing?.overridesJson) | |
| 1041 | − : asRecord(args.overridesJson); | |
| 1042 | − | |
| 1043 | − const boundAuthFingerprint = await computeBoundAuthFingerprint(ctx, args.workspaceId, args.sourceKey); | |
| 1044 | − | |
| 1045 | − if (linkedRows.length > 0 && (hasSubmittedSecret || args.provider)) { | |
| 1046 | − await Promise.all(linkedRows.map(async (row) => { | |
| 1047 | − await ctx.db.patch(row._id, { | |
| 1048 | − provider, | |
| 1049 | − secretJson: finalSecret, | |
| 1050 | − updatedAt: now, | |
| 1051 | − }); | |
| 1052 | − })); | |
| 1053 | − } | |
| 1054 | − | |
| 1055 | − if (existing) { | |
| 1056 | − await ctx.db.patch(existing._id, { | |
| 1057 | − credentialId: connectionId, | |
| 1058 | − provider, | |
| 1059 | − secretJson: finalSecret, | |
| 1060 | − overridesJson, | |
| 1061 | − boundAuthFingerprint, | |
| 1062 | − updatedAt: now, | |
| 1063 | − }); | |
| 1064 | − } else { | |
| 1065 | − await ctx.db.insert("sourceCredentials", { | |
| 1066 | − bindingId: `bind_${crypto.randomUUID()}`, | |
| 1067 | − credentialId: connectionId, | |
| 1068 | − workspaceId: args.workspaceId, | |
| 1069 | − sourceKey: args.sourceKey, | |
| 1070 | − scope: args.scope, | |
| 1071 | − actorId, | |
| 1072 | − provider, | |
| 1073 | − secretJson: finalSecret, | |
| 1074 | − overridesJson, | |
| 1075 | − boundAuthFingerprint, | |
| 1076 | − createdAt: now, | |
| 1077 | − updatedAt: now, | |
| 1078 | − }); | |
| 1079 | − } | |
| 1080 | − | |
| 1081 | − const updated = await ctx.db | |
| 1082 | − .query("sourceCredentials") | |
| 1083 | − .withIndex("by_workspace_source_scope_actor", (q) => | |
| 1084 | − q | |
| 1085 | − .eq("workspaceId", args.workspaceId) | |
| 1086 | − .eq("sourceKey", args.sourceKey) | |
| 1087 | − .eq("scope", args.scope) | |
| 1088 | − .eq("actorId", actorId), | |
| 1089 | − ) | |
| 1090 | − .unique(); | |
| 1091 | − | |
| 1092 | − if (!updated) { | |
| 1093 | − throw new Error("Failed to read upserted credential"); | |
| 1094 | − } | |
| 1095 | − | |
| 1096 | − return mapCredential(updated); | |
| 1097 | − }, | |
| 1098 | −}); | |
| 1099 | − | |
| 1100 | −export const listCredentials = internalQuery({ | |
| 1101 | − args: { workspaceId: v.id("workspaces") }, | |
| 1102 | − handler: async (ctx, args) => { | |
| 1103 | − const docs = await ctx.db | |
| 1104 | − .query("sourceCredentials") | |
| 1105 | − .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 1106 | − .order("desc") | |
| 1107 | − .collect(); | |
| 1108 | − return docs.map(mapCredential); | |
| 1109 | − }, | |
| 1110 | −}); | |
| 1111 | − | |
| 1112 | −export const listCredentialProviders = internalQuery({ | |
| 1113 | − args: {}, | |
| 1114 | − handler: async () => { | |
| 1115 | − const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim()); | |
| 1116 | − return [ | |
| 1117 | − { | |
| 1118 | − id: workosEnabled ? "workos-vault" : "local-convex", | |
| 1119 | − label: workosEnabled ? "Encrypted" : "Local", | |
| 1120 | − description: workosEnabled | |
| 1121 | − ? "Secrets are stored in WorkOS Vault." | |
| 1122 | − : "Secrets are stored locally in Convex on this machine.", | |
| 1123 | − }, | |
| 1124 | − ] as const; | |
| 1125 | − }, | |
| 1126 | −}); | |
| 1127 | − | |
| 1128 | −export const resolveCredential = internalQuery({ | |
| 1129 | − args: { | |
| 1130 | − workspaceId: v.id("workspaces"), | |
| 1131 | − sourceKey: v.string(), | |
| 1132 | − scope: credentialScopeValidator, | |
| 1133 | − actorId: v.optional(v.string()), | |
| 1134 | − }, | |
| 1135 | − handler: async (ctx, args) => { | |
| 1136 | − if (args.scope === "actor") { | |
| 1137 | − const actorId = args.actorId?.trim() || ""; | |
| 1138 | − if (!actorId) { | |
| 1139 | − return null; | |
| 1140 | − } | |
| 1141 | − | |
| 1142 | − const actorDoc = await ctx.db | |
| 1143 | − .query("sourceCredentials") | |
| 1144 | − .withIndex("by_workspace_source_scope_actor", (q) => | |
| 1145 | − q | |
| 1146 | − .eq("workspaceId", args.workspaceId) | |
| 1147 | − .eq("sourceKey", args.sourceKey) | |
| 1148 | − .eq("scope", "actor") | |
| 1149 | − .eq("actorId", actorId), | |
| 1150 | − ) | |
| 1151 | − .unique(); | |
| 1152 | − | |
| 1153 | − return actorDoc ? mapCredential(actorDoc) : null; | |
| 1154 | − } | |
| 1155 | − | |
| 1156 | − const workspaceDoc = await ctx.db | |
| 1157 | − .query("sourceCredentials") | |
| 1158 | − .withIndex("by_workspace_source_scope_actor", (q) => | |
| 1159 | − q | |
| 1160 | − .eq("workspaceId", args.workspaceId) | |
| 1161 | − .eq("sourceKey", args.sourceKey) | |
| 1162 | − .eq("scope", "workspace") | |
| 1163 | − .eq("actorId", ""), | |
| 1164 | − ) | |
| 1165 | − .unique(); | |
| 1166 | − | |
| 1167 | − return workspaceDoc ? mapCredential(workspaceDoc) : null; | |
| 1168 | − }, | |
| 1169 | −}); | |
| 1170 | − | |
| 1171 | −export const upsertToolSource = internalMutation({ | |
| 1172 | − args: { | |
| 1173 | − id: v.optional(v.string()), | |
| 1174 | − workspaceId: v.id("workspaces"), | |
| 1175 | − name: v.string(), | |
| 1176 | − type: toolSourceTypeValidator, | |
| 1177 | − config: v.any(), | |
| 1178 | − enabled: v.optional(v.boolean()), | |
| 1179 | − }, | |
| 1180 | − handler: async (ctx, args) => { | |
| 1181 | − const now = Date.now(); | |
| 1182 | − const sourceId = args.id ?? `src_${crypto.randomUUID()}`; | |
| 1183 | − const config = asRecord(args.config); | |
| 1184 | − const specHash = computeSourceSpecHash(args.type, config); | |
| 1185 | − const authFingerprint = normalizeSourceAuthFingerprint(config.auth); | |
| 1186 | − const [existing, conflict] = await Promise.all([ | |
| 1187 | − ctx.db | |
| 1188 | − .query("toolSources") | |
| 1189 | − .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | |
| 1190 | − .unique(), | |
| 1191 | − ctx.db | |
| 1192 | − .query("toolSources") | |
| 1193 | − .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name)) | |
| 1194 | − .unique(), | |
| 1195 | − ]); | |
| 1196 | − | |
| 1197 | − if (conflict && conflict.sourceId !== sourceId) { | |
| 1198 | − throw new Error(`Tool source name '${args.name}' already exists in workspace ${args.workspaceId}`); | |
| 1199 | − } | |
| 1200 | − | |
| 1201 | − if (existing) { | |
| 1202 | − await ctx.db.patch(existing._id, { | |
| 1203 | − workspaceId: args.workspaceId, | |
| 1204 | − name: args.name, | |
| 1205 | − type: args.type, | |
| 1206 | − config, | |
| 1207 | − specHash, | |
| 1208 | − authFingerprint, | |
| 1209 | − enabled: args.enabled !== false, | |
| 1210 | − updatedAt: now, | |
| 1211 | − }); | |
| 1212 | − } else { | |
| 1213 | − await ctx.db.insert("toolSources", { | |
| 1214 | − sourceId, | |
| 1215 | − workspaceId: args.workspaceId, | |
| 1216 | − name: args.name, | |
| 1217 | − type: args.type, | |
| 1218 | − config, | |
| 1219 | − specHash, | |
| 1220 | − authFingerprint, | |
| 1221 | − enabled: args.enabled !== false, | |
| 1222 | − createdAt: now, | |
| 1223 | − updatedAt: now, | |
| 1224 | − }); | |
| 1225 | − } | |
| 1226 | − | |
| 1227 | − const updated = await ctx.db | |
| 1228 | − .query("toolSources") | |
| 1229 | − .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | |
| 1230 | − .unique(); | |
| 1231 | − if (!updated) { | |
| 1232 | − throw new Error(`Failed to read tool source ${sourceId}`); | |
| 1233 | − } | |
| 1234 | − return mapSource(updated); | |
| 1235 | − }, | |
| 1236 | −}); | |
| 1237 | − | |
| 1238 | −export const listToolSources = internalQuery({ | |
| 1239 | − args: { workspaceId: v.id("workspaces") }, | |
| 1240 | − handler: async (ctx, args) => { | |
| 1241 | − const docs = await ctx.db | |
| 1242 | − .query("toolSources") | |
| 1243 | − .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 1244 | − .order("desc") | |
| 1245 | − .collect(); | |
| 1246 | − return docs.map(mapSource); | |
| 1247 | − }, | |
| 1248 | −}); | |
| 1249 | − | |
| 1250 | −export const deleteToolSource = internalMutation({ | |
| 1251 | − args: { workspaceId: v.id("workspaces"), sourceId: v.string() }, | |
| 1252 | − handler: async (ctx, args) => { | |
| 1253 | − const doc = await ctx.db | |
| 1254 | − .query("toolSources") | |
| 1255 | − .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId)) | |
| 1256 | − .unique(); | |
| 1257 | − | |
| 1258 | − if (!doc || doc.workspaceId !== args.workspaceId) { | |
| 1259 | − return false; | |
| 1260 | − } | |
| 1261 | − | |
| 1262 | − const sourceKey = `source:${args.sourceId}`; | |
| 1263 | − const bindings = await ctx.db | |
| 1264 | − .query("sourceCredentials") | |
| 1265 | − .withIndex("by_workspace_source_scope_actor", (q) => | |
| 1266 | − q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey), | |
| 1267 | − ) | |
| 1268 | − .collect(); | |
| 1269 | − | |
| 1270 | − for (const binding of bindings) { | |
| 1271 | − await ctx.db.delete(binding._id); | |
| 1272 | − } | |
| 1273 | − | |
| 1274 | − await ctx.db.delete(doc._id); | |
| 1275 | − return true; | |
| 1276 | − }, | |
| 1277 | −}); | |
| 1278 | − | |
| 1279 | −export const createTaskEvent = internalMutation({ | |
| 1280 | − args: { | |
| 1281 | − taskId: v.string(), | |
| 1282 | − eventName: v.string(), | |
| 1283 | − type: v.string(), | |
| 1284 | − payload: v.any(), | |
| 1285 | − }, | |
| 1286 | − handler: async (ctx, args) => { | |
| 1287 | − const task = await getTaskDoc(ctx, args.taskId); | |
| 1288 | − if (!task) { | |
| 1289 | − throw new Error(`Task not found for event: ${args.taskId}`); | |
| 1290 | − } | |
| 1291 | − | |
| 1292 | − const latest = await ctx.db | |
| 1293 | − .query("taskEvents") | |
| 1294 | − .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId)) | |
| 1295 | − .order("desc") | |
| 1296 | − .first(); | |
| 1297 | − | |
| 1298 | − const sequence = latest ? latest.sequence + 1 : 1; | |
| 1299 | − const createdAt = Date.now(); | |
| 1300 | − | |
| 1301 | − await ctx.db.insert("taskEvents", { | |
| 1302 | − sequence, | |
| 1303 | − taskId: args.taskId, | |
| 1304 | − eventName: args.eventName, | |
| 1305 | − type: args.type, | |
| 1306 | − payload: asRecord(args.payload), | |
| 1307 | − createdAt, | |
| 1308 | − }); | |
| 1309 | − | |
| 1310 | − const created = await ctx.db | |
| 1311 | − .query("taskEvents") | |
| 1312 | − .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId).eq("sequence", sequence)) | |
| 1313 | − .unique(); | |
| 1314 | − | |
| 1315 | − if (!created) { | |
| 1316 | − throw new Error("Failed to read inserted task event"); | |
| 1317 | − } | |
| 1318 | − | |
| 1319 | − return mapTaskEvent(created); | |
| 1320 | − }, | |
| 1321 | −}); | |
| 1322 | − | |
| 1323 | −export const listTaskEvents = internalQuery({ | |
| 1324 | − args: { taskId: v.string() }, | |
| 1325 | − handler: async (ctx, args) => { | |
| 1326 | − const docs = await ctx.db | |
| 1327 | − .query("taskEvents") | |
| 1328 | − .withIndex("by_task_sequence", (q) => q.eq("taskId", args.taskId)) | |
| 1329 | − .order("asc") | |
| 1330 | − .collect(); | |
| 1331 | − | |
| 1332 | − return docs.map(mapTaskEvent); | |
| 1333 | − }, | |
| 1334 | −}); | |
| 1335 | − | |
| 1336 | −// ── Anonymous OAuth Signing Keys ───────────────────────────────────────────── | |
| 1337 | − | |
| 1338 | −/** | |
| 1339 | − * Get the active anonymous OAuth signing key pair. | |
| 1340 | − * Returns null if no key has been generated yet. | |
| 1341 | − */ | |
| 1342 | −export const getActiveAnonymousOauthSigningKey = internalQuery({ | |
| 1343 | − args: {}, | |
| 1344 | − handler: async (ctx) => { | |
| 1345 | − const doc = await ctx.db | |
| 1346 | − .query("anonymousOauthSigningKeys") | |
| 1347 | − .withIndex("by_status", (q) => q.eq("status", "active")) | |
| 1348 | − .first(); | |
| 1349 | − | |
| 1350 | − if (!doc) return null; | |
| 1351 | − | |
| 1352 | − return { | |
| 1353 | − keyId: doc.keyId, | |
| 1354 | − algorithm: doc.algorithm, | |
| 1355 | − privateKeyJwk: doc.privateKeyJwk, | |
| 1356 | − publicKeyJwk: doc.publicKeyJwk, | |
| 1357 | − createdAt: doc.createdAt, | |
| 1358 | − }; | |
| 1359 | − }, | |
| 1360 | −}); | |
| 1361 | − | |
| 1362 | −/** | |
| 1363 | − * Store a new anonymous OAuth signing key pair, rotating any previously active key. | |
| 1364 | − */ | |
| 1365 | −export const storeAnonymousOauthSigningKey = internalMutation({ | |
| 1366 | − args: { | |
| 1367 | − keyId: v.string(), | |
| 1368 | − algorithm: v.string(), | |
| 1369 | − privateKeyJwk: v.any(), | |
| 1370 | − publicKeyJwk: v.any(), | |
| 1371 | − }, | |
| 1372 | − handler: async (ctx, args) => { | |
| 1373 | − const now = Date.now(); | |
| 1374 | − | |
| 1375 | − // Rotate any currently active keys | |
| 1376 | − const activeKeys = await ctx.db | |
| 1377 | − .query("anonymousOauthSigningKeys") | |
| 1378 | − .withIndex("by_status", (q) => q.eq("status", "active")) | |
| 1379 | − .collect(); | |
| 1380 | − | |
| 1381 | − for (const key of activeKeys) { | |
| 1382 | − await ctx.db.patch(key._id, { status: "rotated", rotatedAt: now }); | |
| 1383 | − } | |
| 1384 | − | |
| 1385 | − // Insert the new active key | |
| 1386 | − await ctx.db.insert("anonymousOauthSigningKeys", { | |
| 1387 | − keyId: args.keyId, | |
| 1388 | − algorithm: args.algorithm, | |
| 1389 | − privateKeyJwk: args.privateKeyJwk, | |
| 1390 | − publicKeyJwk: args.publicKeyJwk, | |
| 1391 | − status: "active", | |
| 1392 | − createdAt: now, | |
| 1393 | − }); | |
| 1394 | − }, | |
| 1395 | −}); | |
| 1396 | − | |
| 1397 | −// ── Anonymous OAuth Client Registrations ──────────────────────────────────── | |
| 1398 | − | |
| 1399 | −/** | |
| 1400 | − * Register a new anonymous OAuth client (RFC 7591 dynamic client registration). | |
| 1401 | − */ | |
| 1402 | −export const registerAnonymousOauthClient = internalMutation({ | |
| 1403 | − args: { | |
| 1404 | − clientId: v.string(), | |
| 1405 | − clientName: v.optional(v.string()), | |
| 1406 | − redirectUris: v.array(v.string()), | |
| 1407 | − }, | |
| 1408 | − handler: async (ctx, args) => { | |
| 1409 | − const now = Date.now(); | |
| 1410 | − await ctx.db.insert("anonymousOauthClients", { | |
| 1411 | − clientId: args.clientId, | |
| 1412 | − clientName: args.clientName, | |
| 1413 | − redirectUris: args.redirectUris, | |
| 1414 | − createdAt: now, | |
| 1415 | − }); | |
| 1416 | − | |
| 1417 | − return { | |
| 1418 | − client_id: args.clientId, | |
| 1419 | − client_name: args.clientName, | |
| 1420 | − redirect_uris: args.redirectUris, | |
| 1421 | − created_at: now, | |
| 1422 | − }; | |
| 1423 | − }, | |
| 1424 | −}); | |
| 1425 | − | |
| 1426 | −/** | |
| 1427 | − * Look up an anonymous OAuth client by client_id. | |
| 1428 | − */ | |
| 1429 | −export const getAnonymousOauthClient = internalQuery({ | |
| 1430 | − args: { clientId: v.string() }, | |
| 1431 | − handler: async (ctx, args) => { | |
| 1432 | − const doc = await ctx.db | |
| 1433 | − .query("anonymousOauthClients") | |
| 1434 | − .withIndex("by_client_id", (q) => q.eq("clientId", args.clientId)) | |
| 1435 | − .unique(); | |
| 1436 | − | |
| 1437 | − if (!doc) return null; | |
| 1438 | − | |
| 1439 | − return { | |
| 1440 | − client_id: doc.clientId, | |
| 1441 | − client_name: doc.clientName, | |
| 1442 | − redirect_uris: doc.redirectUris, | |
| 1443 | − created_at: doc.createdAt, | |
| 1444 | − }; | |
| 1445 | − }, | |
| 1446 | −}); | |
| 1447 | − | |
| 1448 | −// ── Anonymous OAuth Authorization Codes ────────────────────────────────────── | |
| 1449 | − | |
| 1450 | −export const storeAnonymousOauthAuthorizationCode = internalMutation({ | |
| 1451 | − args: { | |
| 1452 | − code: v.string(), | |
| 1453 | − clientId: v.string(), | |
| 1454 | − redirectUri: v.string(), | |
| 1455 | − codeChallenge: v.string(), | |
| 1456 | − codeChallengeMethod: v.string(), | |
| 1457 | − actorId: v.string(), | |
| 1458 | − tokenClaims: v.optional(v.any()), | |
| 1459 | − expiresAt: v.number(), | |
| 1460 | − createdAt: v.number(), | |
| 1461 | − }, | |
| 1462 | − handler: async (ctx, args) => { | |
| 1463 | − await ctx.db.insert("anonymousOauthCodes", { | |
| 1464 | − code: args.code, | |
| 1465 | − clientId: args.clientId, | |
| 1466 | − redirectUri: args.redirectUri, | |
| 1467 | − codeChallenge: args.codeChallenge, | |
| 1468 | − codeChallengeMethod: args.codeChallengeMethod, | |
| 1469 | − actorId: args.actorId, | |
| 1470 | − tokenClaims: args.tokenClaims, | |
| 1471 | − expiresAt: args.expiresAt, | |
| 1472 | − createdAt: args.createdAt, | |
| 1473 | − }); | |
| 1474 | − }, | |
| 1475 | −}); | |
| 1476 | − | |
| 1477 | −export const consumeAnonymousOauthAuthorizationCode = internalMutation({ | |
| 1478 | − args: { | |
| 1479 | − code: v.string(), | |
| 1480 | − }, | |
| 1481 | − handler: async (ctx, args) => { | |
| 1482 | − const doc = await ctx.db | |
| 1483 | − .query("anonymousOauthCodes") | |
| 1484 | − .withIndex("by_code", (q) => q.eq("code", args.code)) | |
| 1485 | − .unique(); | |
| 1486 | − | |
| 1487 | − if (!doc) { | |
| 1488 | − return null; | |
| 1489 | − } | |
| 1490 | − | |
| 1491 | − await ctx.db.delete(doc._id); | |
| 1492 | − | |
| 1493 | − return { | |
| 1494 | − code: doc.code, | |
| 1495 | − clientId: doc.clientId, | |
| 1496 | − redirectUri: doc.redirectUri, | |
| 1497 | − codeChallenge: doc.codeChallenge, | |
| 1498 | − codeChallengeMethod: doc.codeChallengeMethod, | |
| 1499 | − actorId: doc.actorId, | |
| 1500 | − tokenClaims: doc.tokenClaims, | |
| 1501 | − expiresAt: doc.expiresAt, | |
| 1502 | − createdAt: doc.createdAt, | |
| 1503 | − }; | |
| 1504 | − }, | |
| 1505 | −}); | |
| 1506 | − | |
| 1507 | −export const purgeExpiredAnonymousOauthAuthorizationCodes = internalMutation({ | |
| 1508 | − args: { | |
| 1509 | − now: v.number(), | |
| 1510 | − }, | |
| 1511 | − handler: async (ctx, args) => { | |
| 1512 | − const expired = await ctx.db | |
| 1513 | − .query("anonymousOauthCodes") | |
| 1514 | − .withIndex("by_expires_at", (q) => q.lt("expiresAt", args.now)) | |
| 1515 | − .collect(); | |
| 1516 | − | |
| 1517 | − for (const doc of expired) { | |
| 1518 | − await ctx.db.delete(doc._id); | |
| 1519 | − } | |
| 1520 | − | |
| 1521 | − return { purged: expired.length }; | |
| 1522 | − }, | |
| 1523 | −}); | |
| 1524 | − | |
| 1525 | −export const countAnonymousOauthAuthorizationCodes = internalQuery({ | |
| 1526 | − args: {}, | |
| 1527 | − handler: async (ctx) => { | |
| 1528 | − const docs = await ctx.db.query("anonymousOauthCodes").collect(); | |
| 1529 | − return { count: docs.length }; | |
| 1530 | − }, | |
| 1531 | −}); | |
| 1 | +export { | |
| 2 | + createTask, | |
| 3 | + getTask, | |
| 4 | + listTasks, | |
| 5 | + listQueuedTaskIds, | |
| 6 | + getTaskInWorkspace, | |
| 7 | + markTaskRunning, | |
| 8 | + markTaskFinished, | |
| 9 | +} from "./database-tasks"; | |
| 10 | +export { | |
| 11 | + createApproval, | |
| 12 | + getApproval, | |
| 13 | + listApprovals, | |
| 14 | + listPendingApprovals, | |
| 15 | + resolveApproval, | |
| 16 | + getApprovalInWorkspace, | |
| 17 | +} from "./database-approvals"; | |
| 18 | +export { | |
| 19 | + upsertToolCallRequested, | |
| 20 | + getToolCall, | |
| 21 | + setToolCallPendingApproval, | |
| 22 | + finishToolCall, | |
| 23 | + listToolCalls, | |
| 24 | +} from "./database-tool-calls"; | |
| 25 | +export { bootstrapAnonymousSession } from "./database-anonymous-session"; | |
| 26 | +export { | |
| 27 | + listRuntimeTargets, | |
| 28 | + upsertAccessPolicy, | |
| 29 | + listAccessPolicies, | |
| 30 | +} from "./database-policies"; | |
| 31 | +export { | |
| 32 | + upsertCredential, | |
| 33 | + listCredentials, | |
| 34 | + listCredentialProviders, | |
| 35 | + resolveCredential, | |
| 36 | +} from "./database-credentials"; | |
| 37 | +export { | |
| 38 | + upsertToolSource, | |
| 39 | + listToolSources, | |
| 40 | + deleteToolSource, | |
| 41 | +} from "./database-tool-sources"; | |
| 42 | +export { createTaskEvent, listTaskEvents } from "./database-task-events"; | |
| 43 | +export { | |
| 44 | + getActiveAnonymousOauthSigningKey, | |
| 45 | + storeAnonymousOauthSigningKey, | |
| 46 | + registerAnonymousOauthClient, | |
| 47 | + getAnonymousOauthClient, | |
| 48 | + storeAnonymousOauthAuthorizationCode, | |
| 49 | + consumeAnonymousOauthAuthorizationCode, | |
| 50 | + purgeExpiredAnonymousOauthAuthorizationCodes, | |
| 51 | + countAnonymousOauthAuthorizationCodes, | |
| 52 | +} from "./database-oauth"; | |
| @@ -7,28 +7,10 @@ import { workspaceMutation } from "../core/src/function-builders"; · 6 unmodified lines | ||
| 7 | 7 | import { actorIdForAccount } from "../core/src/identity"; |
| 8 | 8 | import { isKnownRuntimeId } from "../core/src/runtimes/runtime-catalog"; |
| 9 | 9 | import type { ApprovalRecord, TaskRecord } from "../core/src/types"; |
| 10 | − | |
| 11 | −const DEFAULT_TIMEOUT_MS = 300_000; | |
| 12 | − | |
| 13 | −async function publishTaskEvent( | |
| 14 | − ctx: MutationCtx, | |
| 15 | − input: { | |
| 16 | − taskId: string; | |
| 17 | − eventName: string; | |
| 18 | − type: string; | |
| 19 | − payload: Record<string, unknown>; | |
| 20 | − }, | |
| 21 | −): Promise<void> { | |
| 22 | − await ctx.runMutation(internal.database.createTaskEvent, input); | |
| 23 | −} | |
| 24 | − | |
| 25 | −function terminalEventForStatus(status: "completed" | "failed" | "timed_out" | "denied"): | |
| 26 | − "task.completed" | "task.failed" | "task.timed_out" | "task.denied" { | |
| 27 | − if (status === "completed") return "task.completed"; | |
| 28 | − if (status === "timed_out") return "task.timed_out"; | |
| 29 | − if (status === "denied") return "task.denied"; | |
| 30 | − return "task.failed"; | |
| 31 | −} | |
| 10 | +import { isTerminalTaskStatus, taskTerminalEventType } from "./task-status"; | |
| 11 | +import { DEFAULT_TASK_TIMEOUT_MS } from "./task-constants"; | |
| 12 | +import { createTaskEvent } from "./task-events"; | |
| 13 | +import { markTaskFinished } from "./task-finish"; | |
| 32 | 14 | |
| 33 | 15 | async function createTaskRecord( |
| 34 | 16 | ctx: MutationCtx, |
| @@ -57,14 +39,14 @@ async function createTaskRecord( · 22 unmodified lines | ||
| 57 | 39 | id: taskId, |
| 58 | 40 | code: args.code, |
| 59 | 41 | runtimeId, |
| 60 | − timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS, | |
| 42 | + timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS, | |
| 61 | 43 | metadata: args.metadata, |
| 62 | 44 | workspaceId: args.workspaceId, |
| 63 | 45 | actorId: args.actorId, |
| 64 | 46 | clientId: args.clientId, |
| 65 | 47 | })) as TaskRecord; |
| 66 | 48 | |
| 67 | − await publishTaskEvent(ctx, { | |
| 49 | + await createTaskEvent(ctx, { | |
| 68 | 50 | taskId, |
| 69 | 51 | eventName: "task", |
| 70 | 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 | 66 | taskId, |
| 85 | 67 | eventName: "task", |
| 86 | 68 | type: "task.queued", |
| @@ -127,7 +109,7 @@ async function resolveApprovalRecord( · 40 unmodified lines | ||
| 127 | 109 | return null; |
| 128 | 110 | } |
| 129 | 111 | |
| 130 | − await publishTaskEvent(ctx, { | |
| 112 | + await createTaskEvent(ctx, { | |
| 131 | 113 | taskId: approval.taskId, |
| 132 | 114 | eventName: "approval", |
| 133 | 115 | type: "approval.resolved", |
| @@ -241,11 +223,11 @@ export const completeRuntimeRun = internalMutation({ · 107 unmodified lines | ||
| 241 | 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 | 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 | 231 | taskId: args.runId, |
| 250 | 232 | status: args.status, |
| 251 | 233 | result: args.result, |
| @@ -257,10 +239,10 @@ export const completeRuntimeRun = internalMutation({ · 5 unmodified lines | ||
| 257 | 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 | 243 | taskId: args.runId, |
| 262 | 244 | eventName: "task", |
| 263 | − type: terminalEventForStatus(args.status), | |
| 245 | + type: taskTerminalEventType(args.status), | |
| 264 | 246 | payload: { |
| 265 | 247 | taskId: args.runId, |
| 266 | 248 | status: finished.status, |
| @@ -1,16 +1,16 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | −import { internal } from "../_generated/api"; | |
| 4 | +import { createTaskEvent, type TaskEventName } from "../task-events"; | |
| 5 | 5 | |
| 6 | 6 | export async function publishTaskEvent( |
| 7 | 7 | ctx: ActionCtx, |
| 8 | 8 | taskId: string, |
| 9 | − eventName: "task" | "approval", | |
| 9 | + eventName: TaskEventName, | |
| 10 | 10 | type: string, |
| 11 | 11 | payload: Record<string, unknown>, |
| 12 | 12 | ): Promise<void> { |
| 13 | − await ctx.runMutation(internal.database.createTaskEvent, { | |
| 13 | + await createTaskEvent(ctx, { | |
| 14 | 14 | taskId, |
| 15 | 15 | eventName, |
| 16 | 16 | type, |
| @@ -14,8 +14,51 @@ import { runCodeWithAdapter } from "../../core/src/runtimes/runtime-core"; · 13 unmodified lines | ||
| 14 | 14 | import type { TaskRecord } from "../../core/src/types"; |
| 15 | 15 | import { describeError } from "../../core/src/utils"; |
| 16 | 16 | import { publishTaskEvent } from "./events"; |
| 17 | +import { taskTerminalEventType } from "../task-status"; | |
| 18 | +import { markTaskFinished } from "../task-finish"; | |
| 17 | 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 | 62 | export async function runQueuedTask( |
| 20 | 63 | ctx: ActionCtx, |
| 21 | 64 | args: { taskId: string }, |
| @@ -26,36 +69,18 @@ export async function runQueuedTask( · 4 unmodified lines | ||
| 26 | 69 | } |
| 27 | 70 | |
| 28 | 71 | if (!isKnownRuntimeId(task.runtimeId)) { |
| 29 | − const failed = await ctx.runMutation(internal.database.markTaskFinished as any, { | |
| 72 | + await markTaskFailedAndPublish(ctx, { | |
| 30 | 73 | taskId: args.taskId, |
| 31 | − status: "failed", | |
| 32 | 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 | 76 | return null; |
| 43 | 77 | } |
| 44 | 78 | |
| 45 | 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 | 81 | taskId: args.taskId, |
| 48 | − status: "failed", | |
| 49 | 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 | 84 | return null; |
| 60 | 85 | } |
| 61 | 86 | |
| @@ -81,24 +106,14 @@ export async function runQueuedTask( · 19 unmodified lines | ||
| 81 | 106 | }); |
| 82 | 107 | |
| 83 | 108 | if (!dispatchResult.ok) { |
| 84 | − const failed = await ctx.runMutation(internal.database.markTaskFinished as any, { | |
| 109 | + await markTaskFailedAndPublish(ctx, { | |
| 85 | 110 | taskId: args.taskId, |
| 86 | − status: "failed", | |
| 87 | 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 | 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 | 117 | taskId: args.taskId, |
| 103 | 118 | status: dispatchResult.status, |
| 104 | 119 | result: dispatchResult.result, |
| @@ -110,22 +125,11 @@ export async function runQueuedTask( · 5 unmodified lines | ||
| 110 | 125 | return null; |
| 111 | 126 | } |
| 112 | 127 | |
| 113 | − const terminalEvent = | |
| 114 | − dispatchResult.status === "completed" | |
| 115 | − ? "task.completed" | |
| 116 | − : dispatchResult.status === "timed_out" | |
| 117 | − ? "task.timed_out" | |
| 118 | − : dispatchResult.status === "denied" | |
| 119 | − ? "task.denied" | |
| 120 | − : "task.failed"; | |
| 121 | − | |
| 122 | − await publishTaskEvent(ctx, args.taskId, "task", terminalEvent, { | |
| 128 | + await publishTerminalTaskResult(ctx, { | |
| 123 | 129 | taskId: args.taskId, |
| 124 | − status: finished.status, | |
| 125 | − exitCode: finished.exitCode, | |
| 130 | + status: dispatchResult.status, | |
| 131 | + finished, | |
| 126 | 132 | durationMs: dispatchResult.durationMs, |
| 127 | − error: finished.error, | |
| 128 | − completedAt: finished.completedAt, | |
| 129 | 133 | }); |
| 130 | 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 | 154 | taskId: args.taskId, |
| 151 | 155 | status: runtimeResult.status, |
| 152 | 156 | result: runtimeResult.result, |
| @@ -158,38 +162,26 @@ export async function runQueuedTask( · 5 unmodified lines | ||
| 158 | 162 | return null; |
| 159 | 163 | } |
| 160 | 164 | |
| 161 | − const terminalEvent = | |
| 162 | − runtimeResult.status === "completed" | |
| 163 | − ? "task.completed" | |
| 164 | − : runtimeResult.status === "timed_out" | |
| 165 | − ? "task.timed_out" | |
| 166 | − : runtimeResult.status === "denied" | |
| 167 | − ? "task.denied" | |
| 168 | − : "task.failed"; | |
| 169 | − | |
| 170 | − await publishTaskEvent(ctx, args.taskId, "task", terminalEvent, { | |
| 165 | + await publishTerminalTaskResult(ctx, { | |
| 171 | 166 | taskId: args.taskId, |
| 172 | − status: finished.status, | |
| 173 | − exitCode: finished.exitCode, | |
| 167 | + status: runtimeResult.status, | |
| 168 | + finished, | |
| 174 | 169 | durationMs: runtimeResult.durationMs, |
| 175 | − error: finished.error, | |
| 176 | − completedAt: finished.completedAt, | |
| 177 | 170 | }); |
| 178 | 171 | } catch (error) { |
| 179 | 172 | const message = describeError(error); |
| 180 | 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 | 175 | taskId: args.taskId, |
| 183 | 176 | status: denied ? "denied" : "failed", |
| 184 | 177 | error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message, |
| 185 | 178 | }); |
| 186 | 179 | |
| 187 | 180 | if (finished) { |
| 188 | − await publishTaskEvent(ctx, args.taskId, "task", denied ? "task.denied" : "task.failed", { | |
| 181 | + await publishTerminalTaskResult(ctx, { | |
| 189 | 182 | taskId: args.taskId, |
| 190 | − status: finished.status, | |
| 191 | − error: finished.error, | |
| 192 | − completedAt: finished.completedAt, | |
| 183 | + status: denied ? "denied" : "failed", | |
| 184 | + finished, | |
| 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 | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | 4 | import { internal } from "../_generated/api"; |
| 5 | −import { resolveCredentialPayload } from "../../core/src/credential-providers"; | |
| 6 | −import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants"; | |
| 7 | −import { parseGraphqlOperationPaths } from "../../core/src/tool-sources"; | |
| 5 | +import { APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants"; | |
| 8 | 6 | import type { |
| 9 | 7 | AccessPolicyRecord, |
| 10 | − CredentialScope, | |
| 11 | 8 | PolicyDecision, |
| 12 | 9 | ResolvedToolCredential, |
| 13 | 10 | TaskRecord, |
| 14 | 11 | ToolCallRecord, |
| 15 | 12 | ToolCallRequest, |
| 16 | − ToolCredentialSpec, | |
| 17 | − ToolDefinition, | |
| 18 | 13 | ToolRunContext, |
| 19 | 14 | } from "../../core/src/types"; |
| 20 | 15 | import { asPayload, describeError } from "../../core/src/utils"; |
| 21 | −import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy"; | |
| 22 | −import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths"; | |
| 23 | −import { baseTools, getWorkspaceTools } from "./workspace_tools"; | |
| 16 | +import { getToolDecision, isToolAllowedForTask } from "./policy"; | |
| 17 | +import { baseTools } from "./workspace_tools"; | |
| 24 | 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 | 23 | function createApprovalId(): string { |
| 27 | 24 | return `approval_${crypto.randomUUID()}`; |
| 28 | 25 | } |
| 29 | 26 | |
| 30 | −async function resolveCredentialHeaders( | |
| 27 | +async function denyToolCallForApproval( | |
| 31 | 28 | ctx: ActionCtx, |
| 32 | − spec: ToolCredentialSpec, | |
| 33 | − task: TaskRecord, | |
| 34 | −): Promise<ResolvedToolCredential | null> { | |
| 35 | − const record = await ctx.runQuery(internal.database.resolveCredential, { | |
| 36 | − workspaceId: task.workspaceId, | |
| 37 | − sourceKey: spec.sourceKey, | |
| 38 | − scope: spec.mode as CredentialScope, | |
| 39 | − actorId: task.actorId, | |
| 29 | + args: { | |
| 30 | + task: TaskRecord; | |
| 31 | + callId: string; | |
| 32 | + toolPath: string; | |
| 33 | + approvalId: string; | |
| 34 | + }, | |
| 35 | +): Promise<never> { | |
| 36 | + const deniedMessage = `${args.toolPath} (${args.approvalId})`; | |
| 37 | + return await denyToolCall(ctx, { | |
| 38 | + task: args.task, | |
| 39 | + callId: args.callId, | |
| 40 | + toolPath: args.toolPath, | |
| 41 | + deniedMessage, | |
| 42 | + approvalId: args.approvalId, | |
| 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 | 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 | 51 | workspaceId: task.workspaceId, |
| 154 | 52 | toolPath, |
| 155 | 53 | })) as ToolCallRecord; |
| 156 | − | |
| 157 | − if (persistedCall.status === "completed") { | |
| 158 | − throw new Error(`Tool call ${callId} already completed; output is not retained`); | |
| 159 | − } | |
| 160 | − | |
| 161 | − if (persistedCall.status === "failed") { | |
| 162 | − throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`); | |
| 163 | − } | |
| 164 | − | |
| 165 | − if (persistedCall.status === "denied") { | |
| 166 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`); | |
| 167 | − } | |
| 54 | + assertPersistedCallRunnable(persistedCall, callId); | |
| 168 | 55 | |
| 169 | 56 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); |
| 170 | 57 | const typedPolicies = policies as AccessPolicyRecord[]; |
| 171 | 58 | |
| 172 | − let workspaceTools: Map<string, ToolDefinition> | undefined; | |
| 173 | − let resolvedToolPath = toolPath; | |
| 174 | − let tool = baseTools.get(toolPath); | |
| 175 | − if (!tool) { | |
| 176 | − const result = await getWorkspaceTools(ctx, task.workspaceId); | |
| 177 | − workspaceTools = result.tools; | |
| 178 | − tool = workspaceTools.get(toolPath); | |
| 179 | − | |
| 180 | − if (!tool) { | |
| 181 | − const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools); | |
| 182 | − if (aliasedPath) { | |
| 183 | − resolvedToolPath = aliasedPath; | |
| 184 | − tool = workspaceTools.get(aliasedPath); | |
| 185 | − } | |
| 186 | − } | |
| 187 | − } | |
| 188 | − | |
| 189 | − if (!tool) { | |
| 190 | − const availableTools = workspaceTools ?? baseTools; | |
| 191 | − const healedPath = resolveClosestToolPath(toolPath, availableTools); | |
| 192 | − if (healedPath) { | |
| 193 | − resolvedToolPath = healedPath; | |
| 194 | − tool = availableTools.get(healedPath); | |
| 195 | − } | |
| 196 | − } | |
| 197 | − | |
| 198 | − if (!tool) { | |
| 199 | − const availableTools = workspaceTools ?? baseTools; | |
| 200 | − const suggestions = suggestToolPaths(toolPath, availableTools); | |
| 201 | − const queryHint = toolPath | |
| 202 | − .split(".") | |
| 203 | − .filter(Boolean) | |
| 204 | − .join(" "); | |
| 205 | − const suggestionText = suggestions.length > 0 | |
| 206 | − ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}` | |
| 207 | − : ""; | |
| 208 | − const discoverHint = `\nTry: const found = await tools.discover({ query: \"${queryHint}\", compact: false, depth: 2, limit: 12 });`; | |
| 209 | − throw new Error(`Unknown tool: ${toolPath}${suggestionText}${discoverHint}`); | |
| 210 | − } | |
| 59 | + let { tool, resolvedToolPath, workspaceTools } = await resolveToolForCall(ctx, task, toolPath); | |
| 211 | 60 | |
| 212 | 61 | let decision: PolicyDecision; |
| 213 | 62 | let effectiveToolPath = resolvedToolPath; |
| 214 | 63 | if (tool._graphqlSource) { |
| 215 | − if (!workspaceTools) { | |
| 216 | − const result = await getWorkspaceTools(ctx, task.workspaceId); | |
| 217 | − workspaceTools = result.tools; | |
| 218 | − } | |
| 64 | + workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools); | |
| 219 | 65 | const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies); |
| 220 | 66 | decision = result.decision; |
| 221 | 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 | 76 | if (decision === "deny") { |
| 231 | 77 | const deniedMessage = `${effectiveToolPath} (policy denied)`; |
| 232 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 233 | − taskId: task.id, | |
| 234 | − callId, | |
| 235 | − status: "denied", | |
| 236 | − error: deniedMessage, | |
| 237 | − }); | |
| 238 | − await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", { | |
| 239 | − taskId: task.id, | |
| 78 | + await denyToolCall(ctx, { | |
| 79 | + task, | |
| 240 | 80 | callId, |
| 241 | 81 | toolPath: effectiveToolPath, |
| 82 | + deniedMessage, | |
| 242 | 83 | reason: "policy_deny", |
| 243 | 84 | }); |
| 244 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 245 | 85 | } |
| 246 | 86 | |
| 247 | 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 | 118 | if (existingApproval.status === "denied") { |
| 279 | − const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`; | |
| 280 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 281 | − taskId: task.id, | |
| 282 | − callId, | |
| 283 | − status: "denied", | |
| 284 | − error: deniedMessage, | |
| 285 | − }); | |
| 286 | − await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", { | |
| 287 | − taskId: task.id, | |
| 119 | + await denyToolCallForApproval(ctx, { | |
| 120 | + task, | |
| 288 | 121 | callId, |
| 289 | 122 | toolPath: effectiveToolPath, |
| 290 | 123 | approvalId: existingApproval.id, |
| 291 | 124 | }); |
| 292 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 293 | 125 | } |
| 294 | 126 | |
| 295 | 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 | 164 | if (approval.status === "denied") { |
| 333 | − const deniedMessage = `${effectiveToolPath} (${approval.id})`; | |
| 334 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 335 | − taskId: task.id, | |
| 336 | − callId, | |
| 337 | − status: "denied", | |
| 338 | − error: deniedMessage, | |
| 339 | − }); | |
| 340 | − await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", { | |
| 341 | − taskId: task.id, | |
| 165 | + await denyToolCallForApproval(ctx, { | |
| 166 | + task, | |
| 342 | 167 | callId, |
| 343 | 168 | toolPath: effectiveToolPath, |
| 344 | 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 | 181 | isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies), |
| 358 | 182 | }; |
| 359 | 183 | const value = await tool.run(input, context); |
| 360 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 361 | − taskId: task.id, | |
| 362 | − callId, | |
| 363 | − status: "completed", | |
| 364 | − }); | |
| 365 | − await publishTaskEvent(ctx, task.id, "task", "tool.call.completed", { | |
| 184 | + await completeToolCall(ctx, { | |
| 366 | 185 | taskId: task.id, |
| 367 | 186 | callId, |
| 368 | 187 | toolPath: effectiveToolPath, |
| 369 | − outputRedacted: true, | |
| 370 | 188 | }); |
| 371 | 189 | return value; |
| 372 | 190 | } catch (error) { |
| 373 | 191 | const message = describeError(error); |
| 374 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 192 | + await failToolCall(ctx, { | |
| 375 | 193 | taskId: task.id, |
| 376 | 194 | callId, |
| 377 | − status: "failed", | |
| 378 | 195 | error: message, |
| 379 | − }); | |
| 380 | − await publishTaskEvent(ctx, task.id, "task", "tool.call.failed", { | |
| 381 | − taskId: task.id, | |
| 382 | − callId, | |
| 383 | 196 | toolPath: effectiveToolPath, |
| 384 | − error: message, | |
| 385 | 197 | }); |
| 386 | 198 | throw error; |
| 387 | 199 | } |
| @@ -7,25 +7,26 @@ import { · 6 unmodified lines | ||
| 7 | 7 | compileOpenApiToolSourceFromPrepared, |
| 8 | 8 | prepareOpenApiSpec, |
| 9 | 9 | type CompiledToolSourceArtifact, |
| 10 | − type ExternalToolSourceConfig, | |
| 11 | − type GraphqlToolSourceConfig, | |
| 12 | − type McpToolSourceConfig, | |
| 13 | − type OpenApiToolSourceConfig, | |
| 14 | − type PreparedOpenApiSpec, | |
| 15 | 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 | 18 | import type { ToolSourceRecord } from "../../core/src/types"; |
| 17 | 19 | |
| 18 | 20 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 19 | 21 | |
| 20 | 22 | /** Cache version - bump when PreparedOpenApiSpec shape changes. */ |
| 21 | −const OPENAPI_CACHE_VERSION = "v15"; | |
| 23 | +const TOOL_SOURCE_CACHE_VERSION = "v15"; | |
| 22 | 24 | |
| 23 | 25 | export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 24 | − const signatureVersion = "v15"; | |
| 25 | 26 | const parts = sources |
| 26 | 27 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) |
| 27 | 28 | .sort(); |
| 28 | − return `${signatureVersion}|${workspaceId}|${parts.join(",")}`; | |
| 29 | + return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`; | |
| 29 | 30 | } |
| 30 | 31 | |
| 31 | 32 | export function normalizeExternalToolSource(raw: { |
| @@ -124,7 +125,7 @@ async function loadCachedOpenApiSpec( · 92 unmodified lines | ||
| 124 | 125 | try { |
| 125 | 126 | const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { |
| 126 | 127 | specUrl, |
| 127 | − version: OPENAPI_CACHE_VERSION, | |
| 128 | + version: TOOL_SOURCE_CACHE_VERSION, | |
| 128 | 129 | maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, |
| 129 | 130 | }); |
| 130 | 131 | |
| @@ -148,7 +149,7 @@ async function loadCachedOpenApiSpec( · 17 unmodified lines | ||
| 148 | 149 | const storageId = await ctx.storage.store(blob); |
| 149 | 150 | await ctx.runMutation(internal.openApiSpecCache.putEntry, { |
| 150 | 151 | specUrl, |
| 151 | − version: OPENAPI_CACHE_VERSION, | |
| 152 | + version: TOOL_SOURCE_CACHE_VERSION, | |
| 152 | 153 | storageId, |
| 153 | 154 | sizeBytes: json.length, |
| 154 | 155 | }); |
| @@ -8,9 +8,9 @@ import { · 7 unmodified lines | ||
| 8 | 8 | materializeCompiledToolSource, |
| 9 | 9 | materializeWorkspaceSnapshot, |
| 10 | 10 | type CompiledToolSourceArtifact, |
| 11 | − type ExternalToolSourceConfig, | |
| 12 | 11 | type WorkspaceToolSnapshot, |
| 13 | 12 | } from "../../core/src/tool-sources"; |
| 13 | +import type { ExternalToolSourceConfig } from "../../core/src/tool-source-types"; | |
| 14 | 14 | import { DEFAULT_TOOLS } from "../../core/src/tools"; |
| 15 | 15 | import type { |
| 16 | 16 | AccessPolicyRecord, |
| @@ -75,6 +75,29 @@ function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<s · 58 unmodified lines | ||
| 75 | 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 | 101 | export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> { |
| 79 | 102 | const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) |
| 80 | 103 | .filter((source: { enabled: boolean }) => source.enabled); |
| @@ -91,22 +114,7 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 10 unmodified lines | ||
| 91 | 114 | if (blob) { |
| 92 | 115 | const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot; |
| 93 | 116 | const restored = materializeWorkspaceSnapshot(snapshot); |
| 94 | − | |
| 95 | − const merged = new Map<string, ToolDefinition>(); | |
| 96 | − for (const tool of baseTools.values()) { | |
| 97 | − if (tool.path === "discover") continue; | |
| 98 | − merged.set(tool.path, tool); | |
| 99 | − } | |
| 100 | − for (const tool of restored) { | |
| 101 | − if (tool.path === "discover") continue; | |
| 102 | − merged.set(tool.path, tool); | |
| 103 | − } | |
| 104 | − const catalogTools = createCatalogTools([...merged.values()]); | |
| 105 | − for (const tool of catalogTools) { | |
| 106 | − merged.set(tool.path, tool); | |
| 107 | − } | |
| 108 | − const discover = createDiscoverTool([...merged.values()]); | |
| 109 | − merged.set(discover.path, discover); | |
| 117 | + const merged = mergeToolsWithCatalog(restored); | |
| 110 | 118 | |
| 111 | 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 | 143 | .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact)); |
| 136 | 144 | const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact)); |
| 137 | 145 | warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings)); |
| 138 | − | |
| 139 | − const merged = new Map<string, ToolDefinition>(); | |
| 140 | − for (const tool of baseTools.values()) { | |
| 141 | − if (tool.path === "discover") continue; | |
| 142 | − merged.set(tool.path, tool); | |
| 143 | − } | |
| 144 | − for (const tool of externalTools) { | |
| 145 | − merged.set(tool.path, tool); | |
| 146 | − } | |
| 147 | − | |
| 148 | − const catalogTools = createCatalogTools([...merged.values()]); | |
| 149 | − for (const tool of catalogTools) { | |
| 150 | − merged.set(tool.path, tool); | |
| 151 | − } | |
| 152 | − | |
| 153 | − const discover = createDiscoverTool([...merged.values()]); | |
| 154 | − merged.set(discover.path, discover); | |
| 146 | + const merged = mergeToolsWithCatalog(externalTools); | |
| 155 | 147 | |
| 156 | 148 | let dtsStorageIds: DtsStorageEntry[] = []; |
| 157 | 149 | try { |
| @@ -18,37 +18,39 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 17 unmodified lines | ||
| 18 | 18 | workspaceId: Id<"workspaces">; |
| 19 | 19 | actorId: string; |
| 20 | 20 | clientId?: string; |
| 21 | − }) => { | |
| 21 | + }): Promise<{ task: TaskRecord }> => { | |
| 22 | 22 | const taskInput = { |
| 23 | 23 | ...input, |
| 24 | 24 | scheduleAfterCreate: false, |
| 25 | − } as Parameters<typeof ctx.runMutation<typeof internal.executor.createTaskInternal>>[1]; | |
| 26 | − return (await ctx.runMutation(internal.executor.createTaskInternal, taskInput)) as { task: TaskRecord }; | |
| 25 | + }; | |
| 26 | + return await ctx.runMutation(internal.executor.createTaskInternal, taskInput); | |
| 27 | 27 | }, |
| 28 | − runTaskNow: async (taskId: string) => { | |
| 29 | − return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null; | |
| 28 | + runTaskNow: async (taskId: string): Promise<null> => { | |
| 29 | + return await ctx.runAction(internal.executorNode.runTask, { taskId }); | |
| 30 | 30 | }, |
| 31 | − getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => { | |
| 31 | + getTask: async (taskId: string, workspaceId?: Id<"workspaces">): Promise<TaskRecord | null> => { | |
| 32 | 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 | 35 | return null; |
| 36 | 36 | }, |
| 37 | 37 | subscribe: () => { |
| 38 | 38 | return () => {}; |
| 39 | 39 | }, |
| 40 | − bootstrapAnonymousContext: async (sessionId?: string) => { | |
| 41 | − return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext; | |
| 40 | + bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => { | |
| 41 | + return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId }); | |
| 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 | 46 | if (!toolContext) { |
| 45 | 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">) => { | |
| 51 | − return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[]; | |
| 52 | + listPendingApprovals: async (workspaceId: Id<"workspaces">): Promise<PendingApprovalRecord[]> => { | |
| 53 | + return await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId }); | |
| 52 | 54 | }, |
| 53 | 55 | resolveApproval: async (input: { |
| 54 | 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.