Compare commits

..

No commits in common. "efe30df03a959b1bea0262f8decaec7c2fff4b96" and "065b1322bbc54d244dbbde3b3c83e030de1827ac" have entirely different histories.

10 changed files with 48 additions and 100 deletions

View File

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

View File

@ -137,7 +137,6 @@ import {
releaseSpaceDeletionClaim, releaseSpaceDeletionClaim,
renewSpaceDeletionClaim, renewSpaceDeletionClaim,
requireMembership, requireMembership,
resolveProjectWorkspaceDirectory,
SPACE_DELETION_CLAIM_TIMEOUT_MS, SPACE_DELETION_CLAIM_TIMEOUT_MS,
SpaceDeletionInProgressError, SpaceDeletionInProgressError,
SpaceLimitError, SpaceLimitError,
@ -574,18 +573,6 @@ export function createRouter(deps: RouterDeps) {
if (input.workspacePath && !context.actor.isDeploymentOwner) { if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN"); 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 }; let space: { id: string; name: string };
try { try {
space = await createSpaceForMember(deps.prisma, { space = await createSpaceForMember(deps.prisma, {
@ -599,9 +586,20 @@ export function createRouter(deps: RouterDeps) {
} }
throw error; throw error;
} }
const workspace = workspacePath let workspace = null;
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath }) if (input.workspacePath) {
: null; 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;
}
}
return { return {
id: space.id, id: space.id,
name: space.name, name: space.name,

View File

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

View File

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

View File

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

View File

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

View File

@ -37,12 +37,7 @@ type FocusBlock = {
type FocusRun = { id: string; status: string }; type FocusRun = { id: string; status: string };
export type WorkspaceFocusSnapshot = { export type WorkspaceFocusSnapshot = {
messages: readonly { messages: readonly { id: string; runId?: string | null; blocks: readonly FocusBlock[] }[];
id: string;
role?: string;
runId?: string | null;
blocks: readonly FocusBlock[];
}[];
run?: FocusRun | null; run?: FocusRun | null;
activeRuns?: readonly FocusRun[]; activeRuns?: readonly FocusRun[];
computer?: { computer?: {
@ -94,12 +89,12 @@ function workingRunIds(snapshot: WorkspaceFocusSnapshot): Set<string> {
return new Set(runs.filter((run) => WORKING_RUN_STATUSES.has(run.status)).map((run) => run.id)); 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 { 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) { for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) {
const message = snapshot.messages[index]; const message = snapshot.messages[index];
if (!message) continue; if (!message || message.runId !== latestRunId) continue;
if (message.role === "user") return null;
for (let blockIndex = message.blocks.length - 1; blockIndex >= 0; blockIndex -= 1) { for (let blockIndex = message.blocks.length - 1; blockIndex >= 0; blockIndex -= 1) {
const block = message.blocks[blockIndex]; const block = message.blocks[blockIndex];
if ( if (
@ -124,7 +119,7 @@ function presentedArtifact(snapshot: WorkspaceFocusSnapshot): WorkspaceArtifact
/** /**
* Derives the pane a conversation should show from what the bot is actually doing: a * 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 * takeover request or live work on its screen shows the screen, a file the bot presented
* as its latest result shows that file, and everything else stays a plain conversation. * as its result shows that file, and everything else stays a plain conversation.
*/ */
export function deriveWorkspaceFocus( export function deriveWorkspaceFocus(
snapshot: WorkspaceFocusSnapshot | null, snapshot: WorkspaceFocusSnapshot | null,
@ -139,19 +134,16 @@ export function deriveWorkspaceFocus(
activeRuns: snapshot.activeRuns, activeRuns: snapshot.activeRuns,
}); });
const computer = snapshot.computer; 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 working = workingRunIds(snapshot);
const mode = live.screenMode ?? "auto"; const mode = live.screenMode ?? "auto";
const pointerWork = const pointerWork = live.screenRunId != null && working.has(live.screenRunId);
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 = const screenWork =
embeddable && screenViewable &&
mode !== "never" && mode !== "never" &&
(pointerWork || (mode === "always" && working.size > 0 && Boolean(computer?.screenAvailable))); ((working.size > 0 && (mode === "always" || pointerWork)) || Boolean(live.screenActive));
const artifact = presentedArtifact(snapshot); const artifact = presentedArtifact(snapshot);
const pane: WorkspacePane | null = const pane: WorkspacePane | null =
takeover || screenWork ? "screen" : artifact ? "artifact" : 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">, { const wired = await wireProjectWorkspace(prisma as unknown as Pick<PrismaClient, "space">, {
spaceId: "space-1", spaceId: "space-1",
workspacePath: await resolveProjectWorkspaceDirectory(root), workspacePath: root,
}); });
expect(wired.path).toBe(realpathSync(root)); expect(wired.path).toBe(realpathSync(root));
expect(wired.git).toEqual({ expect(wired.git).toEqual({

View File

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

View File

@ -2239,25 +2239,15 @@ describeWithDatabase("API authorization and resource isolation", () => {
const legacyNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list"); const legacyNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list");
expect(legacyNavigation.spaces.find((space) => space.id === legacy.id)?.workspace).toBeNull(); 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", { const linked = await rpc<Space>(app, owner, "spaces/create", {
name: "Owner folder", name: "Owner folder",
workspacePath: hostFolder, workspacePath: hostFolder,
}); });
expect(linked.workspace?.path).toBe(hostFolder); expect(linked.workspace?.path).toBe(hostFolder);
const ownerNavigation = await rpc<SpaceNavigation>(app, owner, "spaces/list"); const ownerNavigation = await rpc<SpaceNavigation>(app, owner, "spaces/list");
expect( expect(ownerNavigation.spaces.find((space) => space.id === linked.id)?.workspace?.path).toBe(
ownerNavigation.spaces hostFolder,
.filter((space) => space.name === "Owner folder") );
.map((space) => space.workspace?.path),
).toEqual([hostFolder]);
const ownerBot = await rpc<Bot>( const ownerBot = await rpc<Bot>(
app, app,
owner, owner,