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/harness.ts b/packages/junior-dashboard/e2e/harness.ts index 89accf13e1..9b7fbbed7d 100644 --- a/packages/junior-dashboard/e2e/harness.ts +++ b/packages/junior-dashboard/e2e/harness.ts @@ -540,6 +540,9 @@ export async function mockDashboardApis(page: Page) { }, }); }); + await page.route("**/api/workspaces", async (route) => { + await route.fulfill({ json: { workspaces: [] } }); + }); await page.route("**/api/plugins", async (route) => { await route.fulfill({ json: [ diff --git a/packages/junior-dashboard/e2e/system.spec.ts b/packages/junior-dashboard/e2e/system.spec.ts index 0df3df0d5c..7e908334a1 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", { @@ -66,6 +67,55 @@ test("shows system usage and plugin details", async ({ page }) => { await expect(page.getByText("github.organization")).toBeVisible(); }); +test("creates a Workspace recipe", async ({ page }) => { + let createdBody: unknown; + await page.route("**/api/workspaces", async (route) => { + if (route.request().method() === "POST") { + createdBody = route.request().postDataJSON(); + await route.fulfill({ + json: { + id: "11111111-1111-4111-8111-111111111111", + name: "sentry", + setupScript: "pnpm install", + repos: [ + { + checkoutPath: "repos/sentry", + isPrimary: true, + provider: "github", + repo: "getsentry/sentry", + }, + ], + }, + status: 201, + }); + return; + } + await route.fulfill({ json: { workspaces: [] } }); + }); + + await page.goto(`${server.baseURL}/system/workspaces`); + await page.getByRole("button", { name: "New Workspace" }).click(); + await page.getByLabel("Name").fill("sentry"); + await page + .getByLabel("Repository 1", { exact: true }) + .fill("getsentry/sentry"); + await page.getByLabel("Setup script").fill("pnpm install"); + await page.getByRole("button", { name: "Create Workspace" }).click(); + + await expect(page.getByText("github:getsentry/sentry")).toBeVisible(); + expect(createdBody).toEqual({ + name: "sentry", + repos: [ + { + isPrimary: true, + provider: "github", + repo: "getsentry/sentry", + }, + ], + setupScript: "pnpm install", + }); +}); + test("keeps System navigation usable on mobile", async ({ page }) => { await page.setViewportSize({ height: 844, width: 390 }); await page.goto(`${server.baseURL}/system`); @@ -79,6 +129,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..ccb3053b36 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,18 @@ export function DashboardShell() { } path="/system/people" /> + + + + ) : ( + + ) + } + 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/WorkspaceEditor.tsx b/packages/junior-dashboard/src/client/pages/system/WorkspaceEditor.tsx new file mode 100644 index 0000000000..f8ffd5d7fc --- /dev/null +++ b/packages/junior-dashboard/src/client/pages/system/WorkspaceEditor.tsx @@ -0,0 +1,201 @@ +import { Plus, Star, Trash2 } from "lucide-react"; +import type { FormEvent } from "react"; + +import { Button } from "../../components/Button"; +import { Card } from "../../components/layout/Card"; +import { + createRepoDraft, + type RepoDraft, + type WorkspaceDraft, +} from "./workspaceDraft"; + +type WorkspaceEditorProps = { + busy: boolean; + canSave: boolean; + draft: WorkspaceDraft; + error?: string; + editing: boolean; + onCancel(): void; + onChange(draft: WorkspaceDraft): void; + onSubmit(): void; +}; + +/** Edit one Workspace recipe without owning persistence. */ +export function WorkspaceEditor(props: WorkspaceEditorProps) { + function updateRepo(key: string, patch: Partial) { + props.onChange({ + ...props.draft, + repos: props.draft.repos.map((repo) => + repo.key === key ? { ...repo, ...patch } : repo, + ), + }); + } + + function setPrimary(key: string) { + props.onChange({ + ...props.draft, + repos: props.draft.repos.map((repo) => ({ + ...repo, + isPrimary: repo.key === key, + })), + }); + } + + function removeRepo(key: string) { + const repos = props.draft.repos.filter((repo) => repo.key !== key); + if (repos.length === 0) { + props.onChange({ ...props.draft, repos: [createRepoDraft(true)] }); + return; + } + if (!repos.some((repo) => repo.isPrimary)) { + repos[0] = { ...repos[0]!, isPrimary: true }; + } + props.onChange({ ...props.draft, repos }); + } + + function submit(event: FormEvent) { + event.preventDefault(); + if (props.canSave) props.onSubmit(); + } + + return ( + +
+
+
+

+ {props.editing ? "Edit Workspace" : "New Workspace"} +

+

+ Repositories use fixed repos/{"{name}"} paths. Mark + one primary repository for AGENTS.md. +

+
+ +
+ + + + props.onChange({ ...props.draft, name: event.target.value }) + } + placeholder="sentry" + value={props.draft.name} + /> + +
+
+

Repositories

+ +
+ {props.draft.repos.map((repo, index) => ( +
+ + updateRepo(repo.key, { provider: event.target.value }) + } + placeholder="github" + value={repo.provider} + /> + + updateRepo(repo.key, { repo: event.target.value }) + } + placeholder="getsentry/sentry" + value={repo.repo} + /> +
+ + +
+
+ ))} +
+ + +