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
22 changes: 21 additions & 1 deletion apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,30 @@ describe("gen2 git route", () => {
const response = await GET(new Request(`${url}?operation=status`), {
params,
});
expect(mocks.git).toHaveBeenCalledWith(workspaceId, userId, "status");
expect(mocks.git).toHaveBeenCalledWith(
workspaceId,
userId,
"status",
"main",
);
expect(await response.json()).toEqual({ output: "## main\n M a.ts\n" });
});

it("reads Git state from the selected worktree", async () => {
mocks.git.mockResolvedValue("## codev/agent-a\n");
const response = await GET(
new Request(`${url}?operation=status&worktreeId=agent-a`),
{ params },
);
expect(mocks.git).toHaveBeenCalledWith(
workspaceId,
userId,
"status",
"agent-a",
);
expect(await response.json()).toEqual({ output: "## codev/agent-a\n" });
});

it("rejects an operation it does not serve", async () => {
const response = await GET(new Request(`${url}?operation=push`), {
params,
Expand Down
24 changes: 21 additions & 3 deletions apps/web/app/api/gen2/workspaces/[workspaceId]/git/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { gen2FilePathSchema, gen2GitOperationSchema } from "@codev/contracts";
import {
gen2FilePathSchema,
gen2GitOperationSchema,
gen2SupersetWorktreeIdSchema,
} from "@codev/contracts";

import { ApiError, withUser } from "@/lib/http/api-route";
import { getGen2Git, showGen2HeadFile } from "@/lib/gen2/workbench";
Expand All @@ -13,10 +17,19 @@ export const GET = withUser<Params>(
const operation = gen2GitOperationSchema.safeParse(
url.searchParams.get("operation"),
);
if (!operation.success) {
const worktreeId = gen2SupersetWorktreeIdSchema.safeParse(
url.searchParams.get("worktreeId") ?? "main",
);
if (!operation.success || !worktreeId.success) {
throw new ApiError("operation must be status, diff, or show.", 400);
}
if (operation.data === "show") {
if (worktreeId.data !== "main") {
throw new ApiError(
"show is only available for the primary worktree.",
400,
);
}
const path = gen2FilePathSchema.safeParse(url.searchParams.get("path"));
if (!path.success) {
throw new ApiError("A valid workspace path is required.", 400);
Expand All @@ -26,7 +39,12 @@ export const GET = withUser<Params>(
);
}
return Response.json({
output: await getGen2Git(workspaceId, user.id, operation.data),
output: await getGen2Git(
workspaceId,
user.id,
operation.data,
worktreeId.data,
),
});
},
{ errorStatus: 502 },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getApiUser: vi.fn(),
list: vi.fn(),
create: vi.fn(),
}));

vi.mock("@/lib/http/api", () => ({
apiError: (error: unknown, status = 400) =>
Response.json(
{ error: error instanceof Error ? error.message : "request failed" },
{ status },
),
getApiUser: mocks.getApiUser,
getApiUserAnyAuth: mocks.getApiUser,
}));
vi.mock("@/lib/gen2/superset", () => ({
listGen2SupersetWorktrees: mocks.list,
createGen2SupersetWorktree: mocks.create,
}));

import { GET, POST, maxDuration } from "./route";

const workspaceId = "e010bd2c-a3c1-438f-acef-166287a3b1cb";
const userId = "2f2387ed-4a63-4b05-88cc-266d65f7b82b";
const params = Promise.resolve({ workspaceId });
const url = `https://codev.test/api/gen2/workspaces/${workspaceId}/superset/worktrees`;

describe("Superset worktrees route", () => {
beforeEach(() => {
mocks.getApiUser.mockResolvedValue({ id: userId });
});
afterEach(() => vi.resetAllMocks());

it("keeps the host bridge's workspace operation time budget", () => {
expect(maxDuration).toBe(60);
});

it("lists selectable worktrees", async () => {
mocks.list.mockResolvedValue([{ worktreeId: "main", branch: "main" }]);
const response = await GET(new Request(url), { params });
expect(mocks.list).toHaveBeenCalledWith(workspaceId, userId);
expect(await response.json()).toEqual({
worktrees: [{ worktreeId: "main", branch: "main" }],
});
});

it("validates a worktree before creating it", async () => {
const response = await POST(
new Request(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ worktreeId: "../escape", branch: "codev/a" }),
}),
{ params },
);
expect(response.status).toBe(400);
expect(mocks.create).not.toHaveBeenCalled();
});

it("creates a branch worktree through the authorized library", async () => {
const worktree = { worktreeId: "agent-a", branch: "codev/agent-a" };
mocks.create.mockResolvedValue(worktree);
const response = await POST(
new Request(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...worktree, baseRef: "HEAD" }),
}),
{ params },
);
expect(response.status).toBe(201);
expect(mocks.create).toHaveBeenCalledWith(workspaceId, userId, {
...worktree,
baseRef: "HEAD",
});
expect(await response.json()).toEqual({ worktree });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { gen2SupersetWorktreeCreateRequestSchema } from "@codev/contracts";

import {
createGen2SupersetWorktree,
listGen2SupersetWorktrees,
} from "@/lib/gen2/superset";
import { readJson, withUser } from "@/lib/http/api-route";

type Params = { workspaceId: string };

export const maxDuration = 60;

export const GET = withUser<Params>(
async ({ user, params: { workspaceId } }) =>
Response.json({
worktrees: await listGen2SupersetWorktrees(workspaceId, user.id),
}),
{ errorStatus: 502 },
);

export const POST = withUser<Params>(
async ({ request, user, params: { workspaceId } }) => {
const input = await readJson(
request,
gen2SupersetWorktreeCreateRequestSchema,
);
return Response.json(
{
worktree: await createGen2SupersetWorktree(workspaceId, user.id, input),
},
{ status: 201 },
);
},
{ errorStatus: 502 },
);
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ describe("gen2 terminal route", () => {
expect(await response.json()).toEqual({ sessionId: "term-1-2" });
});

it("forwards a selected worktree to the terminal service", async () => {
mocks.start.mockResolvedValue("term-1-2");
await POST(
post({ action: "start", rows: 24, columns: 80, worktreeId: "agent-a" }),
{ params },
);
expect(mocks.start).toHaveBeenCalledWith(workspaceId, userId, {
action: "start",
rows: 24,
columns: 80,
worktreeId: "agent-a",
});
});

it("refuses a viewport the guest would reject anyway", async () => {
const response = await POST(
post({ action: "start", rows: 24, columns: 501 }),
Expand Down Expand Up @@ -90,6 +104,7 @@ describe("gen2 terminal route", () => {
userId,
"term-1-2",
"ls\n",
"main",
);

const resize = await POST(
Expand Down Expand Up @@ -120,6 +135,11 @@ describe("gen2 terminal route", () => {
{ params },
);
expect(response.status).toBe(204);
expect(mocks.close).toHaveBeenCalledWith(workspaceId, userId, "term-1-2");
expect(mocks.close).toHaveBeenCalledWith(
workspaceId,
userId,
"term-1-2",
"main",
);
});
});
23 changes: 20 additions & 3 deletions apps/web/app/api/gen2/workspaces/[workspaceId]/terminal/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
gen2TerminalActionSchema,
gen2TerminalSessionIdSchema,
gen2SupersetWorktreeIdSchema,
} from "@codev/contracts";

import { ApiError, readJson, withUser } from "@/lib/http/api-route";
Expand Down Expand Up @@ -31,10 +32,17 @@ export const POST = withUser<Params>(
user.id,
input.sessionId,
input.data,
input.worktreeId,
);
return new Response(null, { status: 204 });
case "resize":
await resizeGen2Terminal(workspaceId, user.id, input.sessionId, input);
await resizeGen2Terminal(
workspaceId,
user.id,
input.sessionId,
input,
input.worktreeId,
);
return new Response(null, { status: 204 });
case "poll":
return Response.json(
Expand All @@ -43,6 +51,7 @@ export const POST = withUser<Params>(
user.id,
input.sessionId,
input.after,
input.worktreeId,
),
);
}
Expand All @@ -55,10 +64,18 @@ export const DELETE = withUser<Params>(
const sessionId = gen2TerminalSessionIdSchema.safeParse(
new URL(request.url).searchParams.get("sessionId"),
);
if (!sessionId.success) {
const worktreeId = gen2SupersetWorktreeIdSchema.safeParse(
new URL(request.url).searchParams.get("worktreeId") ?? "main",
);
if (!sessionId.success || !worktreeId.success) {
throw new ApiError("A valid terminal session is required.", 400);
}
await closeGen2Terminal(workspaceId, user.id, sessionId.data);
await closeGen2Terminal(
workspaceId,
user.id,
sessionId.data,
worktreeId.data,
);
return new Response(null, { status: 204 });
},
{ errorStatus: 502 },
Expand Down
10 changes: 10 additions & 0 deletions apps/web/lib/gen2/superset-runtime-feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import "server-only";

/**
* The file bridge and the broader Superset runtime migrate independently.
* Keep terminal/Git/worktree replacement opt-in until it has passed a real
* workspace acceptance run and the old guest endpoints can be retired.
*/
export function isGen2SupersetRuntimeEnabled() {
return process.env.CODEV_SUPERSET_RUNTIME_ENABLED === "true";
}
91 changes: 91 additions & 0 deletions apps/web/lib/gen2/superset-worktrees.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
member: vi.fn(),
list: vi.fn(),
create: vi.fn(),
}));

