Unified · Split
| @@ -1,7 +1,7 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useState, type ReactNode } from "react"; | 3 | import { useState, type ReactNode } from "react"; |
| 4 | import { AlertTriangle, Plus } from "lucide-react"; | 4 | import { AlertTriangle, Plus, Trash2 } from "lucide-react"; |
| 5 | import { useAction, useMutation, useQuery } from "convex/react"; | 5 | import { useAction, useMutation, useQuery } from "convex/react"; |
| 6 | import { Result } from "better-result"; | 6 | import { Result } from "better-result"; |
| 7 | import { toast } from "sonner"; | 7 | import { toast } from "sonner"; |
| @@ -82,6 +82,7 @@ function normalizeEndpoint(value: string): string { · 74 unmodified lines | |||
| 82 | export function AddSourceDialog({ | 82 | export function AddSourceDialog({ |
| 83 | existingSourceNames, | 83 | existingSourceNames, |
| 84 | onSourceAdded, | 84 | onSourceAdded, |
| 85 | onSourceDeleted, | ||
| 85 | sourceToEdit, | 86 | sourceToEdit, |
| 86 | sourceDialogMeta, | 87 | sourceDialogMeta, |
| 87 | sourceAuthProfiles, | 88 | sourceAuthProfiles, |
| @@ -89,6 +90,7 @@ export function AddSourceDialog({ · 1 unmodified line | |||
| 89 | }: { | 90 | }: { |
| 90 | existingSourceNames: Set<string>; | 91 | existingSourceNames: Set<string>; |
| 91 | onSourceAdded?: (source: ToolSourceRecord, options?: { connected?: boolean }) => void; | 92 | onSourceAdded?: (source: ToolSourceRecord, options?: { connected?: boolean }) => void; |
| 93 | onSourceDeleted?: (sourceName: string) => void; | ||
| 92 | sourceToEdit?: ToolSourceRecord; | 94 | sourceToEdit?: ToolSourceRecord; |
| 93 | sourceDialogMeta?: SourceDialogMeta; | 95 | sourceDialogMeta?: SourceDialogMeta; |
| 94 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; | 96 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; |
| @@ -96,6 +98,7 @@ export function AddSourceDialog({ · 1 unmodified line | |||
| 96 | }) { | 98 | }) { |
| 97 | const { context } = useSession(); | 99 | const { context } = useSession(); |
| 98 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | 100 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); |
| 101 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); | ||
| 99 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | 102 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); |
| 100 | const credentials = useQuery( | 103 | const credentials = useQuery( |
| 101 | convexApi.workspace.listCredentials, | 104 | convexApi.workspace.listCredentials, |
| @@ -106,6 +109,7 @@ export function AddSourceDialog({ · 4 unmodified lines | |||
| 106 | const [open, setOpen] = useState(false); | 109 | const [open, setOpen] = useState(false); |
| 107 | const [submitting, setSubmitting] = useState(false); | 110 | const [submitting, setSubmitting] = useState(false); |
| 108 | const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false); | 111 | const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false); |
| 112 | const [deleting, setDeleting] = useState(false); | ||
| 109 | 113 | ||
| 110 | const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source"; | 114 | const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source"; |
| 111 | const sourceDialogDescription = sourceToEdit | 115 | const sourceDialogDescription = sourceToEdit |
| @@ -256,6 +260,33 @@ export function AddSourceDialog({ · 144 unmodified lines | |||
| 256 | ? "Add Source + Save Credentials" | 260 | ? "Add Source + Save Credentials" |
| 257 | : "Add Source"; | 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 | return ( | 290 | return ( |
| 260 | <Dialog open={open} onOpenChange={handleOpenChange}> | 291 | <Dialog open={open} onOpenChange={handleOpenChange}> |
| 261 | <DialogTrigger asChild> | 292 | <DialogTrigger asChild> |
| @@ -338,13 +369,27 @@ export function AddSourceDialog({ · 76 unmodified lines | |||
| 338 | 369 | ||
| 339 | {editSourceWarnings.length > 0 ? ( | 370 | {editSourceWarnings.length > 0 ? ( |
| 340 | <div className="mt-2 rounded-sm border border-terminal-amber/30 bg-terminal-amber/5 px-2 py-1.5"> | 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 | <p key={`${sourceToEdit.name}-warning-${index}`} className="text-[10px] text-terminal-amber/90"> | 373 | <p key={`${sourceToEdit.name}-warning-${index}`} className="text-[10px] text-terminal-amber/90"> |
| 343 | {warning} | 374 | {warning} |
| 344 | </p> | 375 | </p> |
| 345 | ))} | 376 | ))} |
| 346 | </div> | 377 | </div> |
| 347 | ) : null} | 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 | </div> | 393 | </div> |
| 349 | </div> | 394 | </div> |
| 350 | ) : null} | 395 | ) : null} |
| @@ -230,6 +230,7 @@ export function SourceSidebar({ · 229 unmodified lines | |||
| 230 | sourceDialogMeta, | 230 | sourceDialogMeta, |
| 231 | existingSourceNames, | 231 | existingSourceNames, |
| 232 | sourceAuthProfiles, | 232 | sourceAuthProfiles, |
| 233 | onSourceDeleted, | ||
| 233 | }: { | 234 | }: { |
| 234 | sources: ToolSourceRecord[]; | 235 | sources: ToolSourceRecord[]; |
| 235 | sourceCounts: Record<string, number>; | 236 | sourceCounts: Record<string, number>; |
| @@ -240,6 +241,7 @@ export function SourceSidebar({ · 4 unmodified lines | |||
| 240 | sourceDialogMeta?: Record<string, SourceDialogMeta>; | 241 | sourceDialogMeta?: Record<string, SourceDialogMeta>; |
| 241 | existingSourceNames: Set<string>; | 242 | existingSourceNames: Set<string>; |
| 242 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; | 243 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; |
| 244 | onSourceDeleted?: (sourceName: string) => void; | ||
| 243 | }) { | 245 | }) { |
| 244 | const warningCountsBySource = useMemo(() => { | 246 | const warningCountsBySource = useMemo(() => { |
| 245 | const counts: Record<string, number> = {}; | 247 | const counts: Record<string, number> = {}; |
| @@ -349,6 +351,7 @@ export function SourceSidebar({ · 103 unmodified lines | |||
| 349 | sourceToEdit={g.source} | 351 | sourceToEdit={g.source} |
| 350 | sourceDialogMeta={editMeta} | 352 | sourceDialogMeta={editMeta} |
| 351 | sourceAuthProfiles={sourceAuthProfiles} | 353 | sourceAuthProfiles={sourceAuthProfiles} |
| 354 | onSourceDeleted={onSourceDeleted} | ||
| 352 | trigger={ | 355 | trigger={ |
| 353 | <Button | 356 | <Button |
| 354 | variant="ghost" | 357 | variant="ghost" |
| @@ -50,6 +50,7 @@ interface ToolExplorerProps { · 49 unmodified lines | |||
| 50 | sourceDialogMeta?: Record<string, SourceDialogMeta>; | 50 | sourceDialogMeta?: Record<string, SourceDialogMeta>; |
| 51 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; | 51 | sourceAuthProfiles?: Record<string, SourceAuthProfile>; |
| 52 | existingSourceNames?: Set<string>; | 52 | existingSourceNames?: Set<string>; |
| 53 | onSourceDeleted?: (sourceName: string) => void; | ||
| 53 | } | 54 | } |
| 54 | 55 | ||
| 55 | export function ToolExplorer({ | 56 | export function ToolExplorer({ |
| @@ -67,6 +68,7 @@ export function ToolExplorer({ · 11 unmodified lines | |||
| 67 | sourceDialogMeta, | 68 | sourceDialogMeta, |
| 68 | sourceAuthProfiles, | 69 | sourceAuthProfiles, |
| 69 | existingSourceNames, | 70 | existingSourceNames, |
| 71 | onSourceDeleted, | ||
| 70 | }: ToolExplorerProps) { | 72 | }: ToolExplorerProps) { |
| 71 | const [searchInput, setSearchInput] = useState(""); | 73 | const [searchInput, setSearchInput] = useState(""); |
| 72 | const search = useDeferredValue(searchInput); | 74 | const search = useDeferredValue(searchInput); |
| @@ -390,6 +392,7 @@ export function ToolExplorer({ · 317 unmodified lines | |||
| 390 | sourceDialogMeta={sourceDialogMeta} | 392 | sourceDialogMeta={sourceDialogMeta} |
| 391 | sourceAuthProfiles={sourceAuthProfiles} | 393 | sourceAuthProfiles={sourceAuthProfiles} |
| 392 | existingSourceNames={sidebarExistingSourceNames} | 394 | existingSourceNames={sidebarExistingSourceNames} |
| 395 | onSourceDeleted={onSourceDeleted} | ||
| 393 | /> | 396 | /> |
| 394 | ) : null} | 397 | ) : null} |
| 395 | 398 | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useMemo, useState } from "react"; | 3 | import { useCallback, useMemo, useState } from "react"; |
| 4 | import { | 4 | import { |
| 5 | Plus, | 5 | Plus, |
| 6 | } from "lucide-react"; | 6 | } from "lucide-react"; |
| @@ -103,6 +103,10 @@ export function ToolsView({ · 96 unmodified lines | |||
| 103 | && (sourceItems.some((source) => source.name === selectedSource) || toolSourceNames.has(selectedSource)) | 103 | && (sourceItems.some((source) => source.name === selectedSource) || toolSourceNames.has(selectedSource)) |
| 104 | ? selectedSource | 104 | ? selectedSource |
| 105 | : null; | 105 | : null; |
| 106 | |||
| 107 | const handleSourceDeleted = useCallback((sourceName: string) => { | ||
| 108 | setSelectedSource((current) => (current === sourceName ? null : current)); | ||
| 109 | }, []); | ||
| 106 | const openConnectionCreate = (sourceKey?: string) => { | 110 | const openConnectionCreate = (sourceKey?: string) => { |
| 107 | setConnectionDialogEditing(null); | 111 | setConnectionDialogEditing(null); |
| 108 | setConnectionDialogSourceKey(sourceKey ?? null); | 112 | setConnectionDialogSourceKey(sourceKey ?? null); |
| @@ -196,6 +200,7 @@ export function ToolsView({ · 87 unmodified lines | |||
| 196 | sourceDialogMeta={sourceDialogMeta} | 200 | sourceDialogMeta={sourceDialogMeta} |
| 197 | sourceAuthProfiles={sourceAuthProfiles} | 201 | sourceAuthProfiles={sourceAuthProfiles} |
| 198 | existingSourceNames={existingSourceNames} | 202 | existingSourceNames={existingSourceNames} |
| 203 | onSourceDeleted={handleSourceDeleted} | ||
| 199 | onLoadToolDetails={loadToolDetails} | 204 | onLoadToolDetails={loadToolDetails} |
| 200 | warnings={warnings} | 205 | warnings={warnings} |
| 201 | initialSource={initialSource} | 206 | initialSource={initialSource} |
| @@ -23,34 +23,28 @@ export const bootstrapAnonymousSession = mutation({ · 22 unmodified lines | |||
| 23 | actorId: v.optional(v.string()), | 23 | actorId: v.optional(v.string()), |
| 24 | }, | 24 | }, |
| 25 | handler: async (ctx, args) => { | 25 | handler: async (ctx, args) => { |
| 26 | const identity = await ctx.auth.getUserIdentity(); | 26 | const identity = await ctx.auth.getUserIdentity().catch(() => null); |
| 27 | const requestedSessionId = args.sessionId?.trim(); | 27 | const requestedSessionId = args.sessionId?.trim() || undefined; |
| 28 | const requestedActorId = args.actorId?.trim(); | 28 | const requestedActorId = args.actorId?.trim() || undefined; |
| 29 | const isAnonymousIdentityRequest = Boolean(identity && isAnonymousIdentity(identity)); | ||
| 30 | 29 | ||
| 31 | if (!isAnonymousIdentityRequest && identity) { | 30 | if (identity && !isAnonymousIdentity(identity)) { |
| 32 | throw new Error("Anonymous auth token is required"); | 31 | throw new Error("Cannot bootstrap an anonymous session while authenticated"); |
| 33 | } | 32 | } |
| 34 | 33 | ||
| 35 | const actorId = isAnonymousIdentityRequest | 34 | const actorId = identity ? identity.subject : requestedActorId; |
| 36 | ? identity!.subject | ||
| 37 | : requestedActorId; | ||
| 38 | |||
| 39 | if (actorId && !actorId.startsWith("anon_")) { | 35 | if (actorId && !actorId.startsWith("anon_")) { |
| 40 | throw new Error("Anonymous token subject must use anon_* actor ids"); | 36 | throw new Error("actorId must be an anonymous actor (anon_*)"); |
| 41 | } | ||
| 42 | |||
| 43 | if (!actorId && !requestedSessionId) { | ||
| 44 | throw new Error("Anonymous auth token is required"); | ||
| 45 | } | 37 | } |
| 46 | 38 | ||
| 47 | const sessionId = actorId | 39 | // Allow unauthenticated callers to bootstrap a fresh anonymous session. |
| 48 | ? `anon_session_${actorId}` | 40 | // The internal mutation will generate ids when not provided. |
| 41 | const sessionId = identity | ||
| 42 | ? (requestedSessionId ?? `anon_session_${actorId}`) | ||
| 49 | : requestedSessionId; | 43 | : requestedSessionId; |
| 50 | 44 | ||
| 51 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { | 45 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { |
| 52 | sessionId, | 46 | ...(sessionId ? { sessionId } : {}), |
| 53 | actorId, | 47 | ...(actorId ? { actorId } : {}), |
| 54 | clientId: "web", | 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 | function assertSuccess(result: CommandResult, label: string): void { | 48 | function assertSuccess(result: CommandResult, label: string): void { |
| 39 | if (result.exitCode === 0) { | 49 | if (result.exitCode === 0) { |
| 40 | return; | 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 { | 87 | let sandbox: Sandbox | null = await createSandbox(); |
| 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 | } | ||
| 76 | 88 | ||
| 77 | try { | 89 | try { |
| 78 | const install = await runSandboxBash( | 90 | const installScript = [ |
| 79 | sandbox, | 91 | "set -euo pipefail", |
| 80 | [ | 92 | "cd ~", |
| 81 | "set -euo pipefail", | 93 | "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi", |
| 82 | "cd ~", | 94 | "rm -rf ~/.executor", |
| 83 | "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi", | 95 | "start=$(date +%s)", |
| 84 | "rm -rf ~/.executor", | 96 | "curl -fsSL https://executor.sh/install | bash", |
| 85 | "start=$(date +%s)", | 97 | "end=$(date +%s)", |
| 86 | "curl -fsSL https://executor.sh/install | bash", | 98 | "echo INSTALL_SECONDS=$((end-start))", |
| 87 | "end=$(date +%s)", | 99 | "~/.executor/bin/executor doctor --verbose", |
| 88 | "echo INSTALL_SECONDS=$((end-start))", | 100 | ].join("; "); |
| 89 | "~/.executor/bin/executor doctor --verbose", | 101 | |
| 90 | ].join("; "), | 102 | let install: CommandResult; |
| 91 | 900_000, | 103 | try { |
| 92 | ); | 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 | assertSuccess(install, "sandbox install + doctor"); | 118 | assertSuccess(install, "sandbox install + doctor"); |
| 94 | 119 | ||
| 95 | const output = `${install.stdout}\n${install.stderr}`; | 120 | const output = `${install.stdout}\n${install.stderr}`; |
| 96 | expect(output).toContain("Executor status: ready"); | 121 | expect(output).toContain("Executor status: ready"); |
| 97 | expect(output).toContain("Functions: bootstrapped"); | 122 | expect(output).toContain("Functions: bootstrapped"); |
| 98 | 123 | ||
| 99 | const uninstall = await runSandboxBash( | 124 | const uninstallScript = [ |
| 100 | sandbox, | 125 | "set -euo pipefail", |
| 101 | [ | 126 | "~/.executor/bin/executor uninstall --yes", |
| 102 | "set -euo pipefail", | 127 | "test ! -e ~/.executor/runtime/convex-data/convex_local_backend.sqlite3", |
| 103 | "~/.executor/bin/executor uninstall --yes", | 128 | ].join("; "); |
| 104 | "test ! -e ~/.executor/runtime/convex-data/convex_local_backend.sqlite3", | 129 | |
| 105 | ].join("; "), | 130 | const uninstall = await runSandboxBash(sandbox, uninstallScript, 300_000); |
| 106 | 300_000, | ||
| 107 | ); | ||
| 108 | assertSuccess(uninstall, "sandbox uninstall validation"); | 131 | assertSuccess(uninstall, "sandbox uninstall validation"); |
| 109 | } finally { | 132 | } finally { |
| 110 | await sandbox.stop(); | 133 | await sandbox.stop(); |