Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/docs/src/content/docs/operate/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/src/content/docs/operate/sandbox-snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/junior-dashboard/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
51 changes: 51 additions & 0 deletions packages/junior-dashboard/e2e/system.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ test("shows system usage and plugin details", async ({ page }) => {
"Overview",
"People",
"Locations",
"Workspaces",
"Plugins",
]);
const pluginsLink = systemNavigation.getByRole("link", {
Expand Down Expand Up @@ -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`);
Expand All @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions packages/junior-dashboard/src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -313,6 +314,18 @@ export function DashboardShell() {
}
path="/system/people"
/>
<Route
element={
loading ? (
<SystemPageLayout>
<LoadingView label="Loading Workspaces" />
</SystemPageLayout>
) : (
<WorkspacesPage />
)
}
path="/system/workspaces"
/>
<Route
element={
loading ? (
Expand Down
47 changes: 40 additions & 7 deletions packages/junior-dashboard/src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,29 @@ import type { ZodType } from "zod";
/** An authenticated dashboard request rejected by the product API. */
export class DashboardApiError extends Error {
readonly status: number;
readonly apiError?: string;

constructor(path: string, status: number) {
constructor(path: string, status: number, apiError?: string) {
super(`${path} returned ${status}`);
this.status = status;
if (apiError?.trim()) this.apiError = apiError.trim();
}
}

async function throwDashboardApiError(
path: string,
response: Response,
): Promise<never> {
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;
Expand Down Expand Up @@ -45,7 +61,7 @@ export async function patch<T>(
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());
}

Expand All @@ -62,7 +78,24 @@ export async function post<T>(
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<T>(
schema: ZodType<T>,
path: string,
body: unknown,
): Promise<T> {
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());
}

Expand All @@ -73,7 +106,7 @@ export async function deleteDashboardResource(path: string): Promise<void> {
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. */
Expand All @@ -89,7 +122,7 @@ export async function del<T>(
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());
}

Expand All @@ -105,8 +138,8 @@ export async function fetchDashboardJson<T>(
});
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());
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
];

Expand Down
Loading
Loading