From e15635998065f27757e52d898270fed7b46e7034 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:44:07 +0000 Subject: [PATCH 1/6] feat(workspaces): Add admin API and dashboard UI Operators can create, update, list, and delete install-wide Workspace recipes from /system/workspaces and /api/workspaces, so agents can switch into prepared multi-repo sandboxes without direct SQL. Co-Authored-By: David Cramer --- .../src/content/docs/operate/dashboard.md | 1 + .../content/docs/operate/sandbox-snapshots.md | 2 + packages/junior-dashboard/e2e/system.spec.ts | 2 + packages/junior-dashboard/src/client/App.tsx | 15 + packages/junior-dashboard/src/client/http.ts | 49 +- .../client/pages/system/SystemNavigation.tsx | 1 + .../client/pages/system/WorkspacesPage.tsx | 520 ++++++++++++++++++ .../tests/dashboard-routes.test.ts | 2 + .../tests/telemetry-components.test.tsx | 1 + packages/junior/src/api.ts | 2 + packages/junior/src/api/schema.ts | 12 + packages/junior/src/api/schema/workspace.ts | 50 ++ packages/junior/src/api/workspaces/routes.ts | 129 +++++ packages/junior/src/app.ts | 2 + packages/junior/src/chat/sandbox/README.md | 3 +- packages/junior/src/chat/workspaces/store.ts | 205 ++++++- .../junior/src/chat/workspaces/validation.ts | 122 ++++ .../integration/api/workspaces/routes.test.ts | 182 ++++++ .../tests/unit/workspaces/validation.test.ts | 74 +++ 19 files changed, 1346 insertions(+), 28 deletions(-) create mode 100644 packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx create mode 100644 packages/junior/src/api/schema/workspace.ts create mode 100644 packages/junior/src/api/workspaces/routes.ts create mode 100644 packages/junior/src/chat/workspaces/validation.ts create mode 100644 packages/junior/tests/integration/api/workspaces/routes.test.ts create mode 100644 packages/junior/tests/unit/workspaces/validation.test.ts diff --git a/packages/docs/src/content/docs/operate/dashboard.md b/packages/docs/src/content/docs/operate/dashboard.md index 5be5987846..6b5b000be8 100644 --- a/packages/docs/src/content/docs/operate/dashboard.md +++ b/packages/docs/src/content/docs/operate/dashboard.md @@ -87,6 +87,7 @@ The dashboard package owns these routes: | `/system/people/:email` | Actor activity profile. | | `/system/locations` | Public location activity directory. | | `/system/locations/:location` | Public location activity detail. | +| `/system/workspaces` | Install-wide repository Workspace recipes. | | `/system/plugins` | Loaded plugin and skill inventory. | | `/system/plugins/:plugin` | Plugin details and operational reports. | | `/_junior/dashboard/client.js` | Authenticated dashboard browser bundle. | diff --git a/packages/docs/src/content/docs/operate/sandbox-snapshots.md b/packages/docs/src/content/docs/operate/sandbox-snapshots.md index d65870c8a2..63e138f062 100644 --- a/packages/docs/src/content/docs/operate/sandbox-snapshots.md +++ b/packages/docs/src/content/docs/operate/sandbox-snapshots.md @@ -46,6 +46,8 @@ Any change to those inputs produces a new profile hash and a new snapshot. Junior stores install-wide Workspace recipes and their repositories in SQL. The agent reads this configuration when it lists a Workspace, resumes an active Workspace, or starts a switch. +Manage recipes from the authenticated dashboard at `/system/workspaces`, or through the `/api/workspaces` REST routes. Each recipe has a stable name, optional setup script, and one or more repositories. Mark exactly one repository as primary when the recipe includes repositories so Junior can select `AGENTS.md`. + Junior builds one complete snapshot for each selected Workspace. The build installs runtime dependencies, prepares repositories, runs the setup script, and then captures the snapshot. The first switch builds the snapshot on demand. Later switches reuse it until its floating profile becomes stale. Provider plugins prepare repositories through Junior's host egress proxy. Junior removes the credential route before it runs the setup script and captures the snapshot. Real provider credentials do not enter the Sandbox or the captured snapshot. diff --git a/packages/junior-dashboard/e2e/system.spec.ts b/packages/junior-dashboard/e2e/system.spec.ts index 0df3df0d5c..61d38fdb7d 100644 --- a/packages/junior-dashboard/e2e/system.spec.ts +++ b/packages/junior-dashboard/e2e/system.spec.ts @@ -37,6 +37,7 @@ test("shows system usage and plugin details", async ({ page }) => { "Overview", "People", "Locations", + "Workspaces", "Plugins", ]); const pluginsLink = systemNavigation.getByRole("link", { @@ -79,6 +80,7 @@ test("keeps System navigation usable on mobile", async ({ page }) => { "Overview", "People", "Locations", + "Workspaces", "Plugins", ]); await systemNavigation.getByRole("link", { name: "Plugins" }).click(); diff --git a/packages/junior-dashboard/src/client/App.tsx b/packages/junior-dashboard/src/client/App.tsx index 16acdf4597..762742c089 100644 --- a/packages/junior-dashboard/src/client/App.tsx +++ b/packages/junior-dashboard/src/client/App.tsx @@ -27,6 +27,7 @@ import { PersonProfilePage } from "./pages/people/PersonProfilePage"; import { SettingsPage } from "./pages/SettingsPage"; import { SystemPage } from "./pages/system/SystemPage"; import { SystemPageLayout } from "./pages/system/SystemPageLayout"; +import { WorkspacesPage } from "./pages/system/WorkspacesPage"; import { TaskExecutionsPage } from "./pages/tasks/TaskExecutionsPage"; import { TaskRunsPage } from "./pages/tasks/TaskRunsPage"; import { TasksPage } from "./pages/tasks/TasksPage"; @@ -313,6 +314,20 @@ export function DashboardShell() { } path="/system/people" /> + + + + ) : loggedIn ? ( + + ) : ( + + ) + } + path="/system/workspaces" + /> { + let apiError: string | undefined; + try { + const body = (await response.json()) as { error?: unknown }; + if (typeof body.error === "string") apiError = body.error; + } catch { + // Keep the status-only fallback when the body is not JSON. + } + throw new DashboardApiError(path, response.status, apiError); +} + function restartDashboardSignIn(): void { if (typeof window === "undefined") { return; @@ -45,7 +61,7 @@ export async function patch( method: "PATCH", }); if (response.status === 401) restartDashboardSignIn(); - if (!response.ok) throw new DashboardApiError(path, response.status); + if (!response.ok) await throwDashboardApiError(path, response); return schema.parse(await response.json()); } @@ -62,7 +78,24 @@ export async function post( method: "POST", }); if (response.status === 401) restartDashboardSignIn(); - if (!response.ok) throw new DashboardApiError(path, response.status); + if (!response.ok) await throwDashboardApiError(path, response); + return schema.parse(await response.json()); +} + +/** Send one authenticated PUT request and validate its response. */ +export async function put( + schema: ZodType, + path: string, + body: unknown, +): Promise { + const response = await fetch(path, { + body: JSON.stringify(body), + credentials: "same-origin", + headers: { "content-type": "application/json" }, + method: "PUT", + }); + if (response.status === 401) restartDashboardSignIn(); + if (!response.ok) await throwDashboardApiError(path, response); return schema.parse(await response.json()); } @@ -73,7 +106,7 @@ export async function deleteDashboardResource(path: string): Promise { method: "DELETE", }); if (response.status === 401) restartDashboardSignIn(); - if (!response.ok) throw new DashboardApiError(path, response.status); + if (!response.ok) await throwDashboardApiError(path, response); } /** Send one authenticated DELETE request with JSON body and validate its response. */ @@ -89,7 +122,7 @@ export async function del( method: "DELETE", }); if (response.status === 401) restartDashboardSignIn(); - if (!response.ok) throw new DashboardApiError(path, response.status); + if (!response.ok) await throwDashboardApiError(path, response); return schema.parse(await response.json()); } @@ -105,8 +138,8 @@ export async function fetchDashboardJson( }); if (response.status === 401) { restartDashboardSignIn(); - throw new DashboardApiError(path, response.status); + await throwDashboardApiError(path, response); } - if (!response.ok) throw new DashboardApiError(path, response.status); + if (!response.ok) await throwDashboardApiError(path, response); return schema.parse(await response.json()); } diff --git a/packages/junior-dashboard/src/client/pages/system/SystemNavigation.tsx b/packages/junior-dashboard/src/client/pages/system/SystemNavigation.tsx index 9aa2f775e0..dd8c16d2d8 100644 --- a/packages/junior-dashboard/src/client/pages/system/SystemNavigation.tsx +++ b/packages/junior-dashboard/src/client/pages/system/SystemNavigation.tsx @@ -5,6 +5,7 @@ const systemNavigationItems = [ { end: true, label: "Overview", to: "/system" }, { label: "People", to: "/system/people" }, { label: "Locations", to: "/system/locations" }, + { label: "Workspaces", to: "/system/workspaces" }, { label: "Plugins", to: systemPluginsPath }, ]; diff --git a/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx b/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx new file mode 100644 index 0000000000..9d636e04dd --- /dev/null +++ b/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx @@ -0,0 +1,520 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { FolderGit2, Plus, Star, Trash2 } from "lucide-react"; +import { useMemo, useState, type FormEvent } from "react"; +import { + workspaceListSchema, + workspaceSchema, + type WorkspaceReport, +} from "@sentry/junior/api/schema"; + +import { getDashboardAgentName } from "../../agentName"; +import { Button } from "../../components/Button"; +import { EmptyTelemetry } from "../../components/EmptyTelemetry"; +import { LoadingView } from "../../components/LoadingView"; +import { Card } from "../../components/layout/Card"; +import { PageHeader } from "../../components/layout/PageHeader"; +import { + DashboardApiError, + deleteDashboardResource, + fetchDashboardJson, + post, + put, +} from "../../http"; +import { SystemPageLayout } from "./SystemPageLayout"; + +const workspacesQueryKey = ["dashboard", "workspaces"] as const; + +type RepoDraft = { + key: string; + provider: string; + repo: string; + isPrimary: boolean; +}; + +type WorkspaceDraft = { + name: string; + setupScript: string; + repos: RepoDraft[]; +}; + +function blankRepo(isPrimary = false): RepoDraft { + return { + key: crypto.randomUUID(), + provider: "github", + repo: "", + isPrimary, + }; +} + +function blankDraft(): WorkspaceDraft { + return { + name: "", + setupScript: "", + repos: [blankRepo(true)], + }; +} + +function draftFromWorkspace(workspace: WorkspaceReport): WorkspaceDraft { + return { + name: workspace.name, + setupScript: workspace.setupScript, + repos: + workspace.repos.length > 0 + ? workspace.repos.map((repo) => ({ + key: crypto.randomUUID(), + provider: repo.provider, + repo: repo.repo, + isPrimary: repo.isPrimary, + })) + : [blankRepo(true)], + }; +} + +function serializeDraft(draft: WorkspaceDraft) { + return { + name: draft.name.trim(), + setupScript: draft.setupScript, + repos: draft.repos.map((repo) => ({ + provider: repo.provider.trim(), + repo: repo.repo.trim(), + isPrimary: repo.isPrimary, + })), + }; +} + +function readApiError(error: unknown, fallback: string): string { + if (error instanceof DashboardApiError) { + return error.apiError ?? fallback; + } + if (error instanceof Error && error.message.trim()) { + return error.message; + } + return fallback; +} + +/** Manage install-wide repository Workspace recipes. */ +export function WorkspacesPage() { + const queryClient = useQueryClient(); + const [editorOpen, setEditorOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(blankDraft); + const [formError, setFormError] = useState(); + const [actionError, setActionError] = useState(); + + const workspacesQuery = useQuery({ + queryKey: workspacesQueryKey, + queryFn: ({ signal }) => + fetchDashboardJson(workspaceListSchema, "/api/workspaces", signal), + retry: false, + }); + + const saveMutation = useMutation({ + mutationFn: async () => { + const body = serializeDraft(draft); + if (editingId) { + return put( + workspaceSchema, + `/api/workspaces/${encodeURIComponent(editingId)}`, + body, + ); + } + return post(workspaceSchema, "/api/workspaces", body); + }, + onSuccess: async (workspace) => { + setFormError(undefined); + setActionError(undefined); + setEditorOpen(false); + setEditingId(null); + setDraft(blankDraft()); + await queryClient.cancelQueries({ queryKey: workspacesQueryKey }); + queryClient.setQueryData<{ workspaces: WorkspaceReport[] }>( + workspacesQueryKey, + (current) => { + const existing = current?.workspaces ?? []; + const next = [ + workspace, + ...existing.filter((item) => item.id !== workspace.id), + ].sort((left, right) => left.name.localeCompare(right.name)); + return { workspaces: next }; + }, + ); + }, + onError: (error) => { + setFormError( + readApiError(error, "Could not save the Workspace. Try again."), + ); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (workspace: WorkspaceReport) => + deleteDashboardResource( + `/api/workspaces/${encodeURIComponent(workspace.id)}`, + ).then(() => workspace), + onSuccess: async (workspace) => { + setActionError(undefined); + if (editingId === workspace.id) { + setEditorOpen(false); + setEditingId(null); + setDraft(blankDraft()); + } + await queryClient.cancelQueries({ queryKey: workspacesQueryKey }); + queryClient.setQueryData<{ workspaces: WorkspaceReport[] }>( + workspacesQueryKey, + (current) => ({ + workspaces: (current?.workspaces ?? []).filter( + (item) => item.id !== workspace.id, + ), + }), + ); + }, + onError: (error) => { + setActionError( + readApiError(error, "Could not delete the Workspace. Try again."), + ); + }, + }); + + const workspaces = workspacesQuery.data?.workspaces ?? []; + const busy = saveMutation.isPending || deleteMutation.isPending; + const canSave = useMemo(() => { + if (!draft.name.trim()) return false; + if (draft.repos.some((repo) => !repo.provider.trim() || !repo.repo.trim())) { + return false; + } + if (draft.repos.length > 0 && !draft.repos.some((repo) => repo.isPrimary)) { + return false; + } + return !busy; + }, [busy, draft]); + + function openCreate() { + setEditingId(null); + setDraft(blankDraft()); + setFormError(undefined); + setEditorOpen(true); + } + + function openEdit(workspace: WorkspaceReport) { + setEditingId(workspace.id); + setDraft(draftFromWorkspace(workspace)); + setFormError(undefined); + setEditorOpen(true); + } + + function updateRepo(key: string, patch: Partial) { + setDraft((current) => ({ + ...current, + repos: current.repos.map((repo) => + repo.key === key ? { ...repo, ...patch } : repo, + ), + })); + } + + function setPrimary(key: string) { + setDraft((current) => ({ + ...current, + repos: current.repos.map((repo) => ({ + ...repo, + isPrimary: repo.key === key, + })), + })); + } + + function removeRepo(key: string) { + setDraft((current) => { + const remaining = current.repos.filter((repo) => repo.key !== key); + if (remaining.length === 0) return { ...current, repos: [blankRepo(true)] }; + if (!remaining.some((repo) => repo.isPrimary)) { + remaining[0] = { ...remaining[0]!, isPrimary: true }; + } + return { ...current, repos: remaining }; + }); + } + + function submit(event: FormEvent) { + event.preventDefault(); + if (!canSave) return; + setFormError(undefined); + saveMutation.mutate(); + } + + if (!workspacesQuery.data && !workspacesQuery.error) { + return ( + + + + ); + } + + return ( + + +