vi.mock("./workspaces", () => ({
requireGen2Member: (...args: unknown[]) => mocks.member(...args),
}));

vi.mock("../runtime/orchestrator-superset-runtime", () => ({
listSupersetWorktrees: (...args: unknown[]) => mocks.list(...args),
createSupersetWorktree: (...args: unknown[]) => mocks.create(...args),
}));

vi.mock("../runtime/orchestrator-request", () => ({
OrchestratorError: class OrchestratorError extends Error {},
orchestratorRequest: vi.fn(),
}));

const { createGen2SupersetWorktree, listGen2SupersetWorktrees } =
await import("./superset");

const workspaceId = "11111111-1111-4111-8111-111111111111";
const userId = "22222222-2222-4222-8222-222222222222";
const originalSupersetRuntime = process.env.CODEV_SUPERSET_RUNTIME_ENABLED;

describe("Gen 2 Superset worktrees", () => {
beforeEach(() => {
vi.resetAllMocks();
process.env.CODEV_SUPERSET_RUNTIME_ENABLED = "true";
mocks.member.mockResolvedValue({ status: "ready", role: "owner" });
});

it("does not expose runtime worktrees while the migration flag is off", async () => {
delete process.env.CODEV_SUPERSET_RUNTIME_ENABLED;
await expect(
listGen2SupersetWorktrees(workspaceId, userId),
).rejects.toThrow(/not enabled/);
expect(mocks.member).not.toHaveBeenCalled();
expect(mocks.list).not.toHaveBeenCalled();
});

it("checks membership before calling the private host bridge", async () => {
mocks.member.mockRejectedValue(new Error("not a member"));
await expect(
listGen2SupersetWorktrees(workspaceId, userId),
).rejects.toThrow("not a member");
expect(mocks.list).not.toHaveBeenCalled();
});

it("does not let a viewer create an isolated checkout", async () => {
mocks.member.mockResolvedValue({ status: "ready", role: "viewer" });
await expect(
createGen2SupersetWorktree(workspaceId, userId, {
worktreeId: "agent-a",
branch: "codev/agent-a",
}),
).rejects.toThrow(/Edit permission/);
expect(mocks.create).not.toHaveBeenCalled();
});

it("creates and returns a host-owned worktree for an editor", async () => {
mocks.create.mockResolvedValue({
worktreeId: "agent-a",
branch: "codev/agent-a",
});
await expect(
createGen2SupersetWorktree(workspaceId, userId, {
worktreeId: "agent-a",
branch: "codev/agent-a",
baseRef: "HEAD",
}),
).resolves.toEqual({ worktreeId: "agent-a", branch: "codev/agent-a" });
expect(mocks.create).toHaveBeenCalledWith(workspaceId, {
worktreeId: "agent-a",
branch: "codev/agent-a",
baseRef: "HEAD",
});
});
});

afterEach(() => {
if (originalSupersetRuntime === undefined) {
delete process.env.CODEV_SUPERSET_RUNTIME_ENABLED;
} else {
process.env.CODEV_SUPERSET_RUNTIME_ENABLED = originalSupersetRuntime;
}
});
Loading
Loading