Unified · Split
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useState, type ReactNode } from "react"; |
| 4 | −import { AlertTriangle, Plus } from "lucide-react"; | |
| 4 | +import { AlertTriangle, Plus, Trash2 } from "lucide-react"; | |
| 5 | 5 | import { useAction, useMutation, useQuery } from "convex/react"; |
| 6 | 6 | import { Result } from "better-result"; |
| 7 | 7 | import { toast } from "sonner"; |
| @@ -82,6 +82,7 @@ function normalizeEndpoint(value: string): string { · 74 unmodified lines | ||
| 82 | 82 | export function AddSourceDialog({ |
| 83 | 83 | existingSourceNames, |
| 84 | 84 | onSourceAdded, |
| 85 | + onSourceDeleted, | |
| 85 | 86 | sourceToEdit, |
| 86 | 87 | sourceDialogMeta, |
| 87 | 88 | sourceAuthProfiles, |
| @@ -89,6 +90,7 @@ export function AddSourceDialog({ · 1 unmodified line | ||
| 89 | 90 | }: { |
| 90 | 91 | existingSourceNames: Set<string>; |
| 91 | 92 | onSourceAdded?: (source: ToolSourceRecord, options?: { connected?: boolean }) => void; |
| 93 | + onSourceDeleted?: (sourceName: string) => void; | |
| 92 | 94 | sourceToEdit?: ToolSourceRecord; |
| 93 | 95 | sourceDialogMeta?: SourceDialogMeta; |
| 94 | 96 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; |
| @@ -96,6 +98,7 @@ export function AddSourceDialog({ · 1 unmodified line | ||
| 96 | 98 | }) { |
| 97 | 99 | const { context } = useSession(); |
| 98 | 100 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); |
| 101 | + const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); | |
| 99 | 102 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); |
| 100 | 103 | const credentials = useQuery( |
| 101 | 104 | convexApi.workspace.listCredentials, |
| @@ -106,6 +109,7 @@ export function AddSourceDialog({ · 4 unmodified lines | ||
| 106 | 109 | const [open, setOpen] = useState(false); |
| 107 | 110 | const [submitting, setSubmitting] = useState(false); |
| 108 | 111 | const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false); |
| 112 | + const [deleting, setDeleting] = useState(false); | |
| 109 | 113 | |
| 110 | 114 | const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source"; |
| 111 | 115 | const sourceDialogDescription = sourceToEdit |
| @@ -256,6 +260,33 @@ export function AddSourceDialog({ · 144 unmodified lines | ||
| 256 | 260 | ? "Add Source + Save Credentials" |
| 257 | 261 | : "Add Source"; |
| 258 | 262 | |
| 263 | + const handleDeleteSource = async () => { | |
| 264 | + if (!sourceToEdit || !context) { | |
| 265 | + return; | |
| 266 | + } | |
| 267 | + | |
| 268 | + const confirmed = window.confirm(`Remove source "${sourceToEdit.name}" and all related tools from this workspace?`); | |
| 269 | + if (!confirmed) { | |
| 270 | + return; | |
| 271 | + } | |
| 272 | + | |
| 273 | + setDeleting(true); | |
| 274 | + try { | |
| 275 | + await deleteToolSource({ | |
| 276 | + workspaceId: context.workspaceId, | |
| 277 | + sessionId: context.sessionId, | |
| 278 | + sourceId: sourceToEdit.id, | |
| 279 | + }); | |
| 280 | + toast.success(`Removed "${sourceToEdit.name}"`); | |
| 281 | + onSourceDeleted?.(sourceToEdit.name); | |
| 282 | + setOpen(false); | |
| 283 | + } catch (err) { | |
| 284 | + toast.error(err instanceof Error ? err.message : "Failed to remove source"); | |
| 285 | + } finally { | |
| 286 | + setDeleting(false); | |
| 287 | + } | |
| 288 | + }; | |
| 289 | + | |
| 259 | 290 | return ( |
| 260 | 291 | <Dialog open={open} onOpenChange={handleOpenChange}> |
| 261 | 292 | <DialogTrigger asChild> |
| @@ -338,13 +369,27 @@ export function AddSourceDialog({ · 76 unmodified lines | ||
| 338 | 369 | |
| 339 | 370 | {editSourceWarnings.length > 0 ? ( |
| 340 | 371 | <div className="mt-2 rounded-sm border border-terminal-amber/30 bg-terminal-amber/5 px-2 py-1.5"> |
| 341 | − {editSourceWarnings.map((warning: string, index: number) => ( | |
| 372 | + {editSourceWarnings.map((warning: string, index: number) => ( | |
| 342 | 373 | <p key={`${sourceToEdit.name}-warning-${index}`} className="text-[10px] text-terminal-amber/90"> |
| 343 | 374 | {warning} |
| 344 | 375 | </p> |
| 345 | 376 | ))} |
| 346 | 377 | </div> |
| 347 | 378 | ) : null} |
| 379 | + | |
| 380 | + <div className="mt-3 flex justify-end"> | |
| 381 | + <Button | |
| 382 | + type="button" | |
| 383 | + variant="outline" | |
| 384 | + size="sm" | |
| 385 | + className="h-7 text-[11px] border-terminal-red/40 text-terminal-red hover:bg-terminal-red/10" | |
| 386 | + onClick={handleDeleteSource} | |
| 387 | + disabled={deleting} | |
| 388 | + > | |
| 389 | + <Trash2 className="h-3.5 w-3.5 mr-1.5" /> | |
| 390 | + {deleting ? "Removing..." : "Remove source"} | |
| 391 | + </Button> | |
| 392 | + </div> | |
| 348 | 393 | </div> |
| 349 | 394 | </div> |
| 350 | 395 | ) : null} |
| @@ -230,6 +230,7 @@ export function SourceSidebar({ · 229 unmodified lines | ||
| 230 | 230 | sourceDialogMeta, |
| 231 | 231 | existingSourceNames, |
| 232 | 232 | sourceAuthProfiles, |
| 233 | + onSourceDeleted, | |
| 233 | 234 | }: { |
| 234 | 235 | sources: ToolSourceRecord[]; |
| 235 | 236 | sourceCounts: Record<string, number>; |
| @@ -240,6 +241,7 @@ export function SourceSidebar({ · 4 unmodified lines | ||
| 240 | 241 | sourceDialogMeta?: Record<string, SourceDialogMeta>; |
| 241 | 242 | existingSourceNames: Set<string>; |
| 242 | 243 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; |
| 244 | + onSourceDeleted?: (sourceName: string) => void; | |
| 243 | 245 | }) { |
| 244 | 246 | const warningCountsBySource = useMemo(() => { |
| 245 | 247 | const counts: Record<string, number> = {}; |
| @@ -349,6 +351,7 @@ export function SourceSidebar({ · 103 unmodified lines | ||
| 349 | 351 | sourceToEdit={g.source} |
| 350 | 352 | sourceDialogMeta={editMeta} |
| 351 | 353 | sourceAuthProfiles={sourceAuthProfiles} |
| 354 | + onSourceDeleted={onSourceDeleted} | |
| 352 | 355 | trigger={ |
| 353 | 356 | <Button |
| 354 | 357 | variant="ghost" |
| @@ -50,6 +50,7 @@ interface ToolExplorerProps { · 49 unmodified lines | ||
| 50 | 50 | sourceDialogMeta?: Record<string, SourceDialogMeta>; |
| 51 | 51 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; |
| 52 | 52 | existingSourceNames?: Set<string>; |
| 53 | + onSourceDeleted?: (sourceName: string) => void; | |
| 53 | 54 | } |
| 54 | 55 | |
| 55 | 56 | export function ToolExplorer({ |
| @@ -67,6 +68,7 @@ export function ToolExplorer({ · 11 unmodified lines | ||
| 67 | 68 | sourceDialogMeta, |
| 68 | 69 | sourceAuthProfiles, |
| 69 | 70 | existingSourceNames, |
| 71 | + onSourceDeleted, | |
| 70 | 72 | }: ToolExplorerProps) { |
| 71 | 73 | const [searchInput, setSearchInput] = useState(""); |
| 72 | 74 | const search = useDeferredValue(searchInput); |
| @@ -390,6 +392,7 @@ export function ToolExplorer({ · 317 unmodified lines | ||
| 390 | 392 | sourceDialogMeta={sourceDialogMeta} |
| 391 | 393 | sourceAuthProfiles={sourceAuthProfiles} |
| 392 | 394 | existingSourceNames={sidebarExistingSourceNames} |
| 395 | + onSourceDeleted={onSourceDeleted} | |
| 393 | 396 | /> |
| 394 | 397 | ) : null} |
| 395 | 398 | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useMemo, useState } from "react"; | |
| 3 | +import { useCallback, useMemo, useState } from "react"; | |
| 4 | 4 | import { |
| 5 | 5 | Plus, |
| 6 | 6 | } from "lucide-react"; |
| @@ -103,6 +103,10 @@ export function ToolsView({ · 96 unmodified lines | ||
| 103 | 103 | && (sourceItems.some((source) => source.name === selectedSource) || toolSourceNames.has(selectedSource)) |
| 104 | 104 | ? selectedSource |
| 105 | 105 | : null; |
| 106 | + | |
| 107 | + const handleSourceDeleted = useCallback((sourceName: string) => { | |
| 108 | + setSelectedSource((current) => (current === sourceName ? null : current)); | |
| 109 | + }, []); | |
| 106 | 110 | const openConnectionCreate = (sourceKey?: string) => { |
| 107 | 111 | setConnectionDialogEditing(null); |
| 108 | 112 | setConnectionDialogSourceKey(sourceKey ?? null); |
| @@ -196,6 +200,7 @@ export function ToolsView({ · 87 unmodified lines | ||
| 196 | 200 | sourceDialogMeta={sourceDialogMeta} |
| 197 | 201 | sourceAuthProfiles={sourceAuthProfiles} |
| 198 | 202 | existingSourceNames={existingSourceNames} |
| 203 | + onSourceDeleted={handleSourceDeleted} | |
| 199 | 204 | onLoadToolDetails={loadToolDetails} |
| 200 | 205 | warnings={warnings} |
| 201 | 206 | initialSource={initialSource} |
| @@ -23,34 +23,28 @@ export const bootstrapAnonymousSession = mutation({ · 22 unmodified lines | ||
| 23 | 23 | actorId: v.optional(v.string()), |
| 24 | 24 | }, |
| 25 | 25 | handler: async (ctx, args) => { |
| 26 | − const identity = await ctx.auth.getUserIdentity(); | |
| 27 | − const requestedSessionId = args.sessionId?.trim(); | |
| 28 | − const requestedActorId = args.actorId?.trim(); | |
| 29 | − const isAnonymousIdentityRequest = Boolean(identity && isAnonymousIdentity(identity)); | |
| 26 | + const identity = await ctx.auth.getUserIdentity().catch(() => null); | |
| 27 | + const requestedSessionId = args.sessionId?.trim() || undefined; | |
| 28 | + const requestedActorId = args.actorId?.trim() || undefined; | |
| 30 | 29 | |
| 31 | − if (!isAnonymousIdentityRequest && identity) { | |
| 32 | − throw new Error("Anonymous auth token is required"); | |
| 30 | + if (identity && !isAnonymousIdentity(identity)) { | |
| 31 | + throw new Error("Cannot bootstrap an anonymous session while authenticated"); | |
| 33 | 32 | } |
| 34 | 33 | |
| 35 | − const actorId = isAnonymousIdentityRequest | |
| 36 | − ? identity!.subject | |
| 37 | − : requestedActorId; | |
| 38 | − | |
| 34 | + const actorId = identity ? identity.subject : requestedActorId; | |
| 39 | 35 | if (actorId && !actorId.startsWith("anon_")) { |
| 40 | − throw new Error("Anonymous token subject must use anon_* actor ids"); | |
| 41 | − } | |
| 42 | − | |
| 43 | − if (!actorId && !requestedSessionId) { | |
| 44 | − throw new Error("Anonymous auth token is required"); | |
| 36 | + throw new Error("actorId must be an anonymous actor (anon_*)"); | |
| 45 | 37 | } |
| 46 | 38 | |
| 47 | − const sessionId = actorId | |
| 48 | − ? `anon_session_${actorId}` | |
| 39 | + // Allow unauthenticated callers to bootstrap a fresh anonymous session. | |
| 40 | + // The internal mutation will generate ids when not provided. | |
| 41 | + const sessionId = identity | |
| 42 | + ? (requestedSessionId ?? `anon_session_${actorId}`) | |
| 49 | 43 | : requestedSessionId; |
| 50 | 44 | |
| 51 | 45 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { |
| 52 | − sessionId, | |
| 53 | − actorId, | |
| 46 | + ...(sessionId ? { sessionId } : {}), | |
| 47 | + ...(actorId ? { actorId } : {}), | |
| 54 | 48 | clientId: "web", |
| 55 | 49 | }); |
| 56 | 50 | }, |
| @@ -35,6 +35,16 @@ async function runSandboxBash( · 34 unmodified lines | ||
| 35 | 35 | } |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | +function isSandboxStoppedError(error: unknown): boolean { | |
| 39 | + if (!error) return false; | |
| 40 | + const anyErr = error as any; | |
| 41 | + const status = typeof anyErr?.response?.status === "number" ? anyErr.response.status : null; | |
| 42 | + if (status === 410) return true; | |
| 43 | + | |
| 44 | + const message = error instanceof Error ? error.message : String(error); | |
| 45 | + return message.includes("sandbox_stopped") || message.includes("Status code 410"); | |
| 46 | +} | |
| 47 | + | |
| 38 | 48 | function assertSuccess(result: CommandResult, label: string): void { |
| 39 | 49 | if (result.exitCode === 0) { |
| 40 | 50 | return; |
| @@ -59,52 +69,65 @@ test("installer works in fresh Vercel sandbox", async () => { · 18 unmodified lines | ||
| 59 | 69 | } |
| 60 | 70 | : {}; |
| 61 | 71 | |
| 62 | − let sandbox: Sandbox | null = null; | |
| 72 | + const createSandbox = async () => { | |
| 73 | + try { | |
| 74 | + return await Sandbox.create({ | |
| 75 | + runtime: "node22", | |
| 76 | + timeout: 30 * 60 * 1000, | |
| 77 | + ...credentials, | |
| 78 | + }); | |
| 79 | + } catch (error) { | |
| 80 | + const message = error instanceof Error ? error.message : String(error); | |
| 81 | + throw new Error( | |
| 82 | + `Could not create Vercel sandbox. Configure auth via VERCEL_TOKEN/VERCEL_TEAM_ID/VERCEL_PROJECT_ID or VERCEL_OIDC_TOKEN.\n\n${message}`, | |
| 83 | + ); | |
| 84 | + } | |
| 85 | + }; | |
| 63 | 86 | |
| 64 | − try { | |
| 65 | − sandbox = await Sandbox.create({ | |
| 66 | − runtime: "node22", | |
| 67 | − timeout: 30 * 60 * 1000, | |
| 68 | − ...credentials, | |
| 69 | − }); | |
| 70 | − } catch (error) { | |
| 71 | − const message = error instanceof Error ? error.message : String(error); | |
| 72 | − throw new Error( | |
| 73 | − `Could not create Vercel sandbox. Configure auth via VERCEL_TOKEN/VERCEL_TEAM_ID/VERCEL_PROJECT_ID or VERCEL_OIDC_TOKEN.\n\n${message}`, | |
| 74 | − ); | |
| 75 | − } | |
| 87 | + let sandbox: Sandbox | null = await createSandbox(); | |
| 76 | 88 | |
| 77 | 89 | try { |
| 78 | − const install = await runSandboxBash( | |
| 79 | − sandbox, | |
| 80 | − [ | |
| 81 | − "set -euo pipefail", | |
| 82 | − "cd ~", | |
| 83 | − "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi", | |
| 84 | − "rm -rf ~/.executor", | |
| 85 | − "start=$(date +%s)", | |
| 86 | − "curl -fsSL https://executor.sh/install | bash", | |
| 87 | − "end=$(date +%s)", | |
| 88 | − "echo INSTALL_SECONDS=$((end-start))", | |
| 89 | − "~/.executor/bin/executor doctor --verbose", | |
| 90 | − ].join("; "), | |
| 91 | − 900_000, | |
| 92 | − ); | |
| 90 | + const installScript = [ | |
| 91 | + "set -euo pipefail", | |
| 92 | + "cd ~", | |
| 93 | + "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi", | |
| 94 | + "rm -rf ~/.executor", | |
| 95 | + "start=$(date +%s)", | |
| 96 | + "curl -fsSL https://executor.sh/install | bash", | |
| 97 | + "end=$(date +%s)", | |
| 98 | + "echo INSTALL_SECONDS=$((end-start))", | |
| 99 | + "~/.executor/bin/executor doctor --verbose", | |
| 100 | + ].join("; "); | |
| 101 | + | |
| 102 | + let install: CommandResult; | |
| 103 | + try { | |
| 104 | + install = await runSandboxBash(sandbox, installScript, 900_000); | |
| 105 | + } catch (error) { | |
| 106 | + if (!isSandboxStoppedError(error)) { | |
| 107 | + throw error; | |
| 108 | + } | |
| 109 | + // Vercel sandboxes can stop unexpectedly; recreate once and retry. | |
| 110 | + try { | |
| 111 | + await sandbox.stop(); | |
| 112 | + } catch { | |
| 113 | + // ignore | |
| 114 | + } | |
| 115 | + sandbox = await createSandbox(); | |
| 116 | + install = await runSandboxBash(sandbox, installScript, 900_000); | |
| 117 | + } | |
| 93 | 118 | assertSuccess(install, "sandbox install + doctor"); |
| 94 | 119 | |
| 95 | 120 | const output = `${install.stdout}\n${install.stderr}`; |
| 96 | 121 | expect(output).toContain("Executor status: ready"); |
| 97 | 122 | expect(output).toContain("Functions: bootstrapped"); |
| 98 | 123 | |
| 99 | − const uninstall = await runSandboxBash( | |
| 100 | − sandbox, | |
| 101 | − [ | |
| 102 | − "set -euo pipefail", | |
| 103 | − "~/.executor/bin/executor uninstall --yes", | |
| 104 | − "test ! -e ~/.executor/runtime/convex-data/convex_local_backend.sqlite3", | |
| 105 | − ].join("; "), | |
| 106 | − 300_000, | |
| 107 | − ); | |
| 124 | + const uninstallScript = [ | |
| 125 | + "set -euo pipefail", | |
| 126 | + "~/.executor/bin/executor uninstall --yes", | |
| 127 | + "test ! -e ~/.executor/runtime/convex-data/convex_local_backend.sqlite3", | |
| 128 | + ].join("; "); | |
| 129 | + | |
| 130 | + const uninstall = await runSandboxBash(sandbox, uninstallScript, 300_000); | |
| 108 | 131 | assertSuccess(uninstall, "sandbox uninstall validation"); |
| 109 | 132 | } finally { |
| 110 | 133 | await sandbox.stop(); |