Compare commits

..

3 Commits

Author SHA1 Message Date
Claude
efe30df03a
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
2026-10-03 19:29:03 +00:00
Claude
cb3d65be2a
fix: open the screen beside the chat even before the computer status catches up
A bot that boots its computer inside a run emits no computer.status event,
so the client still saw a stopped computer and never opened the screen for
screen work or a takeover. Pointer activity and takeover requests now count
as proof of a running screen; desktop hosts and bots without a computer
still never get one.

A presented file is now the newest one since the user last wrote, so another
member's run in a group no longer hides it. Only panes the work opened get
the wide width, so switching from the computer to routines or settings no
longer animates the width. E2E specs follow the new behaviour: the takeover
screen opens on its own and group Markdown previews open beside the chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
2026-10-03 19:12:51 +00:00
cynarAI
a15da068d7
fix(api): check a project folder before creating the space (#114)
spaces.create created the space first and only then checked the folder,
so a folder that was missing or not a folder returned 400 but left the
new space behind. The New project dialog shows that error, so fixing the
path and submitting again created a second project with the same name.

The folder is now resolved and checked right after the deployment-owner
check, before the space exists, and the space stores that resolved path.
wireProjectWorkspace takes the resolved folder instead of resolving it
again after the space was created. The onboarding scan, its other
caller, resolves and checks each repository folder the same way before
creating the project, and skips a folder that cannot be linked.

The authorization test has the deployment owner submit a missing folder
and a file (both 400) and then the real folder under the same name, and
checks that exactly one project with that name exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HG4pFTUBDpuCS2Y44TV8x
2026-10-03 20:35:29 +02:00
10 changed files with 100 additions and 48 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

@ -69,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
})
.toBe("waiting_takeover");
await captureScreenshot(page, testInfo, "08-protected-input-request");
await page.getByTitle("Agent computer").click();
// A takeover request brings the bot's screen beside the chat on its own.
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveCSS("width", "384px");
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
const [mainBox, panelBox] = await Promise.all([
page.locator("main").boundingBox(),
sidePanel.boundingBox(),

View File

@ -303,9 +303,11 @@ test("create group from + and see two bots in one transcript", async ({ page },
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
await groupMarkdown.click();
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" });
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownDialog.getByRole("button", { name: "Close preview" }).click();
// Wide windows open the file beside the group conversation.
const markdownPane = page.getByTestId("artifact-pane");
await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownPane.getByRole("button", { name: "Close panel" }).click();
await expect(markdownPane).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();

View File

@ -3370,7 +3370,9 @@ export function ShellPage() {
}
const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
const workspacePaneOpen = panel === "computer" || panel === "artifact";
// Only what the work opened gets the wide pane; settings, routines and a hand-opened screen keep
// one width so switching between them never jumps.
const workspacePaneOpen = panel === "artifact" || (panel === "computer" && autoPanel === panel);
const hasControl = userHoldsComputerControl(computer, active?.id);
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
const computerScreenError =

View File

@ -28,6 +28,7 @@ function snapshot(overrides: Partial<WorkspaceFocusSnapshot> = {}): WorkspaceFoc
const report = {
id: "msg-report",
role: "bot",
runId: "run-1",
blocks: [
{
@ -73,7 +74,7 @@ describe("deriveWorkspaceFocus", () => {
for (const computer of [
{ ...runningComputer, kind: "desktop" },
{ ...runningComputer, kind: "none" },
{ ...runningComputer, screenAvailable: false },
null,
]) {
expect(
deriveWorkspaceFocus(snapshot({ computer }), { screenRunId: "run-1" }).pane,
@ -81,6 +82,25 @@ describe("deriveWorkspaceFocus", () => {
}
});
it("trusts screen work over a computer status that has not caught up with the boot", () => {
const stale = { ...runningComputer, state: "stopped", screenAvailable: false };
expect(deriveWorkspaceFocus(snapshot({ computer: stale }), { screenRunId: "run-1" }).pane).toBe(
"screen",
);
expect(
deriveWorkspaceFocus(
snapshot({
run: { id: "run-1", status: "waiting_takeover" },
computer: { ...stale, takeoverRequested: true },
}),
).pane,
).toBe("screen");
// Without evidence of screen work, a learned default never boots a stopped computer.
expect(
deriveWorkspaceFocus(snapshot({ computer: stale }), { screenMode: "always" }).pane,
).toBeNull();
});
it("shows the screen for a takeover request even when the user turned auto off", () => {
const focus = deriveWorkspaceFocus(
snapshot({
@ -104,7 +124,7 @@ describe("deriveWorkspaceFocus", () => {
).toBeNull();
});
it("shows a file the latest run presented", () => {
it("shows the file the bot presented as its latest result", () => {
const focus = deriveWorkspaceFocus(
snapshot({ run: { id: "run-1", status: "completed" }, messages: [report] }),
);
@ -118,17 +138,20 @@ describe("deriveWorkspaceFocus", () => {
expect(focus.key).toBe("artifact:art-1|ask:none");
});
it("ignores files that were only attached, presented by older runs, or not previewable", () => {
it("ignores files that were only attached, presented before the user wrote again, or not previewable", () => {
const attached = {
...report,
blocks: [{ ...report.blocks[0]!, present: undefined }],
};
expect(deriveWorkspaceFocus(snapshot({ messages: [attached] })).pane).toBeNull();
const followUp = { id: "msg-user", role: "user", runId: "run-2", blocks: [{ kind: "text" }] };
expect(deriveWorkspaceFocus(snapshot({ messages: [report, followUp] })).pane).toBeNull();
// Another member's newer run in a group does not hide the result.
expect(
deriveWorkspaceFocus(
snapshot({ run: { id: "run-2", status: "running" }, messages: [report] }),
snapshot({ run: { id: "run-9", status: "running" }, messages: [report] }),
).pane,
).toBeNull();
).toBe("artifact");
const pdf = {
...report,
blocks: [{ ...report.blocks[0]!, mimeType: "application/pdf", name: "report.pdf" }],
@ -181,7 +204,10 @@ describe("applyWorkspaceFocus", () => {
it("keeps a presented file open while the user follows up", () => {
const followUp = deriveWorkspaceFocus(
snapshot({ run: { id: "run-2", status: "running" }, messages: [report] }),
snapshot({
run: { id: "run-2", status: "running" },
messages: [report, { id: "msg-user", role: "user", blocks: [{ kind: "text" }] }],
}),
);
expect(followUp.pane).toBeNull();
expect(applyWorkspaceFocus(artifact, followUp, { open: "artifact", auto: true })).toBeNull();

View File

@ -37,7 +37,12 @@ type FocusBlock = {
type FocusRun = { id: string; status: string };
export type WorkspaceFocusSnapshot = {
messages: readonly { id: string; runId?: string | null; blocks: readonly FocusBlock[] }[];
messages: readonly {
id: string;
role?: string;
runId?: string | null;
blocks: readonly FocusBlock[];
}[];
run?: FocusRun | null;
activeRuns?: readonly FocusRun[];
computer?: {
@ -89,12 +94,12 @@ function workingRunIds(snapshot: WorkspaceFocusSnapshot): Set<string> {
return new Set(runs.filter((run) => WORKING_RUN_STATUSES.has(run.status)).map((run) => run.id));
}
/** The newest file a bot presented since the user last wrote; a new request starts fresh. */
function presentedArtifact(snapshot: WorkspaceFocusSnapshot): WorkspaceArtifact | null {
const latestRunId = snapshot.run?.id;
if (!latestRunId) return null;
for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) {
const message = snapshot.messages[index];
if (!message || message.runId !== latestRunId) continue;
if (!message) continue;
if (message.role === "user") return null;
for (let blockIndex = message.blocks.length - 1; blockIndex >= 0; blockIndex -= 1) {
const block = message.blocks[blockIndex];
if (
@ -119,7 +124,7 @@ function presentedArtifact(snapshot: WorkspaceFocusSnapshot): WorkspaceArtifact
/**
* Derives the pane a conversation should show from what the bot is actually doing: a
* takeover request or live work on its screen shows the screen, a file the bot presented
* as its result shows that file, and everything else stays a plain conversation.
* as its latest result shows that file, and everything else stays a plain conversation.
*/
export function deriveWorkspaceFocus(
snapshot: WorkspaceFocusSnapshot | null,
@ -134,16 +139,19 @@ export function deriveWorkspaceFocus(
activeRuns: snapshot.activeRuns,
});
const computer = snapshot.computer;
const screenViewable =
Boolean(computer?.screenAvailable) && computer?.kind !== "desktop" && computer?.kind !== "none";
const takeover = Boolean(computer?.takeoverRequested) && screenViewable;
const working = workingRunIds(snapshot);
const mode = live.screenMode ?? "auto";
const pointerWork = live.screenRunId != null && working.has(live.screenRunId);
const pointerWork =
Boolean(live.screenActive) || (live.screenRunId != null && working.has(live.screenRunId));
// A desktop host or a bot without a computer has no screen to embed.
const embeddable = Boolean(computer) && computer?.kind !== "desktop" && computer?.kind !== "none";
// A bot that points at its screen or asks for a takeover has a running screen, even when the
// client's computer status has not caught up with a boot that happened inside the run.
const takeover = embeddable && Boolean(computer?.takeoverRequested);
const screenWork =
screenViewable &&
embeddable &&
mode !== "never" &&
((working.size > 0 && (mode === "always" || pointerWork)) || Boolean(live.screenActive));
(pointerWork || (mode === "always" && working.size > 0 && Boolean(computer?.screenAvailable)));
const artifact = presentedArtifact(snapshot);
const pane: WorkspacePane | null =
takeover || screenWork ? "screen" : artifact ? "artifact" : null;

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,