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 (
+
+
+
+ New Workspace
+
+ }
+ description={`Named repository recipes ${getDashboardAgentName()} can switch into without cloning each turn.`}
+ title="Workspaces"
+ />
+
+ {workspacesQuery.error ? (
+
+
+ Workspaces failed to load. Try refreshing the dashboard.
+
+
+ ) : null}
+
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
+ {editorOpen ? (
+
+
+
+ ) : null}
+
+
+ {workspaces.length === 0 ? (
+
+
+ No Workspaces yet. Create one so agents can switch into a prepared
+ multi-repo Sandbox.
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/packages/junior-dashboard/tests/dashboard-routes.test.ts b/packages/junior-dashboard/tests/dashboard-routes.test.ts
index 65051a421e..ef7bcbb9b4 100644
--- a/packages/junior-dashboard/tests/dashboard-routes.test.ts
+++ b/packages/junior-dashboard/tests/dashboard-routes.test.ts
@@ -72,6 +72,7 @@ describe("dashboard routes", () => {
"/memories/memory-1",
"/settings",
"/settings/api-tokens",
+ "/system/workspaces",
]) {
const response = await app.fetch(new Request(`http://localhost${path}`));
expect(response.status).toBe(302);
@@ -436,6 +437,7 @@ describe("dashboard routes", () => {
"/memories/memory-1",
"/settings",
"/settings/api-tokens",
+ "/system/workspaces",
"/plugins/memory/memories",
"/plugins/memory/memories/library",
]) {
diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx
index 9cc0790c82..235a8c69a7 100644
--- a/packages/junior-dashboard/tests/telemetry-components.test.tsx
+++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx
@@ -1608,6 +1608,7 @@ describe("dashboard canonical-event components", () => {
expect(systemHtml).toContain('aria-label="System navigation"');
expect(systemHtml).toContain('href="/system/people"');
expect(systemHtml).toContain('href="/system/locations"');
+ expect(systemHtml).toContain('href="/system/workspaces"');
expect(systemHtml).toContain('href="/system/plugins"');
expect(systemHtml).toContain(">Plugins");
expect(systemHtml).not.toContain(">Capabilities<");
diff --git a/packages/junior/src/api.ts b/packages/junior/src/api.ts
index 3e66ef69a5..b36138cb84 100644
--- a/packages/junior/src/api.ts
+++ b/packages/junior/src/api.ts
@@ -8,6 +8,7 @@ import { createPeopleRoutes } from "./api/people/routes";
import { createPersonalTokenRoutes } from "./api/personal-tokens/routes";
import { createUserPageRoutes } from "./api/user-pages/routes";
import { createTaskRoutes } from "./api/tasks/routes";
+import { createWorkspaceRoutes } from "./api/workspaces/routes";
import type { JuniorApiEnv, JuniorApiVariables } from "./api/route";
import { apiErrorSchema } from "./api/schema/common";
import {
@@ -73,6 +74,7 @@ export function createJuniorApi(): Hono {
app.route("/api/locations", createLocationRoutes());
app.route("/api/user-pages", createUserPageRoutes());
app.route("/api/tasks", createTaskRoutes());
+ app.route("/api/workspaces", createWorkspaceRoutes());
app.notFound(() =>
jsonResponse(
apiErrorSchema,
diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts
index 02326405ca..2af0afb238 100644
--- a/packages/junior/src/api/schema.ts
+++ b/packages/junior/src/api/schema.ts
@@ -100,6 +100,18 @@ export {
revokePersonalTokenResponseSchema,
} from "./schema/personal-token";
export type { PersonalTokenMetadata } from "./schema/personal-token";
+export {
+ deleteWorkspaceResponseSchema,
+ workspaceBodySchema,
+ workspaceListSchema,
+ workspaceParamsSchema,
+ workspaceRepoSchema,
+ workspaceSchema,
+} from "./schema/workspace";
+export type {
+ WorkspaceListReport,
+ WorkspaceReport,
+} from "./schema/workspace";
export {
eventTaskSummarySchema,
scheduledTaskSummarySchema,
diff --git a/packages/junior/src/api/schema/workspace.ts b/packages/junior/src/api/schema/workspace.ts
new file mode 100644
index 0000000000..f1ca15aa87
--- /dev/null
+++ b/packages/junior/src/api/schema/workspace.ts
@@ -0,0 +1,50 @@
+import { z } from "zod";
+
+const workspaceRepoInputSchema = z
+ .object({
+ provider: z.string().trim().min(1).max(64),
+ repo: z.string().trim().min(1).max(200),
+ isPrimary: z.boolean().optional(),
+ })
+ .strict();
+
+export const workspaceRepoSchema = z
+ .object({
+ provider: z.string(),
+ repo: z.string(),
+ checkoutPath: z.string(),
+ isPrimary: z.boolean(),
+ })
+ .strict();
+
+export const workspaceSchema = z
+ .object({
+ id: z.string().uuid(),
+ name: z.string(),
+ setupScript: z.string(),
+ repos: z.array(workspaceRepoSchema),
+ })
+ .strict();
+
+export const workspaceListSchema = z
+ .object({ workspaces: z.array(workspaceSchema) })
+ .strict();
+
+export const workspaceBodySchema = z
+ .object({
+ name: z.string().trim().min(1).max(64),
+ setupScript: z.string().max(65_536).optional(),
+ repos: z.array(workspaceRepoInputSchema).max(32),
+ })
+ .strict();
+
+export const workspaceParamsSchema = z
+ .object({ id: z.string().uuid() })
+ .strict();
+
+export const deleteWorkspaceResponseSchema = z
+ .object({ deleted: z.literal(true) })
+ .strict();
+
+export type WorkspaceReport = z.infer;
+export type WorkspaceListReport = z.infer;
diff --git a/packages/junior/src/api/workspaces/routes.ts b/packages/junior/src/api/workspaces/routes.ts
new file mode 100644
index 0000000000..ece253c933
--- /dev/null
+++ b/packages/junior/src/api/workspaces/routes.ts
@@ -0,0 +1,129 @@
+import { Hono } from "hono";
+import { getDb } from "@/chat/db";
+import { workspaceRepoCheckoutPath } from "@/chat/workspaces/checkout-path";
+import {
+ createWorkspace,
+ deleteWorkspace,
+ getWorkspace,
+ listWorkspaces,
+ updateWorkspace,
+ WorkspaceValidationError,
+} from "@/chat/workspaces/store";
+import type { Workspace } from "@/chat/workspaces/types";
+import { jsonResponse, throwApiError } from "../http";
+import type { JuniorApiEnv } from "../route";
+import {
+ deleteWorkspaceResponseSchema,
+ workspaceBodySchema,
+ workspaceListSchema,
+ workspaceParamsSchema,
+ workspaceSchema,
+} from "../schema/workspace";
+import { validateRequest } from "../validation";
+import { requireViewer } from "../viewer";
+
+function view(workspace: Workspace) {
+ return {
+ id: workspace.id,
+ name: workspace.name,
+ setupScript: workspace.setupScript,
+ repos: workspace.repos.map((repo) => ({
+ provider: repo.provider,
+ repo: repo.repo,
+ checkoutPath: workspaceRepoCheckoutPath(repo.repo),
+ isPrimary: repo.isPrimary,
+ })),
+ };
+}
+
+function handleValidationError(error: unknown): never {
+ if (error instanceof WorkspaceValidationError) {
+ throwApiError(400, error.message);
+ }
+ throw error;
+}
+
+/** Create authenticated native Workspace list and admin routes. */
+export function createWorkspaceRoutes(): Hono {
+ const app = new Hono();
+
+ app.get("/", requireViewer, async () => {
+ return jsonResponse(workspaceListSchema, {
+ workspaces: (await listWorkspaces(getDb())).map(view),
+ });
+ });
+
+ app.post(
+ "/",
+ requireViewer,
+ validateRequest("json", workspaceBodySchema, "Invalid request body."),
+ async (context) => {
+ const body = context.req.valid("json");
+ try {
+ const workspace = await createWorkspace(body);
+ return jsonResponse(workspaceSchema, view(workspace), { status: 201 });
+ } catch (error) {
+ handleValidationError(error);
+ }
+ },
+ );
+
+ app.get(
+ "/:id",
+ requireViewer,
+ validateRequest(
+ "param",
+ workspaceParamsSchema,
+ "Invalid route parameters.",
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+ const workspace = await getWorkspace(getDb(), id);
+ if (!workspace) throwApiError(404, "Workspace not found.");
+ return jsonResponse(workspaceSchema, view(workspace));
+ },
+ );
+
+ app.put(
+ "/:id",
+ requireViewer,
+ validateRequest(
+ "param",
+ workspaceParamsSchema,
+ "Invalid route parameters.",
+ ),
+ validateRequest("json", workspaceBodySchema, "Invalid request body."),
+ async (context) => {
+ const { id } = context.req.valid("param");
+ const body = context.req.valid("json");
+ try {
+ const workspace = await updateWorkspace(id, body);
+ if (!workspace) throwApiError(404, "Workspace not found.");
+ return jsonResponse(workspaceSchema, view(workspace));
+ } catch (error) {
+ handleValidationError(error);
+ }
+ },
+ );
+
+ app.delete(
+ "/:id",
+ requireViewer,
+ validateRequest(
+ "param",
+ workspaceParamsSchema,
+ "Invalid route parameters.",
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+ if (!(await deleteWorkspace(id))) {
+ throwApiError(404, "Workspace not found.");
+ }
+ return jsonResponse(deleteWorkspaceResponseSchema, {
+ deleted: true as const,
+ });
+ },
+ );
+
+ return app;
+}
diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts
index e8bd583cbe..85365b5995 100644
--- a/packages/junior/src/app.ts
+++ b/packages/junior/src/app.ts
@@ -458,6 +458,8 @@ function dashboardHostRoutePaths(dashboard: JuniorDashboardOptions): string[] {
"/api/people/*",
"/api/personal-tokens",
"/api/personal-tokens/*",
+ "/api/workspaces",
+ "/api/workspaces/*",
"/api/config",
"/api/me",
authPath,
diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md
index 935ce84abf..010b2b14fa 100644
--- a/packages/junior/src/chat/sandbox/README.md
+++ b/packages/junior/src/chat/sandbox/README.md
@@ -50,7 +50,8 @@ traffic through verified host egress.
steps for that profile.
- A Workspace recipe is part of the profile hash. One build installs runtime
dependencies, prepares repositories, runs setup, and captures the complete
- snapshot.
+ snapshot. Operators manage recipes from `/system/workspaces` or
+ `/api/workspaces`.
- Workspace repositories clone to fixed `repos/{name}` paths. Setup scripts
receive `JUNIOR_WORKSPACE_ROOT` and `JUNIOR_REPOS_ROOT` so they do not depend
on the provider's absolute Sandbox path.
diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts
index 53b81156d0..52e41ce606 100644
--- a/packages/junior/src/chat/workspaces/store.ts
+++ b/packages/junior/src/chat/workspaces/store.ts
@@ -1,7 +1,14 @@
+import { randomUUID } from "node:crypto";
import { asc, eq } from "drizzle-orm";
-import type { JuniorDatabase } from "@/db/db";
+import { getSqlExecutor } from "@/chat/db";
+import type { JuniorDatabase, JuniorSqlDatabase } from "@/db/db";
import { juniorWorkspaceRepos, juniorWorkspaces } from "@/db/schema";
import type { Workspace } from "./types";
+import {
+ normalizeWorkspaceRecipe,
+ WorkspaceValidationError,
+ type WorkspaceRecipeInput,
+} from "./validation";
function workspaceFromRows(
row: typeof juniorWorkspaces.$inferSelect,
@@ -19,6 +26,58 @@ function workspaceFromRows(
};
}
+async function loadWorkspaceRepos(
+ db: JuniorDatabase,
+ workspaceId: string,
+): Promise> {
+ return await db
+ .select()
+ .from(juniorWorkspaceRepos)
+ .where(eq(juniorWorkspaceRepos.workspaceId, workspaceId))
+ .orderBy(
+ asc(juniorWorkspaceRepos.provider),
+ asc(juniorWorkspaceRepos.repo),
+ );
+}
+
+async function replaceWorkspaceRepos(
+ db: JuniorDatabase,
+ workspaceId: string,
+ repos: WorkspaceRecipeInput["repos"],
+): Promise {
+ await db
+ .delete(juniorWorkspaceRepos)
+ .where(eq(juniorWorkspaceRepos.workspaceId, workspaceId));
+ if (repos.length === 0) return;
+ await db.insert(juniorWorkspaceRepos).values(
+ repos.map((repo) => ({
+ workspaceId,
+ provider: repo.provider,
+ repo: repo.repo,
+ isPrimary: repo.isPrimary,
+ })),
+ );
+}
+
+function isUniqueViolation(error: unknown): boolean {
+ let current: unknown = error;
+ for (let depth = 0; current && depth < 6; depth += 1) {
+ if (
+ typeof current === "object" &&
+ current !== null &&
+ "code" in current &&
+ (current as { code?: unknown }).code === "23505"
+ ) {
+ return true;
+ }
+ current =
+ typeof current === "object" && current !== null && "cause" in current
+ ? (current as { cause?: unknown }).cause
+ : undefined;
+ }
+ return false;
+}
+
/** List Workspace recipes by stable name. */
export async function listWorkspaces(db: JuniorDatabase): Promise {
const [workspaces, repos] = await Promise.all([
@@ -52,15 +111,10 @@ export async function getWorkspaceByName(
.limit(1);
const workspace = rows[0];
if (!workspace) return undefined;
- const repos = await db
- .select()
- .from(juniorWorkspaceRepos)
- .where(eq(juniorWorkspaceRepos.workspaceId, workspace.id))
- .orderBy(
- asc(juniorWorkspaceRepos.provider),
- asc(juniorWorkspaceRepos.repo),
- );
- return workspaceFromRows(workspace, repos);
+ return workspaceFromRows(
+ workspace,
+ await loadWorkspaceRepos(db, workspace.id),
+ );
}
/** Resolve one Workspace recipe by id. */
@@ -75,13 +129,126 @@ export async function getWorkspace(
.limit(1);
const workspace = rows[0];
if (!workspace) return undefined;
- const repos = await db
- .select()
- .from(juniorWorkspaceRepos)
- .where(eq(juniorWorkspaceRepos.workspaceId, workspace.id))
- .orderBy(
- asc(juniorWorkspaceRepos.provider),
- asc(juniorWorkspaceRepos.repo),
- );
- return workspaceFromRows(workspace, repos);
+ return workspaceFromRows(
+ workspace,
+ await loadWorkspaceRepos(db, workspace.id),
+ );
}
+
+/** Create one install-wide Workspace recipe. */
+export async function createWorkspace(
+ input: {
+ name: string;
+ setupScript?: string;
+ repos: Array<{
+ provider: string;
+ repo: string;
+ isPrimary?: boolean;
+ }>;
+ },
+ options: {
+ db?: JuniorDatabase;
+ executor?: JuniorSqlDatabase;
+ now?: Date;
+ } = {},
+): Promise {
+ const recipe = normalizeWorkspaceRecipe(input);
+ const executor = options.executor ?? getSqlExecutor();
+ const db = options.db ?? executor.db();
+ const now = options.now ?? new Date();
+ const id = randomUUID();
+
+ try {
+ await executor.transaction(async () => {
+ await db.insert(juniorWorkspaces).values({
+ id,
+ name: recipe.name,
+ setupScript: recipe.setupScript,
+ createdAt: now,
+ updatedAt: now,
+ });
+ await replaceWorkspaceRepos(db, id, recipe.repos);
+ });
+ } catch (error) {
+ if (isUniqueViolation(error)) {
+ throw new WorkspaceValidationError(
+ `Workspace name already exists: ${recipe.name}`,
+ );
+ }
+ throw error;
+ }
+
+ return (await getWorkspace(db, id))!;
+}
+
+/** Replace one install-wide Workspace recipe. */
+export async function updateWorkspace(
+ id: string,
+ input: {
+ name: string;
+ setupScript?: string;
+ repos: Array<{
+ provider: string;
+ repo: string;
+ isPrimary?: boolean;
+ }>;
+ },
+ options: {
+ db?: JuniorDatabase;
+ executor?: JuniorSqlDatabase;
+ now?: Date;
+ } = {},
+): Promise {
+ const recipe = normalizeWorkspaceRecipe(input);
+ const executor = options.executor ?? getSqlExecutor();
+ const db = options.db ?? executor.db();
+ const now = options.now ?? new Date();
+
+ try {
+ const updated = await executor.transaction(async () => {
+ const rows = await db
+ .update(juniorWorkspaces)
+ .set({
+ name: recipe.name,
+ setupScript: recipe.setupScript,
+ updatedAt: now,
+ })
+ .where(eq(juniorWorkspaces.id, id))
+ .returning();
+ if (!rows[0]) return undefined;
+ await replaceWorkspaceRepos(db, id, recipe.repos);
+ return rows[0];
+ });
+ if (!updated) return undefined;
+ } catch (error) {
+ if (isUniqueViolation(error)) {
+ throw new WorkspaceValidationError(
+ `Workspace name already exists: ${recipe.name}`,
+ );
+ }
+ throw error;
+ }
+
+ return await getWorkspace(db, id);
+}
+
+/** Delete one install-wide Workspace recipe. */
+export async function deleteWorkspace(
+ id: string,
+ options: {
+ db?: JuniorDatabase;
+ executor?: JuniorSqlDatabase;
+ } = {},
+): Promise {
+ const executor = options.executor ?? getSqlExecutor();
+ const db = options.db ?? executor.db();
+ return await executor.transaction(async () => {
+ const rows = await db
+ .delete(juniorWorkspaces)
+ .where(eq(juniorWorkspaces.id, id))
+ .returning({ id: juniorWorkspaces.id });
+ return rows.length > 0;
+ });
+}
+
+export { WorkspaceValidationError };
diff --git a/packages/junior/src/chat/workspaces/validation.ts b/packages/junior/src/chat/workspaces/validation.ts
new file mode 100644
index 0000000000..2a12d4b20a
--- /dev/null
+++ b/packages/junior/src/chat/workspaces/validation.ts
@@ -0,0 +1,122 @@
+import { tryWorkspaceRepoCheckoutPath } from "./checkout-path";
+import type { WorkspaceRepo } from "./types";
+
+const WORKSPACE_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
+const PROVIDER_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
+const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
+const MAX_SETUP_SCRIPT_BYTES = 64 * 1024;
+const MAX_REPOS = 32;
+
+/** Normalized recipe fields accepted by Workspace write paths. */
+export interface WorkspaceRecipeInput {
+ name: string;
+ setupScript: string;
+ repos: WorkspaceRepo[];
+}
+
+/** Validation failure for one Workspace recipe write. */
+export class WorkspaceValidationError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "WorkspaceValidationError";
+ }
+}
+
+/** Normalize and validate one Workspace recipe write payload. */
+export function normalizeWorkspaceRecipe(input: {
+ name: string;
+ setupScript?: string;
+ repos: Array<{
+ provider: string;
+ repo: string;
+ isPrimary?: boolean;
+ }>;
+}): WorkspaceRecipeInput {
+ const name = input.name.trim().toLowerCase();
+ if (!WORKSPACE_NAME_PATTERN.test(name)) {
+ throw new WorkspaceValidationError(
+ "Workspace name must start with a letter and use only lowercase letters, digits, underscores, or hyphens.",
+ );
+ }
+
+ const setupScript = input.setupScript ?? "";
+ if (typeof setupScript !== "string") {
+ throw new WorkspaceValidationError("Setup script must be a string.");
+ }
+ if (Buffer.byteLength(setupScript, "utf8") > MAX_SETUP_SCRIPT_BYTES) {
+ throw new WorkspaceValidationError(
+ `Setup script must be at most ${MAX_SETUP_SCRIPT_BYTES} bytes.`,
+ );
+ }
+
+ if (!Array.isArray(input.repos)) {
+ throw new WorkspaceValidationError("Repositories must be an array.");
+ }
+ if (input.repos.length > MAX_REPOS) {
+ throw new WorkspaceValidationError(
+ `A Workspace can include at most ${MAX_REPOS} repositories.`,
+ );
+ }
+
+ const repos: WorkspaceRepo[] = input.repos.map((entry, index) => {
+ const provider = entry.provider.trim().toLowerCase();
+ if (!PROVIDER_PATTERN.test(provider)) {
+ throw new WorkspaceValidationError(
+ `Repository ${index + 1} provider is invalid.`,
+ );
+ }
+ const repo = entry.repo.trim();
+ if (!REPO_PATTERN.test(repo)) {
+ throw new WorkspaceValidationError(
+ `Repository ${index + 1} must use owner/name format.`,
+ );
+ }
+ if (!tryWorkspaceRepoCheckoutPath(repo)) {
+ throw new WorkspaceValidationError(
+ `Repository ${index + 1} name cannot be used as a checkout path.`,
+ );
+ }
+ return {
+ provider,
+ repo,
+ isPrimary: entry.isPrimary === true,
+ };
+ });
+
+ const identities = new Set();
+ for (const repo of repos) {
+ const identity = `${repo.provider}:${repo.repo.toLowerCase()}`;
+ if (identities.has(identity)) {
+ throw new WorkspaceValidationError(
+ `Duplicate repository in Workspace: ${repo.provider}:${repo.repo}`,
+ );
+ }
+ identities.add(identity);
+ }
+
+ const checkoutPaths = new Set();
+ for (const repo of repos) {
+ const path = tryWorkspaceRepoCheckoutPath(repo.repo)!;
+ const key = path.toLowerCase();
+ if (checkoutPaths.has(key)) {
+ throw new WorkspaceValidationError(
+ `Workspace checkout path collision: ${path}`,
+ );
+ }
+ checkoutPaths.add(key);
+ }
+
+ const primaryCount = repos.filter((repo) => repo.isPrimary).length;
+ if (repos.length > 0 && primaryCount === 0) {
+ throw new WorkspaceValidationError(
+ "Select one primary repository for AGENTS.md instructions.",
+ );
+ }
+ if (primaryCount > 1) {
+ throw new WorkspaceValidationError(
+ "A Workspace can have only one primary repository.",
+ );
+ }
+
+ return { name, setupScript, repos };
+}
diff --git a/packages/junior/tests/integration/api/workspaces/routes.test.ts b/packages/junior/tests/integration/api/workspaces/routes.test.ts
new file mode 100644
index 0000000000..f5243eaf5a
--- /dev/null
+++ b/packages/junior/tests/integration/api/workspaces/routes.test.ts
@@ -0,0 +1,182 @@
+import { Hono } from "hono";
+import { afterEach, describe, expect, it } from "vitest";
+import { createJuniorApi, type JuniorApiVariables } from "@/api";
+import {
+ apiErrorSchema,
+ deleteWorkspaceResponseSchema,
+ workspaceListSchema,
+ workspaceSchema,
+} from "@/api/schema";
+import { closeDb } from "@/chat/db";
+import { resolveViewerUser } from "@/chat/plugins/viewer";
+
+function authenticatedApi(email = "person@example.com") {
+ const app = new Hono<{ Variables: JuniorApiVariables }>();
+ app.use("*", async (c, next) => {
+ const viewer = await resolveViewerUser(email);
+ if (!viewer) {
+ throw new Error(`missing viewer for ${email}`);
+ }
+ c.set("viewer", viewer);
+ await next();
+ });
+ app.route("/", createJuniorApi());
+ return app;
+}
+
+describe("workspace admin API", () => {
+ afterEach(async () => {
+ await closeDb();
+ });
+
+ it("creates, lists, updates, and deletes Workspace recipes", async () => {
+ const app = authenticatedApi();
+
+ const createResponse = await app.request("http://localhost/api/workspaces", {
+ body: JSON.stringify({
+ name: "Sentry",
+ setupScript: "pnpm install",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ isPrimary: true,
+ },
+ {
+ provider: "github",
+ repo: "getsentry/getsentry",
+ },
+ ],
+ }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ expect(createResponse.status).toBe(201);
+ const created = workspaceSchema.parse(await createResponse.json());
+ expect(created).toMatchObject({
+ name: "sentry",
+ setupScript: "pnpm install",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/getsentry",
+ checkoutPath: "repos/getsentry",
+ isPrimary: false,
+ },
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ checkoutPath: "repos/sentry",
+ isPrimary: true,
+ },
+ ],
+ });
+
+ const listResponse = await app.request("http://localhost/api/workspaces");
+ expect(listResponse.status).toBe(200);
+ const listed = workspaceListSchema.parse(await listResponse.json());
+ expect(listed.workspaces.map((workspace) => workspace.id)).toContain(
+ created.id,
+ );
+
+ const updateResponse = await app.request(
+ `http://localhost/api/workspaces/${created.id}`,
+ {
+ body: JSON.stringify({
+ name: "sentry-core",
+ setupScript: "pnpm install --frozen-lockfile",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ isPrimary: true,
+ },
+ ],
+ }),
+ headers: { "content-type": "application/json" },
+ method: "PUT",
+ },
+ );
+ expect(updateResponse.status).toBe(200);
+ const updated = workspaceSchema.parse(await updateResponse.json());
+ expect(updated).toMatchObject({
+ id: created.id,
+ name: "sentry-core",
+ setupScript: "pnpm install --frozen-lockfile",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ checkoutPath: "repos/sentry",
+ isPrimary: true,
+ },
+ ],
+ });
+
+ const deleteResponse = await app.request(
+ `http://localhost/api/workspaces/${created.id}`,
+ { method: "DELETE" },
+ );
+ expect(deleteResponse.status).toBe(200);
+ expect(
+ deleteWorkspaceResponseSchema.parse(await deleteResponse.json()),
+ ).toEqual({ deleted: true });
+
+ const missing = await app.request(
+ `http://localhost/api/workspaces/${created.id}`,
+ );
+ expect(missing.status).toBe(404);
+ expect(apiErrorSchema.parse(await missing.json())).toEqual({
+ error: "Workspace not found.",
+ });
+ });
+
+ it("rejects invalid recipes with the stable error contract", async () => {
+ const response = await authenticatedApi().request(
+ "http://localhost/api/workspaces",
+ {
+ body: JSON.stringify({
+ name: "bad name",
+ repos: [{ provider: "github", repo: "getsentry/sentry" }],
+ }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ },
+ );
+
+ expect(response.status).toBe(400);
+ expect(apiErrorSchema.parse(await response.json()).error).toMatch(
+ /name must start with a letter/i,
+ );
+ });
+
+ it("rejects duplicate names", async () => {
+ const app = authenticatedApi();
+ const body = {
+ name: "shared",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ isPrimary: true,
+ },
+ ],
+ };
+ const first = await app.request("http://localhost/api/workspaces", {
+ body: JSON.stringify(body),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ expect(first.status).toBe(201);
+
+ const second = await app.request("http://localhost/api/workspaces", {
+ body: JSON.stringify(body),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ expect(second.status).toBe(400);
+ expect(apiErrorSchema.parse(await second.json())).toEqual({
+ error: "Workspace name already exists: shared",
+ });
+ });
+});
diff --git a/packages/junior/tests/unit/workspaces/validation.test.ts b/packages/junior/tests/unit/workspaces/validation.test.ts
new file mode 100644
index 0000000000..d6d718a603
--- /dev/null
+++ b/packages/junior/tests/unit/workspaces/validation.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeWorkspaceRecipe,
+ WorkspaceValidationError,
+} from "@/chat/workspaces/validation";
+
+describe("normalizeWorkspaceRecipe", () => {
+ it("normalizes names and providers", () => {
+ expect(
+ normalizeWorkspaceRecipe({
+ name: " Sentry ",
+ setupScript: "pnpm install",
+ repos: [
+ {
+ provider: " GitHub ",
+ repo: " getsentry/sentry ",
+ isPrimary: true,
+ },
+ ],
+ }),
+ ).toEqual({
+ name: "sentry",
+ setupScript: "pnpm install",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ isPrimary: true,
+ },
+ ],
+ });
+ });
+
+ it("requires one primary repository when repos exist", () => {
+ expect(() =>
+ normalizeWorkspaceRecipe({
+ name: "sentry",
+ repos: [{ provider: "github", repo: "getsentry/sentry" }],
+ }),
+ ).toThrow(WorkspaceValidationError);
+ });
+
+ it("rejects checkout path collisions", () => {
+ expect(() =>
+ normalizeWorkspaceRecipe({
+ name: "sentry",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ isPrimary: true,
+ },
+ {
+ provider: "github",
+ repo: "acme/sentry",
+ },
+ ],
+ }),
+ ).toThrow(/checkout path collision/i);
+ });
+
+ it("allows empty repository lists", () => {
+ expect(
+ normalizeWorkspaceRecipe({
+ name: "empty",
+ repos: [],
+ }),
+ ).toEqual({
+ name: "empty",
+ setupScript: "",
+ repos: [],
+ });
+ });
+});
From 9cc18582d68b60b1715186e06b979509ea8f399b 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:47:51 +0000
Subject: [PATCH 2/6] fix(dashboard): Keep path status in DashboardApiError
message
API body text stays on apiError for UI copy. The Error message keeps the
stable path/status contract used by client tests and logs.
---
packages/junior-dashboard/src/client/http.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/junior-dashboard/src/client/http.ts b/packages/junior-dashboard/src/client/http.ts
index cdd302f6af..a5aa29a7a7 100644
--- a/packages/junior-dashboard/src/client/http.ts
+++ b/packages/junior-dashboard/src/client/http.ts
@@ -6,7 +6,7 @@ export class DashboardApiError extends Error {
readonly apiError?: string;
constructor(path: string, status: number, apiError?: string) {
- super(apiError?.trim() || `${path} returned ${status}`);
+ super(`${path} returned ${status}`);
this.status = status;
if (apiError?.trim()) this.apiError = apiError.trim();
}
From cc03cccfc61134521fd3f15fa74ed06bc724bf72 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 17:36:05 +0000
Subject: [PATCH 3/6] fix(workspaces): Keep recipe writes transactional
---
packages/junior/src/chat/workspaces/store.ts | 15 ++--
.../integration/api/workspaces/routes.test.ts | 77 ++++++++++++++++++-
2 files changed, 82 insertions(+), 10 deletions(-)
diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts
index 52e41ce606..d0b119aabe 100644
--- a/packages/junior/src/chat/workspaces/store.ts
+++ b/packages/junior/src/chat/workspaces/store.ts
@@ -147,19 +147,18 @@ export async function createWorkspace(
}>;
},
options: {
- db?: JuniorDatabase;
executor?: JuniorSqlDatabase;
now?: Date;
} = {},
): Promise {
const recipe = normalizeWorkspaceRecipe(input);
const executor = options.executor ?? getSqlExecutor();
- const db = options.db ?? executor.db();
const now = options.now ?? new Date();
const id = randomUUID();
try {
await executor.transaction(async () => {
+ const db = executor.db();
await db.insert(juniorWorkspaces).values({
id,
name: recipe.name,
@@ -178,7 +177,7 @@ export async function createWorkspace(
throw error;
}
- return (await getWorkspace(db, id))!;
+ return (await getWorkspace(executor.db(), id))!;
}
/** Replace one install-wide Workspace recipe. */
@@ -194,18 +193,17 @@ export async function updateWorkspace(
}>;
},
options: {
- db?: JuniorDatabase;
executor?: JuniorSqlDatabase;
now?: Date;
} = {},
): Promise {
const recipe = normalizeWorkspaceRecipe(input);
const executor = options.executor ?? getSqlExecutor();
- const db = options.db ?? executor.db();
const now = options.now ?? new Date();
try {
const updated = await executor.transaction(async () => {
+ const db = executor.db();
const rows = await db
.update(juniorWorkspaces)
.set({
@@ -229,21 +227,20 @@ export async function updateWorkspace(
throw error;
}
- return await getWorkspace(db, id);
+ return await getWorkspace(executor.db(), id);
}
/** Delete one install-wide Workspace recipe. */
export async function deleteWorkspace(
id: string,
options: {
- db?: JuniorDatabase;
executor?: JuniorSqlDatabase;
} = {},
): Promise {
const executor = options.executor ?? getSqlExecutor();
- const db = options.db ?? executor.db();
return await executor.transaction(async () => {
- const rows = await db
+ const rows = await executor
+ .db()
.delete(juniorWorkspaces)
.where(eq(juniorWorkspaces.id, id))
.returning({ id: juniorWorkspaces.id });
diff --git a/packages/junior/tests/integration/api/workspaces/routes.test.ts b/packages/junior/tests/integration/api/workspaces/routes.test.ts
index f5243eaf5a..dae12f28c7 100644
--- a/packages/junior/tests/integration/api/workspaces/routes.test.ts
+++ b/packages/junior/tests/integration/api/workspaces/routes.test.ts
@@ -7,8 +7,14 @@ import {
workspaceListSchema,
workspaceSchema,
} from "@/api/schema";
-import { closeDb } from "@/chat/db";
+import { closeDb, getDb, getSqlExecutor } from "@/chat/db";
import { resolveViewerUser } from "@/chat/plugins/viewer";
+import {
+ createWorkspace,
+ getWorkspace,
+ getWorkspaceByName,
+ updateWorkspace,
+} from "@/chat/workspaces/store";
function authenticatedApi(email = "person@example.com") {
const app = new Hono<{ Variables: JuniorApiVariables }>();
@@ -150,6 +156,75 @@ describe("workspace admin API", () => {
);
});
+ it("rolls back recipe writes when repository replacement fails", async () => {
+ const executor = getSqlExecutor();
+ const original = await createWorkspace({
+ name: "original",
+ setupScript: "pnpm install",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/junior",
+ isPrimary: true,
+ },
+ ],
+ });
+
+ await executor.execute(`
+CREATE OR REPLACE FUNCTION junior_test_reject_workspace_repo()
+RETURNS trigger AS $$
+BEGIN
+ RAISE EXCEPTION 'reject workspace repo for rollback test';
+END;
+$$ LANGUAGE plpgsql
+`);
+ await executor.execute(`
+CREATE TRIGGER junior_test_reject_workspace_repo
+BEFORE INSERT ON junior_workspace_repos
+FOR EACH ROW EXECUTE FUNCTION junior_test_reject_workspace_repo()
+`);
+
+ try {
+ await expect(
+ createWorkspace({
+ name: "partial-create",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ isPrimary: true,
+ },
+ ],
+ }),
+ ).rejects.toThrow(/junior_workspace_repos/);
+ expect(
+ await getWorkspaceByName(getDb(), "partial-create"),
+ ).toBeUndefined();
+
+ await expect(
+ updateWorkspace(original.id, {
+ name: "partial-update",
+ setupScript: "changed",
+ repos: [
+ {
+ provider: "github",
+ repo: "getsentry/sentry",
+ isPrimary: true,
+ },
+ ],
+ }),
+ ).rejects.toThrow(/junior_workspace_repos/);
+ expect(await getWorkspace(getDb(), original.id)).toEqual(original);
+ } finally {
+ await executor.execute(
+ "DROP TRIGGER IF EXISTS junior_test_reject_workspace_repo ON junior_workspace_repos",
+ );
+ await executor.execute(
+ "DROP FUNCTION IF EXISTS junior_test_reject_workspace_repo()",
+ );
+ }
+ });
+
it("rejects duplicate names", async () => {
const app = authenticatedApi();
const body = {
From b6d585dcaf9c61cfd0e2a27f28027f69f5c51065 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 17:53:39 +0000
Subject: [PATCH 4/6] refactor(workspaces): Organize dashboard components
Co-Authored-By: David Cramer
---
packages/junior-dashboard/e2e/harness.ts | 3 +
packages/junior-dashboard/e2e/system.spec.ts | 49 ++
.../client/pages/system/WorkspaceEditor.tsx | 201 ++++++++
.../src/client/pages/system/WorkspaceList.tsx | 117 +++++
.../client/pages/system/WorkspacesPage.tsx | 454 +++---------------
.../src/client/pages/system/workspaceDraft.ts | 70 +++
packages/junior/src/api/schema.ts | 5 +-
packages/junior/src/api/schema/workspace.ts | 11 +-
packages/junior/src/chat/workspaces/store.ts | 47 +-
9 files changed, 534 insertions(+), 423 deletions(-)
create mode 100644 packages/junior-dashboard/src/client/pages/system/WorkspaceEditor.tsx
create mode 100644 packages/junior-dashboard/src/client/pages/system/WorkspaceList.tsx
create mode 100644 packages/junior-dashboard/src/client/pages/system/workspaceDraft.ts
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 61d38fdb7d..7e908334a1 100644
--- a/packages/junior-dashboard/e2e/system.spec.ts
+++ b/packages/junior-dashboard/e2e/system.spec.ts
@@ -67,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`);
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 (
+
+
+
+ );
+}
diff --git a/packages/junior-dashboard/src/client/pages/system/WorkspaceList.tsx b/packages/junior-dashboard/src/client/pages/system/WorkspaceList.tsx
new file mode 100644
index 0000000000..7cf8a95d9e
--- /dev/null
+++ b/packages/junior-dashboard/src/client/pages/system/WorkspaceList.tsx
@@ -0,0 +1,117 @@
+import { FolderGit2, Star, Trash2 } from "lucide-react";
+import type { WorkspaceReport } from "@sentry/junior/api/schema";
+
+import { Button } from "../../components/Button";
+import { EmptyTelemetry } from "../../components/EmptyTelemetry";
+import { Card } from "../../components/layout/Card";
+
+type WorkspaceListProps = {
+ busy: boolean;
+ onDelete(workspace: WorkspaceReport): void;
+ onEdit(workspace: WorkspaceReport): void;
+ workspaces: WorkspaceReport[];
+};
+
+/** List Workspace recipes and expose their edit and delete actions. */
+export function WorkspaceList(props: WorkspaceListProps) {
+ return (
+
+ {props.workspaces.length === 0 ? (
+
+
+ No Workspaces yet. Create one so agents can switch into a prepared
+ multi-repo Sandbox.
+
+
+ ) : (
+
+ {props.workspaces.map((workspace) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function WorkspaceListItem(props: {
+ busy: boolean;
+ onDelete(workspace: WorkspaceReport): void;
+ onEdit(workspace: WorkspaceReport): void;
+ workspace: WorkspaceReport;
+}) {
+ const { workspace } = props;
+ return (
+
+
+
+
+
+
+
+ {workspace.name}
+
+
+ {workspace.repos.length} repo
+ {workspace.repos.length === 1 ? "" : "s"}
+
+
+
+ {workspace.repos.length === 0 ? (
+
+ No repositories
+
+ ) : (
+ workspace.repos.map((repo) => (
+
+ {repo.isPrimary ? (
+
+ ) : null}
+ {repo.provider}:{repo.repo}
+
+ → {repo.checkoutPath}
+
+
+ ))
+ )}
+
+ {workspace.setupScript.trim() ? (
+
+ {workspace.setupScript.trim()}
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx b/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx
index 9d636e04dd..10c8ec8fd7 100644
--- a/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx
+++ b/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { FolderGit2, Plus, Star, Trash2 } from "lucide-react";
-import { useMemo, useState, type FormEvent } from "react";
+import { Plus } from "lucide-react";
+import { useState } from "react";
import {
workspaceListSchema,
workspaceSchema,
@@ -21,67 +21,18 @@ import {
put,
} from "../../http";
import { SystemPageLayout } from "./SystemPageLayout";
+import { WorkspaceEditor } from "./WorkspaceEditor";
+import { WorkspaceList } from "./WorkspaceList";
+import {
+ canSaveWorkspaceDraft,
+ createWorkspaceDraft,
+ editWorkspaceDraft,
+ type WorkspaceDraft,
+ workspaceDraftBody,
+} from "./workspaceDraft";
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;
@@ -95,11 +46,10 @@ function readApiError(error: unknown, fallback: string): string {
/** 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 [editingId, setEditingId] = useState();
+ const [draft, setDraft] = useState();
+ const [formError, setFormError] = useState();
+ const [actionError, setActionError] = useState();
const workspacesQuery = useQuery({
queryKey: workspacesQueryKey,
@@ -110,33 +60,30 @@ export function WorkspacesPage() {
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);
+ if (!draft) throw new Error("Workspace draft is required");
+ const body = workspaceDraftBody(draft);
+ return editingId
+ ? put(
+ workspaceSchema,
+ `/api/workspaces/${encodeURIComponent(editingId)}`,
+ body,
+ )
+ : post(workspaceSchema, "/api/workspaces", body);
},
onSuccess: async (workspace) => {
- setFormError(undefined);
+ closeEditor();
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 = [
+ (current) => ({
+ workspaces: [
workspace,
- ...existing.filter((item) => item.id !== workspace.id),
- ].sort((left, right) => left.name.localeCompare(right.name));
- return { workspaces: next };
- },
+ ...(current?.workspaces ?? []).filter(
+ (item) => item.id !== workspace.id,
+ ),
+ ].sort((left, right) => left.name.localeCompare(right.name)),
+ }),
);
},
onError: (error) => {
@@ -147,17 +94,15 @@ export function WorkspacesPage() {
});
const deleteMutation = useMutation({
- mutationFn: (workspace: WorkspaceReport) =>
- deleteDashboardResource(
+ mutationFn: async (workspace: WorkspaceReport) => {
+ await deleteDashboardResource(
`/api/workspaces/${encodeURIComponent(workspace.id)}`,
- ).then(() => workspace),
+ );
+ return workspace;
+ },
onSuccess: async (workspace) => {
setActionError(undefined);
- if (editingId === workspace.id) {
- setEditorOpen(false);
- setEditingId(null);
- setDraft(blankDraft());
- }
+ if (editingId === workspace.id) closeEditor();
await queryClient.cancelQueries({ queryKey: workspacesQueryKey });
queryClient.setQueryData<{ workspaces: WorkspaceReport[] }>(
workspacesQueryKey,
@@ -177,66 +122,33 @@ export function WorkspacesPage() {
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 closeEditor() {
+ setDraft(undefined);
+ setEditingId(undefined);
+ setFormError(undefined);
+ }
function openCreate() {
- setEditingId(null);
- setDraft(blankDraft());
+ setEditingId(undefined);
+ setDraft(createWorkspaceDraft());
setFormError(undefined);
- setEditorOpen(true);
}
function openEdit(workspace: WorkspaceReport) {
setEditingId(workspace.id);
- setDraft(draftFromWorkspace(workspace));
+ setDraft(editWorkspaceDraft(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();
+ function confirmDelete(workspace: WorkspaceReport) {
+ if (
+ window.confirm(
+ `Delete Workspace “${workspace.name}”? Active conversations keep their current Sandbox until the next switch.`,
+ )
+ ) {
+ deleteMutation.mutate(workspace);
+ }
}
if (!workspacesQuery.data && !workspacesQuery.error) {
@@ -274,247 +186,25 @@ export function WorkspacesPage() {
) : null}
- {editorOpen ? (
-
-
-
+ {draft ? (
+ saveMutation.mutate()}
+ />
) : null}
-
- {workspaces.length === 0 ? (
-
-
- No Workspaces yet. Create one so agents can switch into a prepared
- multi-repo Sandbox.
-
-
- ) : (
-
- )}
-
+
);
}
diff --git a/packages/junior-dashboard/src/client/pages/system/workspaceDraft.ts b/packages/junior-dashboard/src/client/pages/system/workspaceDraft.ts
new file mode 100644
index 0000000000..77e223b2da
--- /dev/null
+++ b/packages/junior-dashboard/src/client/pages/system/workspaceDraft.ts
@@ -0,0 +1,70 @@
+import type { WorkspaceReport } from "@sentry/junior/api/schema";
+
+export type RepoDraft = {
+ key: string;
+ provider: string;
+ repo: string;
+ isPrimary: boolean;
+};
+
+export type WorkspaceDraft = {
+ name: string;
+ setupScript: string;
+ repos: RepoDraft[];
+};
+
+export function createRepoDraft(isPrimary = false): RepoDraft {
+ return {
+ key: crypto.randomUUID(),
+ provider: "github",
+ repo: "",
+ isPrimary,
+ };
+}
+
+export function createWorkspaceDraft(): WorkspaceDraft {
+ return {
+ name: "",
+ setupScript: "",
+ repos: [createRepoDraft(true)],
+ };
+}
+
+export function editWorkspaceDraft(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,
+ }))
+ : [createRepoDraft(true)],
+ };
+}
+
+export function workspaceDraftBody(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,
+ })),
+ };
+}
+
+export function canSaveWorkspaceDraft(
+ draft: WorkspaceDraft,
+ busy: boolean,
+): boolean {
+ if (busy || !draft.name.trim()) return false;
+ if (draft.repos.some((repo) => !repo.provider.trim() || !repo.repo.trim())) {
+ return false;
+ }
+ return draft.repos.length === 0 || draft.repos.some((repo) => repo.isPrimary);
+}
diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts
index 2af0afb238..2bcfb5de49 100644
--- a/packages/junior/src/api/schema.ts
+++ b/packages/junior/src/api/schema.ts
@@ -108,10 +108,7 @@ export {
workspaceRepoSchema,
workspaceSchema,
} from "./schema/workspace";
-export type {
- WorkspaceListReport,
- WorkspaceReport,
-} from "./schema/workspace";
+export type { WorkspaceReport } from "./schema/workspace";
export {
eventTaskSummarySchema,
scheduledTaskSummarySchema,
diff --git a/packages/junior/src/api/schema/workspace.ts b/packages/junior/src/api/schema/workspace.ts
index f1ca15aa87..2c8a5ecaa3 100644
--- a/packages/junior/src/api/schema/workspace.ts
+++ b/packages/junior/src/api/schema/workspace.ts
@@ -2,8 +2,8 @@ import { z } from "zod";
const workspaceRepoInputSchema = z
.object({
- provider: z.string().trim().min(1).max(64),
- repo: z.string().trim().min(1).max(200),
+ provider: z.string(),
+ repo: z.string(),
isPrimary: z.boolean().optional(),
})
.strict();
@@ -32,9 +32,9 @@ export const workspaceListSchema = z
export const workspaceBodySchema = z
.object({
- name: z.string().trim().min(1).max(64),
- setupScript: z.string().max(65_536).optional(),
- repos: z.array(workspaceRepoInputSchema).max(32),
+ name: z.string(),
+ setupScript: z.string().optional(),
+ repos: z.array(workspaceRepoInputSchema),
})
.strict();
@@ -47,4 +47,3 @@ export const deleteWorkspaceResponseSchema = z
.strict();
export type WorkspaceReport = z.infer;
-export type WorkspaceListReport = z.infer;
diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts
index d0b119aabe..5d6f6f09b5 100644
--- a/packages/junior/src/chat/workspaces/store.ts
+++ b/packages/junior/src/chat/workspaces/store.ts
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { asc, eq } from "drizzle-orm";
import { getSqlExecutor } from "@/chat/db";
-import type { JuniorDatabase, JuniorSqlDatabase } from "@/db/db";
+import type { JuniorDatabase } from "@/db/db";
import { juniorWorkspaceRepos, juniorWorkspaces } from "@/db/schema";
import type { Workspace } from "./types";
import {
@@ -136,24 +136,18 @@ export async function getWorkspace(
}
/** Create one install-wide Workspace recipe. */
-export async function createWorkspace(
- input: {
- name: string;
- setupScript?: string;
- repos: Array<{
- provider: string;
- repo: string;
- isPrimary?: boolean;
- }>;
- },
- options: {
- executor?: JuniorSqlDatabase;
- now?: Date;
- } = {},
-): Promise {
+export async function createWorkspace(input: {
+ name: string;
+ setupScript?: string;
+ repos: Array<{
+ provider: string;
+ repo: string;
+ isPrimary?: boolean;
+ }>;
+}): Promise {
const recipe = normalizeWorkspaceRecipe(input);
- const executor = options.executor ?? getSqlExecutor();
- const now = options.now ?? new Date();
+ const executor = getSqlExecutor();
+ const now = new Date();
const id = randomUUID();
try {
@@ -192,14 +186,10 @@ export async function updateWorkspace(
isPrimary?: boolean;
}>;
},
- options: {
- executor?: JuniorSqlDatabase;
- now?: Date;
- } = {},
): Promise {
const recipe = normalizeWorkspaceRecipe(input);
- const executor = options.executor ?? getSqlExecutor();
- const now = options.now ?? new Date();
+ const executor = getSqlExecutor();
+ const now = new Date();
try {
const updated = await executor.transaction(async () => {
@@ -231,13 +221,8 @@ export async function updateWorkspace(
}
/** Delete one install-wide Workspace recipe. */
-export async function deleteWorkspace(
- id: string,
- options: {
- executor?: JuniorSqlDatabase;
- } = {},
-): Promise {
- const executor = options.executor ?? getSqlExecutor();
+export async function deleteWorkspace(id: string): Promise {
+ const executor = getSqlExecutor();
return await executor.transaction(async () => {
const rows = await executor
.db()
From 59d3da98a3f4fa59dbefa16774aad5f7f11f2a75 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 18:05:51 +0000
Subject: [PATCH 5/6] fix(dashboard): Keep Workspaces reachable without auth
Match People and Locations: /system/workspaces should render when
auth is disabled instead of bouncing to home via loggedIn.
---
packages/junior-dashboard/src/client/App.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/packages/junior-dashboard/src/client/App.tsx b/packages/junior-dashboard/src/client/App.tsx
index 762742c089..ccb3053b36 100644
--- a/packages/junior-dashboard/src/client/App.tsx
+++ b/packages/junior-dashboard/src/client/App.tsx
@@ -320,10 +320,8 @@ export function DashboardShell() {
- ) : loggedIn ? (
-
) : (
-
+
)
}
path="/system/workspaces"
From 2b08d8c866bd8b27e83360cd8dd476863d4068f1 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 18:25:03 +0000
Subject: [PATCH 6/6] fix(dashboard): Ignore stale Workspace save callbacks
Capture an editor session when opening or closing the draft. Save
success and error only touch editor state when that session is still
current, matching delete's guard against wiping a newer draft.
---
.../client/pages/system/WorkspacesPage.tsx | 36 +++++++++++++------
1 file changed, 26 insertions(+), 10 deletions(-)
diff --git a/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx b/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx
index 10c8ec8fd7..96f1fcd014 100644
--- a/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx
+++ b/packages/junior-dashboard/src/client/pages/system/WorkspacesPage.tsx
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
-import { useState } from "react";
+import { useRef, useState } from "react";
import {
workspaceListSchema,
workspaceSchema,
@@ -46,6 +46,7 @@ function readApiError(error: unknown, fallback: string): string {
/** Manage install-wide repository Workspace recipes. */
export function WorkspacesPage() {
const queryClient = useQueryClient();
+ const editorSessionRef = useRef(0);
const [editingId, setEditingId] = useState();
const [draft, setDraft] = useState();
const [formError, setFormError] = useState();
@@ -59,19 +60,23 @@ export function WorkspacesPage() {
});
const saveMutation = useMutation({
- mutationFn: async () => {
- if (!draft) throw new Error("Workspace draft is required");
- const body = workspaceDraftBody(draft);
- return editingId
+ mutationFn: async (input: {
+ draft: WorkspaceDraft;
+ editingId?: string;
+ session: number;
+ }) => {
+ const body = workspaceDraftBody(input.draft);
+ return input.editingId
? put(
workspaceSchema,
- `/api/workspaces/${encodeURIComponent(editingId)}`,
+ `/api/workspaces/${encodeURIComponent(input.editingId)}`,
body,
)
: post(workspaceSchema, "/api/workspaces", body);
},
- onSuccess: async (workspace) => {
- closeEditor();
+ onSuccess: async (workspace, input) => {
+ // Only dismiss the editor that started this save.
+ if (editorSessionRef.current === input.session) closeEditor();
setActionError(undefined);
await queryClient.cancelQueries({ queryKey: workspacesQueryKey });
queryClient.setQueryData<{ workspaces: WorkspaceReport[] }>(
@@ -86,7 +91,8 @@ export function WorkspacesPage() {
}),
);
},
- onError: (error) => {
+ onError: (error, input) => {
+ if (editorSessionRef.current !== input.session) return;
setFormError(
readApiError(error, "Could not save the Workspace. Try again."),
);
@@ -124,18 +130,21 @@ export function WorkspacesPage() {
const busy = saveMutation.isPending || deleteMutation.isPending;
function closeEditor() {
+ editorSessionRef.current += 1;
setDraft(undefined);
setEditingId(undefined);
setFormError(undefined);
}
function openCreate() {
+ editorSessionRef.current += 1;
setEditingId(undefined);
setDraft(createWorkspaceDraft());
setFormError(undefined);
}
function openEdit(workspace: WorkspaceReport) {
+ editorSessionRef.current += 1;
setEditingId(workspace.id);
setDraft(editWorkspaceDraft(workspace));
setFormError(undefined);
@@ -195,7 +204,14 @@ export function WorkspacesPage() {
error={formError}
onCancel={closeEditor}
onChange={setDraft}
- onSubmit={() => saveMutation.mutate()}
+ onSubmit={() => {
+ if (!draft) return;
+ saveMutation.mutate({
+ draft,
+ editingId,
+ session: editorSessionRef.current,
+ });
+ }}
/>
) : null}