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 @@
1"use client";1"use client";
2 2
3import { useState, type ReactNode } from "react";3import { useState, type ReactNode } from "react";
4import { AlertTriangle, Plus } from "lucide-react";4import { AlertTriangle, Plus, Trash2 } from "lucide-react";
5import { useAction, useMutation, useQuery } from "convex/react";5import { useAction, useMutation, useQuery } from "convex/react";
6import { Result } from "better-result";6import { Result } from "better-result";
7import { toast } from "sonner";7import { toast } from "sonner";
@@ -82,6 +82,7 @@ function normalizeEndpoint(value: string): string { · 74 unmodified lines
82export function AddSourceDialog({82export 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 = sourceToEdit115 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}
executor/apps/web/src/components/tools/explorer-groups.tsx+3−0
@@ -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 <Button356 <Button
354 variant="ghost"357 variant="ghost"
executor/apps/web/src/components/tools/explorer.tsx+3−0
@@ -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
55export function ToolExplorer({56export 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
executor/apps/web/src/components/tools/view.tsx+6−1
@@ -1,6 +1,6 @@
1"use client";1"use client";
2 2
3import { useMemo, useState } from "react";3import { useCallback, useMemo, useState } from "react";
4import {4import {
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 ? selectedSource104 ? 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}
executor/packages/convex/workspace.ts+13−19
@@ -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 = isAnonymousIdentityRequest34 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 = actorId39 // 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 },
executor/scripts/e2e/install.sandbox.e2e.test.ts+60−37
@@ -35,6 +35,16 @@ async function runSandboxBash( · 34 unmodified lines
35 }35 }
36}36}
37 37
38function 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
38function assertSuccess(result: CommandResult, label: string): void {48function 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();

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