History

Allow guest session bootstrap and source removal

Rhys Sullivan committed 9 months ago

Commit 34a8eeb1 parent 4f58fef6 files · +132 −59Browse files

Unified · Split

6 files
  1. executor/apps/web/src/components/tools/add/source-dialog.tsx +47 −2
  2. executor/apps/web/src/components/tools/explorer-groups.tsx +3 −0
  3. executor/apps/web/src/components/tools/explorer.tsx +3 −0
  4. executor/apps/web/src/components/tools/view.tsx +6 −1
  5. executor/packages/convex/workspace.ts +13 −19
  6. executor/scripts/e2e/install.sandbox.e2e.test.ts +60 −37
executor/apps/web/src/components/tools/add/source-dialog.tsx+47−2
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { useState, type ReactNode } from "react";
4−import { AlertTriangle, Plus } from "lucide-react";
4+import { AlertTriangle, Plus, Trash2 } from "lucide-react";
55 import { useAction, useMutation, useQuery } from "convex/react";
66 import { Result } from "better-result";
77 import { toast } from "sonner";
@@ -82,6 +82,7 @@ function normalizeEndpoint(value: string): string { · 74 unmodified lines
8282 export function AddSourceDialog({
8383 existingSourceNames,
8484 onSourceAdded,
85+ onSourceDeleted,
8586 sourceToEdit,
8687 sourceDialogMeta,
8788 sourceAuthProfiles,
@@ -89,6 +90,7 @@ export function AddSourceDialog({ · 1 unmodified line
8990 }: {
9091 existingSourceNames: Set<string>;
9192 onSourceAdded?: (source: ToolSourceRecord, options?: { connected?: boolean }) => void;
93+ onSourceDeleted?: (sourceName: string) => void;
9294 sourceToEdit?: ToolSourceRecord;
9395 sourceDialogMeta?: SourceDialogMeta;
9496 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
@@ -96,6 +98,7 @@ export function AddSourceDialog({ · 1 unmodified line
9698 }) {
9799 const { context } = useSession();
98100 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
101+ const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
99102 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
100103 const credentials = useQuery(
101104 convexApi.workspace.listCredentials,
@@ -106,6 +109,7 @@ export function AddSourceDialog({ · 4 unmodified lines
106109 const [open, setOpen] = useState(false);
107110 const [submitting, setSubmitting] = useState(false);
108111 const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);
112+ const [deleting, setDeleting] = useState(false);
109113
110114 const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";
111115 const sourceDialogDescription = sourceToEdit
@@ -256,6 +260,33 @@ export function AddSourceDialog({ · 144 unmodified lines
256260 ? "Add Source + Save Credentials"
257261 : "Add Source";
258262
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+
259290 return (
260291 <Dialog open={open} onOpenChange={handleOpenChange}>
261292 <DialogTrigger asChild>
@@ -338,13 +369,27 @@ export function AddSourceDialog({ · 76 unmodified lines
338369
339370 {editSourceWarnings.length > 0 ? (
340371 <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) => (
342373 <p key={`${sourceToEdit.name}-warning-${index}`} className="text-[10px] text-terminal-amber/90">
343374 {warning}
344375 </p>
345376 ))}
346377 </div>
347378 ) : 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>
348393 </div>
349394 </div>
350395 ) : null}
executor/apps/web/src/components/tools/explorer-groups.tsx+3−0
@@ -230,6 +230,7 @@ export function SourceSidebar({ · 229 unmodified lines
230230 sourceDialogMeta,
231231 existingSourceNames,
232232 sourceAuthProfiles,
233+ onSourceDeleted,
233234 }: {
234235 sources: ToolSourceRecord[];
235236 sourceCounts: Record<string, number>;
@@ -240,6 +241,7 @@ export function SourceSidebar({ · 4 unmodified lines
240241 sourceDialogMeta?: Record<string, SourceDialogMeta>;
241242 existingSourceNames: Set<string>;
242243 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
244+ onSourceDeleted?: (sourceName: string) => void;
243245 }) {
244246 const warningCountsBySource = useMemo(() => {
245247 const counts: Record<string, number> = {};
@@ -349,6 +351,7 @@ export function SourceSidebar({ · 103 unmodified lines
349351 sourceToEdit={g.source}
350352 sourceDialogMeta={editMeta}
351353 sourceAuthProfiles={sourceAuthProfiles}
354+ onSourceDeleted={onSourceDeleted}
352355 trigger={
353356 <Button
354357 variant="ghost"
executor/apps/web/src/components/tools/explorer.tsx+3−0
@@ -50,6 +50,7 @@ interface ToolExplorerProps { · 49 unmodified lines
5050 sourceDialogMeta?: Record<string, SourceDialogMeta>;
5151 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
5252 existingSourceNames?: Set<string>;
53+ onSourceDeleted?: (sourceName: string) => void;
5354 }
5455
5556 export function ToolExplorer({
@@ -67,6 +68,7 @@ export function ToolExplorer({ · 11 unmodified lines
6768 sourceDialogMeta,
6869 sourceAuthProfiles,
6970 existingSourceNames,
71+ onSourceDeleted,
7072 }: ToolExplorerProps) {
7173 const [searchInput, setSearchInput] = useState("");
7274 const search = useDeferredValue(searchInput);
@@ -390,6 +392,7 @@ export function ToolExplorer({ · 317 unmodified lines
390392 sourceDialogMeta={sourceDialogMeta}
391393 sourceAuthProfiles={sourceAuthProfiles}
392394 existingSourceNames={sidebarExistingSourceNames}
395+ onSourceDeleted={onSourceDeleted}
393396 />
394397 ) : null}
395398
executor/apps/web/src/components/tools/view.tsx+6−1
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useMemo, useState } from "react";
3+import { useCallback, useMemo, useState } from "react";
44 import {
55 Plus,
66 } from "lucide-react";
@@ -103,6 +103,10 @@ export function ToolsView({ · 96 unmodified lines
103103 && (sourceItems.some((source) => source.name === selectedSource) || toolSourceNames.has(selectedSource))
104104 ? selectedSource
105105 : null;
106+
107+ const handleSourceDeleted = useCallback((sourceName: string) => {
108+ setSelectedSource((current) => (current === sourceName ? null : current));
109+ }, []);
106110 const openConnectionCreate = (sourceKey?: string) => {
107111 setConnectionDialogEditing(null);
108112 setConnectionDialogSourceKey(sourceKey ?? null);
@@ -196,6 +200,7 @@ export function ToolsView({ · 87 unmodified lines
196200 sourceDialogMeta={sourceDialogMeta}
197201 sourceAuthProfiles={sourceAuthProfiles}
198202 existingSourceNames={existingSourceNames}
203+ onSourceDeleted={handleSourceDeleted}
199204 onLoadToolDetails={loadToolDetails}
200205 warnings={warnings}
201206 initialSource={initialSource}
executor/packages/convex/workspace.ts+13−19
@@ -23,34 +23,28 @@ export const bootstrapAnonymousSession = mutation({ · 22 unmodified lines
2323 actorId: v.optional(v.string()),
2424 },
2525 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;
3029
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");
3332 }
3433
35− const actorId = isAnonymousIdentityRequest
36− ? identity!.subject
37− : requestedActorId;
38−
34+ const actorId = identity ? identity.subject : requestedActorId;
3935 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_*)");
4537 }
4638
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}`)
4943 : requestedSessionId;
5044
5145 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {
52− sessionId,
53− actorId,
46+ ...(sessionId ? { sessionId } : {}),
47+ ...(actorId ? { actorId } : {}),
5448 clientId: "web",
5549 });
5650 },
executor/scripts/e2e/install.sandbox.e2e.test.ts+60−37
@@ -35,6 +35,16 @@ async function runSandboxBash( · 34 unmodified lines
3535 }
3636 }
3737
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+
3848 function assertSuccess(result: CommandResult, label: string): void {
3949 if (result.exitCode === 0) {
4050 return;
@@ -59,52 +69,65 @@ test("installer works in fresh Vercel sandbox", async () => { · 18 unmodified lines
5969 }
6070 : {};
6171
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+ };
6386
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();
7688
7789 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+ }
93118 assertSuccess(install, "sandbox install + doctor");
94119
95120 const output = `${install.stdout}\n${install.stderr}`;
96121 expect(output).toContain("Executor status: ready");
97122 expect(output).toContain("Functions: bootstrapped");
98123
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);
108131 assertSuccess(uninstall, "sandbox uninstall validation");
109132 } finally {
110133 await sandbox.stop();

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t