Merge main into the adaptive workspace branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
This commit is contained in:
Claude 2026-10-03 19:29:03 +00:00
commit efe30df03a
No known key found for this signature in database
5 changed files with 38 additions and 25 deletions

View File

@ -1,13 +1,14 @@
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
// Runs synchronously on the host machine (no sandbox needed).
import { spawn } from "node:child_process";
import { access, readdir, realpath, stat } from "node:fs/promises";
import { access, readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
createSpaceForMember,
InvalidSpaceNameError,
resolveProjectWorkspaceDirectory,
SpaceLimitError,
wireProjectWorkspace,
} from "@rakazo/db";
@ -656,7 +657,8 @@ async function createProjects(
for (const repo of repos) {
// Compare and link the real folder, so the next scan recognizes the projects of this one.
const folder = await realpath(repo.path).catch(() => null);
// Checking it before the project exists means a folder that cannot be linked leaves none.
const folder = await resolveProjectWorkspaceDirectory(repo.path).catch(() => null);
if (!folder || linkedFolders.has(folder)) continue;
let space: { id: string; name: string };
try {

View File

@ -137,6 +137,7 @@ import {
releaseSpaceDeletionClaim,
renewSpaceDeletionClaim,
requireMembership,
resolveProjectWorkspaceDirectory,
SPACE_DELETION_CLAIM_TIMEOUT_MS,
SpaceDeletionInProgressError,
SpaceLimitError,
@ -573,6 +574,18 @@ export function createRouter(deps: RouterDeps) {
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
// Check the folder before the space exists, so a bad path leaves no space behind.
let workspacePath: string | null = null;
if (input.workspacePath) {
try {
workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath);
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
let space: { id: string; name: string };
try {
space = await createSpaceForMember(deps.prisma, {
@ -586,20 +599,9 @@ export function createRouter(deps: RouterDeps) {
}
throw error;
}
let workspace = null;
if (input.workspacePath) {
try {
workspace = await wireProjectWorkspace(deps.prisma, {
spaceId: space.id,
workspacePath: input.workspacePath,
});
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
const workspace = workspacePath
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
: null;
return {
id: space.id,
name: space.name,

View File

@ -72,7 +72,7 @@ describe("project workspace wiring", () => {
const wired = await wireProjectWorkspace(prisma as unknown as Pick<PrismaClient, "space">, {
spaceId: "space-1",
workspacePath: root,
workspacePath: await resolveProjectWorkspaceDirectory(root),
});
expect(wired.path).toBe(realpathSync(root));
expect(wired.git).toEqual({

View File

@ -50,18 +50,17 @@ export interface WiredProjectWorkspace {
} | null;
}
/** Persist a host folder and optional git metadata on a newly created space. */
/** Persist a folder from `resolveProjectWorkspaceDirectory`, and its git metadata, on a new space. */
export async function wireProjectWorkspace(
prisma: Pick<PrismaClient, "space">,
input: { spaceId: string; workspacePath: string },
): Promise<WiredProjectWorkspace> {
const resolved = await resolveProjectWorkspaceDirectory(input.workspacePath);
const git = await readGitCheckout(resolved);
const git = await readGitCheckout(input.workspacePath);
await prisma.space.update({
where: { id: input.spaceId },
data: {
workspacePath: resolved,
workspacePath: input.workspacePath,
gitRemoteUrl: git.present && git.remoteUrl ? git.remoteUrl : null,
gitDefaultBranch: git.present && git.defaultBranch ? git.defaultBranch : null,
gitHeadCommit: git.present ? git.headCommit : null,
@ -69,7 +68,7 @@ export async function wireProjectWorkspace(
});
return {
path: resolved,
path: input.workspacePath,
git:
git.present && git.remoteUrl && git.defaultBranch
? {

View File

@ -2239,15 +2239,25 @@ describeWithDatabase("API authorization and resource isolation", () => {
const legacyNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list");
expect(legacyNavigation.spaces.find((space) => space.id === legacy.id)?.workspace).toBeNull();
// A bad folder is rejected before the project exists, so fixing it and retrying leaves one.
for (const badPath of [path.join(hostFolder, "missing"), path.join(hostFolder, ".env")]) {
const rejected = await raw(app, owner, "spaces/create", {
name: "Owner folder",
workspacePath: badPath,
});
expect(rejected.status, badPath).toBe(400);
}
const linked = await rpc<Space>(app, owner, "spaces/create", {
name: "Owner folder",
workspacePath: hostFolder,
});
expect(linked.workspace?.path).toBe(hostFolder);
const ownerNavigation = await rpc<SpaceNavigation>(app, owner, "spaces/list");
expect(ownerNavigation.spaces.find((space) => space.id === linked.id)?.workspace?.path).toBe(
hostFolder,
);
expect(
ownerNavigation.spaces
.filter((space) => space.name === "Owner folder")
.map((space) => space.workspace?.path),
).toEqual([hostFolder]);
const ownerBot = await rpc<Bot>(
app,
owner,