Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee98a1d75e | |||
| dc4a3a341c | |||
| 7baf0a9ba3 | |||
| c63c53b362 | |||
| ea232e754c | |||
| 137c7dea9e | |||
| 4610ab6eb5 | |||
| 3f9f82e386 | |||
| 88ecb4efb6 |
@@ -10,7 +10,11 @@ import { createElement } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { HostFilesystemEntry } from "./useHostFilesystem";
|
||||
import { buildHostFilesystemUrl, useHostFilesystem } from "./useHostFilesystem";
|
||||
import {
|
||||
buildHostFilesystemUrl,
|
||||
createHostDirectory,
|
||||
useHostFilesystem,
|
||||
} from "./useHostFilesystem";
|
||||
|
||||
describe("buildHostFilesystemUrl", () => {
|
||||
it("returns the no-path endpoint when absolutePath is empty", () => {
|
||||
@@ -190,3 +194,50 @@ describe("useHostFilesystem", () => {
|
||||
expect(err?.message).toContain("HTTP 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createHostDirectory — POSTs to /v1/hosts/{id}/directories and surfaces the
|
||||
// server's error detail. Shares the same seam as the useHostFilesystem suite:
|
||||
// authenticatedFetch ultimately calls the global fetch, so we stub that.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createHostDirectory", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("POSTs the path and returns the created absolute path", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ object: "directory", path: "/Users/me/new" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const created = await createHostDirectory("host_abc", "/Users/me/new");
|
||||
|
||||
expect(created).toBe("/Users/me/new");
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/v1/hosts/host_abc/directories");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ path: "/Users/me/new" });
|
||||
});
|
||||
|
||||
it("throws the server's detail message on a non-OK response", async () => {
|
||||
// A 409 with a detail must surface the human message ("directory
|
||||
// already exists") so the picker shows it instead of a bare code.
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: "directory already exists" }), { status: 409 }),
|
||||
);
|
||||
|
||||
await expect(createHostDirectory("host_abc", "/Users/me/dup")).rejects.toThrow(
|
||||
"directory already exists",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { authenticatedFetch } from "@/lib/identity";
|
||||
|
||||
@@ -176,3 +176,71 @@ export function useHostFilesystem(hostId: string | null, path: string | null) {
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
/** Shape returned by ``POST /v1/hosts/{id}/directories``. */
|
||||
interface CreateHostDirectoryResponse {
|
||||
object: string;
|
||||
/** Absolute path of the created directory, e.g. ``"/Users/me/new"``. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory on a host via ``POST /v1/hosts/{id}/directories``.
|
||||
*
|
||||
* The server forwards a ``host.create_dir`` frame to the host, which
|
||||
* runs ``os.makedirs`` (parents included) and returns the created
|
||||
* absolute path. A non-OK response carries the host's error message
|
||||
* (e.g. "directory already exists" as a 409) so the picker can show it
|
||||
* inline.
|
||||
*
|
||||
* @param hostId Host identifier, e.g. ``"host_a1b2..."``.
|
||||
* @param path Absolute (or ``~``-prefixed) directory path to create.
|
||||
* @returns The created directory's absolute path.
|
||||
* @throws FetchError carrying the HTTP status and the server's detail
|
||||
* message on a non-OK response.
|
||||
*/
|
||||
export async function createHostDirectory(hostId: string, path: string): Promise<string> {
|
||||
const res = await authenticatedFetch(`/v1/hosts/${encodeURIComponent(hostId)}/directories`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Surface the server's detail (e.g. "directory already exists") so
|
||||
// the user sees why creation failed rather than a bare status code.
|
||||
let detail: string | null = null;
|
||||
try {
|
||||
const body = (await res.json()) as { detail?: string };
|
||||
detail = typeof body.detail === "string" ? body.detail : null;
|
||||
} catch {
|
||||
detail = null;
|
||||
}
|
||||
const err: FetchError = new Error(detail ?? `create directory failed: HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const body = (await res.json()) as CreateHostDirectoryResponse;
|
||||
return body.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query mutation: create a directory on a host, then refresh any
|
||||
* cached listings for that host so the new folder appears.
|
||||
*
|
||||
* Invalidates every ``["host-filesystem", hostId, *]`` query rather
|
||||
* than just the parent's, because the picker keys listings by its raw
|
||||
* path state ("" for home, absolute otherwise) and the caller may not
|
||||
* know which key the new directory's parent maps to.
|
||||
*
|
||||
* @returns A React Query mutation; call ``mutateAsync({ hostId, path })``.
|
||||
*/
|
||||
export function useCreateHostDirectory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ hostId, path }: { hostId: string; path: string }) =>
|
||||
createHostDirectory(hostId, path),
|
||||
onSuccess: (_createdPath, { hostId }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["host-filesystem", hostId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ vi.mock("@/hooks/useAvailableAgents", () => ({ useAvailableAgents: vi.fn() }));
|
||||
// always set here, so keep this inert (returns no listing).
|
||||
vi.mock("@/hooks/useHostFilesystem", () => ({
|
||||
useHostFilesystem: () => ({ data: undefined }),
|
||||
// WorkspacePicker reads this on mount when the file browser opens;
|
||||
// an idle mutation keeps it inert for these tests.
|
||||
useCreateHostDirectory: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
// No other sessions in scope — keep the conflict hooks inert so they don't
|
||||
// issue their own /health fetch or surface a warning. The warning is covered
|
||||
|
||||
@@ -36,7 +36,12 @@ vi.mock("@/lib/identity", async (importOriginal) => ({
|
||||
}));
|
||||
vi.mock("@/hooks/useHosts", () => ({ useHosts: vi.fn() }));
|
||||
vi.mock("@/hooks/useAvailableAgents", () => ({ useAvailableAgents: vi.fn() }));
|
||||
vi.mock("@/hooks/useHostFilesystem", () => ({ useHostFilesystem: vi.fn() }));
|
||||
vi.mock("@/hooks/useHostFilesystem", () => ({
|
||||
useHostFilesystem: vi.fn(),
|
||||
// WorkspacePicker (rendered by the file browser) reads this on mount;
|
||||
// an idle mutation keeps it inert for these tests.
|
||||
useCreateHostDirectory: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
}));
|
||||
vi.mock("@/hooks/useDirectorySessions", () => ({
|
||||
useDirectorySessions: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -12,18 +12,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
basename,
|
||||
joinPath,
|
||||
listingFilter,
|
||||
normalizeTypedPath,
|
||||
parentOf,
|
||||
WorkspacePicker,
|
||||
} from "./WorkspacePicker";
|
||||
import { useHostFilesystem, type HostFilesystemEntry } from "@/hooks/useHostFilesystem";
|
||||
import {
|
||||
useCreateHostDirectory,
|
||||
useHostFilesystem,
|
||||
type HostFilesystemEntry,
|
||||
} from "@/hooks/useHostFilesystem";
|
||||
|
||||
vi.mock("@/hooks/useHostFilesystem", () => ({
|
||||
useHostFilesystem: vi.fn(),
|
||||
// Default to an idle mutation; tests that exercise creation override
|
||||
// mutateAsync. The component only reads this when the new-folder form
|
||||
// is open, so the default is harmless for the other suites.
|
||||
useCreateHostDirectory: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
}));
|
||||
|
||||
const useHostFilesystemMock = vi.mocked(useHostFilesystem);
|
||||
const useCreateHostDirectoryMock = vi.mocked(useCreateHostDirectory);
|
||||
|
||||
function dir(name: string, path: string): HostFilesystemEntry {
|
||||
return { name, path, type: "directory", bytes: null, modified_at: 0 };
|
||||
@@ -435,3 +445,131 @@ describe("WorkspacePicker listing filter", () => {
|
||||
expect(screen.queryByTestId("workspace-picker-entry-src")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinPath", () => {
|
||||
it("joins a nested directory and a child name", () => {
|
||||
expect(joinPath("/Users/me", "new-app")).toBe("/Users/me/new-app");
|
||||
});
|
||||
|
||||
it("does not double the slash at the filesystem root", () => {
|
||||
// "/" + "foo" must be "/foo", not "//foo" — the latter would
|
||||
// confuse the host's path resolution.
|
||||
expect(joinPath("/", "foo")).toBe("/foo");
|
||||
});
|
||||
|
||||
it("ignores a trailing slash on the parent", () => {
|
||||
expect(joinPath("/Users/me/", "foo")).toBe("/Users/me/foo");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from the child name", () => {
|
||||
expect(joinPath("/Users/me", " foo ")).toBe("/Users/me/foo");
|
||||
});
|
||||
});
|
||||
|
||||
// The "New folder" action lets a user create a directory inline rather
|
||||
// than dropping to a terminal. It only makes sense once the picker has
|
||||
// resolved a real absolute directory to create in.
|
||||
describe("WorkspacePicker new folder", () => {
|
||||
beforeEach(() => {
|
||||
useHostFilesystemMock.mockReset();
|
||||
useCreateHostDirectoryMock.mockReset();
|
||||
useCreateHostDirectoryMock.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useCreateHostDirectory>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function listingWith(entries: HostFilesystemEntry[]) {
|
||||
useHostFilesystemMock.mockReturnValue(
|
||||
result({ data: { entries, truncated: false }, isLoading: false, isPlaceholderData: false }),
|
||||
);
|
||||
}
|
||||
|
||||
it("creates a folder under the current directory and navigates into it", async () => {
|
||||
listingWith([dir("app", "/Users/corey/projects/app")]);
|
||||
const mutateAsync = vi.fn().mockResolvedValue("/Users/corey/projects/fresh");
|
||||
useCreateHostDirectoryMock.mockReturnValue({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useCreateHostDirectory>);
|
||||
|
||||
render(<WorkspacePicker hostId="host_1" initialPath="/Users/corey/projects" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("workspace-picker-new-folder"));
|
||||
fireEvent.change(screen.getByTestId("workspace-picker-new-folder-input"), {
|
||||
target: { value: "fresh" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("workspace-picker-new-folder-create"));
|
||||
|
||||
await Promise.resolve();
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
hostId: "host_1",
|
||||
path: "/Users/corey/projects/fresh",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the server error inline when creation fails", async () => {
|
||||
listingWith([dir("app", "/Users/corey/projects/app")]);
|
||||
const mutateAsync = vi.fn().mockRejectedValue(new Error("directory already exists"));
|
||||
useCreateHostDirectoryMock.mockReturnValue({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useCreateHostDirectory>);
|
||||
|
||||
render(<WorkspacePicker hostId="host_1" initialPath="/Users/corey/projects" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("workspace-picker-new-folder"));
|
||||
fireEvent.change(screen.getByTestId("workspace-picker-new-folder-input"), {
|
||||
target: { value: "app" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("workspace-picker-new-folder-create"));
|
||||
|
||||
// Let the rejected mutation settle and the error state render.
|
||||
await screen.findByTestId("workspace-picker-new-folder-error");
|
||||
expect(screen.getByTestId("workspace-picker-new-folder-error").textContent).toContain(
|
||||
"already exists",
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the New folder button until an absolute directory resolves", () => {
|
||||
// Home view ("") with no listing yet — currentAbsolute is "", so the
|
||||
// button is disabled (there is no real directory to create in).
|
||||
useHostFilesystemMock.mockReturnValue(
|
||||
result({ data: undefined, isLoading: true, isPlaceholderData: false }),
|
||||
);
|
||||
render(<WorkspacePicker hostId="host_1" />);
|
||||
const btn = screen.getByTestId("workspace-picker-new-folder") as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("creates under ~ when home is empty (no entry to resolve the absolute path)", async () => {
|
||||
// An empty home has no entries, so the absolute home path can't be
|
||||
// derived — but the listing HAS loaded. The button must still enable
|
||||
// and create under "~" (the host expands it), otherwise the first
|
||||
// folder in an empty home could never be made.
|
||||
listingWith([]);
|
||||
const mutateAsync = vi.fn().mockResolvedValue("/home/e2e/fresh");
|
||||
useCreateHostDirectoryMock.mockReturnValue({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useCreateHostDirectory>);
|
||||
|
||||
render(<WorkspacePicker hostId="host_1" />);
|
||||
|
||||
const btn = screen.getByTestId("workspace-picker-new-folder") as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(btn);
|
||||
fireEvent.change(screen.getByTestId("workspace-picker-new-folder-input"), {
|
||||
target: { value: "fresh" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("workspace-picker-new-folder-create"));
|
||||
|
||||
await Promise.resolve();
|
||||
expect(mutateAsync).toHaveBeenCalledWith({ hostId: "host_1", path: "~/fresh" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
FolderIcon,
|
||||
FolderPlusIcon,
|
||||
FileIcon,
|
||||
ArrowUpIcon,
|
||||
HomeIcon,
|
||||
@@ -12,7 +13,29 @@ import {
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHostFilesystem } from "@/hooks/useHostFilesystem";
|
||||
import { useCreateHostDirectory, useHostFilesystem } from "@/hooks/useHostFilesystem";
|
||||
|
||||
/**
|
||||
* Join a directory path and a new child name into an absolute path.
|
||||
*
|
||||
* Handles the filesystem root (``"/"`` + ``"foo"`` → ``"/foo"`` rather
|
||||
* than ``"//foo"``) and trims a trailing slash off the parent so a
|
||||
* typed ``"/Users/me/"`` still produces ``"/Users/me/foo"``. The child
|
||||
* name is trimmed; surrounding/duplicate slashes in it are left to the
|
||||
* host to resolve.
|
||||
*
|
||||
* @param dir Absolute parent directory, e.g. ``"/Users/me"`` or ``"/"``.
|
||||
* @param name New child name, e.g. ``"new-app"``.
|
||||
* @returns The joined absolute path, e.g. ``"/Users/me/new-app"``.
|
||||
*/
|
||||
export function joinPath(dir: string, name: string): string {
|
||||
const trimmedName = name.trim();
|
||||
if (dir === "/") {
|
||||
return `/${trimmedName}`;
|
||||
}
|
||||
const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
||||
return `${base}/${trimmedName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the parent directory of an absolute path.
|
||||
@@ -243,6 +266,12 @@ export function WorkspacePicker({
|
||||
// True while the user is editing the path bar, so a late listing
|
||||
// (e.g. home resolving) can't overwrite what they're typing.
|
||||
const userEditedRef = useRef(false);
|
||||
// "New folder" inline form: null when closed, otherwise the in-progress
|
||||
// folder name. A separate error string holds the last create failure
|
||||
// (e.g. "directory already exists") so it shows inline by the input.
|
||||
const [newFolderName, setNewFolderName] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const createDir = useCreateHostDirectory();
|
||||
|
||||
// Reset to home when the host *changes* — a path from the old host
|
||||
// is meaningless on the new one. Compare the previous hostId rather
|
||||
@@ -256,6 +285,8 @@ export function WorkspacePicker({
|
||||
setPathInput("");
|
||||
setResolvedHome(null);
|
||||
userEditedRef.current = false;
|
||||
setNewFolderName(null);
|
||||
setCreateError(null);
|
||||
}, [hostId]);
|
||||
|
||||
const { data, isLoading, error, isPlaceholderData } = useHostFilesystem(hostId, path);
|
||||
@@ -378,6 +409,49 @@ export function WorkspacePicker({
|
||||
onSelect?.(currentAbsolute);
|
||||
}
|
||||
|
||||
// Directory the "New folder" action creates in. A resolved absolute
|
||||
// path is used as-is. At the home view the absolute path is derived
|
||||
// from the first listing entry, so an *empty* home yields no entry and
|
||||
// never resolves — fall back to "~" (the host expands it) once the
|
||||
// listing has loaded, otherwise creating the first folder in an empty
|
||||
// home would be impossible. Stays null while loading so the button is
|
||||
// disabled until we know what home resolves to.
|
||||
const createBaseDir = currentAbsolute.startsWith("/")
|
||||
? currentAbsolute
|
||||
: path === "" && !isLoading && !isPlaceholderData
|
||||
? "~"
|
||||
: null;
|
||||
const canCreateFolder = hostId !== null && createBaseDir !== null;
|
||||
|
||||
function openNewFolder() {
|
||||
setCreateError(null);
|
||||
setNewFolderName("");
|
||||
}
|
||||
|
||||
function cancelNewFolder() {
|
||||
setNewFolderName(null);
|
||||
setCreateError(null);
|
||||
}
|
||||
|
||||
async function commitNewFolder() {
|
||||
const name = (newFolderName ?? "").trim();
|
||||
if (name === "" || hostId === null || createBaseDir === null) {
|
||||
return;
|
||||
}
|
||||
const target = joinPath(createBaseDir, name);
|
||||
try {
|
||||
const created = await createDir.mutateAsync({ hostId, path: target });
|
||||
// Drop into the freshly created folder so the user can pick it
|
||||
// straight away (the reason they made it). The listing refresh is
|
||||
// handled by the mutation's onSuccess invalidation.
|
||||
setNewFolderName(null);
|
||||
setCreateError(null);
|
||||
navigateTo(created);
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "Failed to create folder");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex max-h-80 min-h-0 flex-col rounded-md border"
|
||||
@@ -437,6 +511,17 @@ export function WorkspacePicker({
|
||||
>
|
||||
{showHidden ? <EyeIcon className="size-4" /> : <EyeOffIcon className="size-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewFolder}
|
||||
disabled={!canCreateFolder}
|
||||
aria-label="New folder"
|
||||
title="New folder"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30"
|
||||
data-testid="workspace-picker-new-folder"
|
||||
>
|
||||
<FolderPlusIcon className="size-4" />
|
||||
</button>
|
||||
{onSelect && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -464,6 +549,71 @@ export function WorkspacePicker({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newFolderName !== null && (
|
||||
<div
|
||||
className="flex shrink-0 flex-col gap-1 border-b px-3 py-1.5"
|
||||
data-testid="workspace-picker-new-folder-form"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderPlusIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- focus belongs on
|
||||
// the field the user just opened; the picker is already a focus trap.
|
||||
autoFocus
|
||||
value={newFolderName}
|
||||
onChange={(e) => {
|
||||
setNewFolderName(e.target.value);
|
||||
if (createError !== null) setCreateError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void commitNewFolder();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelNewFolder();
|
||||
}
|
||||
}}
|
||||
placeholder="New folder name"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="min-w-0 flex-1 bg-transparent text-xs text-foreground focus:outline-none"
|
||||
data-testid="workspace-picker-new-folder-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newFolderName.trim() === "" || createDir.isPending}
|
||||
onClick={() => void commitNewFolder()}
|
||||
aria-label="Create folder"
|
||||
title="Create folder"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30"
|
||||
data-testid="workspace-picker-new-folder-create"
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelNewFolder}
|
||||
aria-label="Cancel new folder"
|
||||
title="Cancel"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
data-testid="workspace-picker-new-folder-cancel"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{createError !== null && (
|
||||
<span
|
||||
className="text-xs text-destructive"
|
||||
data-testid="workspace-picker-new-folder-error"
|
||||
>
|
||||
{createError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{occupiedCount > 0 && (
|
||||
<div
|
||||
className="flex shrink-0 items-start gap-1.5 border-b bg-warning/10 px-3 py-2 text-xs text-warning"
|
||||
|
||||
@@ -22,6 +22,8 @@ from websockets.exceptions import InvalidStatus, InvalidURI
|
||||
|
||||
from omnigent.host.frames import (
|
||||
HARNESS_NOT_CONFIGURED_ERROR_CODE,
|
||||
HostCreateDirFrame,
|
||||
HostCreateDirResultFrame,
|
||||
HostCreateWorktreeFrame,
|
||||
HostCreateWorktreeResultFrame,
|
||||
HostHelloFrame,
|
||||
@@ -1050,6 +1052,78 @@ class HostProcess:
|
||||
before=frame.before,
|
||||
)
|
||||
|
||||
def _handle_create_dir(self, frame: HostCreateDirFrame) -> HostCreateDirResultFrame:
|
||||
"""Handle a ``host.create_dir`` request from the server.
|
||||
|
||||
Creates the directory (and any missing parents) with
|
||||
``os.makedirs``. ``~`` expands against the host process
|
||||
owner's home, same rules as ``host.list_dir``. Expected
|
||||
filesystem errors (the directory already exists, permission
|
||||
denied, a parent component is a file) return ``status: "ok"``
|
||||
with a descriptive ``error`` so the route layer can map them
|
||||
to a 409 rather than a 500 — mirroring how ``_handle_list_dir``
|
||||
reports a missing path. Only unexpected I/O errors surface as
|
||||
``status: "failed"``.
|
||||
|
||||
:param frame: The create-dir request frame. ``frame.path`` may
|
||||
be absolute or tilde-prefixed.
|
||||
:returns: Result frame carrying the created absolute path on
|
||||
success, or an ``error`` describing why it was not created.
|
||||
"""
|
||||
try:
|
||||
expanded = os.path.expanduser(frame.path)
|
||||
except (TypeError, ValueError) as exc:
|
||||
return HostCreateDirResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error=f"path expansion failed: {exc}",
|
||||
)
|
||||
try:
|
||||
# exist_ok=False so creating an existing folder is a clear
|
||||
# "already exists" rather than a silent no-op — the picker
|
||||
# should tell the user the name is taken.
|
||||
os.makedirs(expanded, exist_ok=False)
|
||||
except FileExistsError:
|
||||
# makedirs raises FileExistsError whether the leaf is an
|
||||
# existing directory or a regular file. Distinguish the two
|
||||
# so "name is taken by a file" isn't mislabelled as an
|
||||
# existing directory.
|
||||
error = (
|
||||
"directory already exists"
|
||||
if os.path.isdir(expanded)
|
||||
else "a file already exists at that path"
|
||||
)
|
||||
return HostCreateDirResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
error=error,
|
||||
)
|
||||
except NotADirectoryError:
|
||||
return HostCreateDirResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
error="a parent path component is not a directory",
|
||||
)
|
||||
except PermissionError:
|
||||
return HostCreateDirResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
error="permission denied",
|
||||
)
|
||||
except OSError as exc:
|
||||
return HostCreateDirResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error=f"mkdir failed: {exc.strerror or str(exc)}",
|
||||
)
|
||||
created = os.path.abspath(expanded)
|
||||
_logger.info("Created directory %s", created)
|
||||
return HostCreateDirResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
path=created,
|
||||
)
|
||||
|
||||
async def _handle_create_worktree(
|
||||
self,
|
||||
frame: HostCreateWorktreeFrame,
|
||||
@@ -1412,6 +1486,8 @@ class HostProcess:
|
||||
await ws.send(encode_host_frame(self._handle_stat(frame)))
|
||||
elif isinstance(frame, HostListDirFrame):
|
||||
await ws.send(encode_host_frame(self._handle_list_dir(frame)))
|
||||
elif isinstance(frame, HostCreateDirFrame):
|
||||
await ws.send(encode_host_frame(self._handle_create_dir(frame)))
|
||||
elif isinstance(frame, HostCreateWorktreeFrame):
|
||||
await ws.send(encode_host_frame(await self._handle_create_worktree(frame)))
|
||||
elif isinstance(frame, HostRemoveWorktreeFrame):
|
||||
|
||||
@@ -49,6 +49,8 @@ class HostFrameKind(str, Enum):
|
||||
CREATE_WORKTREE_RESULT = "host.create_worktree_result"
|
||||
REMOVE_WORKTREE = "host.remove_worktree"
|
||||
REMOVE_WORKTREE_RESULT = "host.remove_worktree_result"
|
||||
CREATE_DIR = "host.create_dir"
|
||||
CREATE_DIR_RESULT = "host.create_dir_result"
|
||||
|
||||
|
||||
# ── Frame dataclasses ────────────────────────────────────
|
||||
@@ -426,6 +428,50 @@ class HostRemoveWorktreeResultFrame:
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostCreateDirFrame:
|
||||
"""Server → host: create a new directory on the host.
|
||||
|
||||
Backs ``POST /v1/hosts/{id}/directories``, used by the Web UI's
|
||||
workspace picker so a user can make a fresh folder to start a
|
||||
session in without dropping to a terminal. The host owns ``~``
|
||||
resolution, same rules as ``host.list_dir`` / ``host.stat``.
|
||||
|
||||
:param request_id: Correlates the result, e.g. ``"req_mkdir_1"``.
|
||||
:param path: Absolute or tilde-prefixed directory path to create,
|
||||
e.g. ``"/Users/corey/projects/new-app"`` or ``"~/scratch"``.
|
||||
Missing parent directories are created (``os.makedirs``).
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostCreateDirResultFrame:
|
||||
"""Host → server: outcome of a create-dir request.
|
||||
|
||||
:param request_id: Correlates to the
|
||||
:class:`HostCreateDirFrame`, e.g. ``"req_mkdir_1"``.
|
||||
:param status: ``"ok"`` or ``"failed"``. ``"failed"`` is reserved
|
||||
for unexpected I/O errors; an expected filesystem error (the
|
||||
directory already exists, permission denied, a parent path
|
||||
component is a file) collapses to ``"ok"`` with a descriptive
|
||||
``error`` so the route layer can map it to a 409 rather than a
|
||||
500 — same posture as ``host.list_dir`` for a missing path.
|
||||
:param path: Absolute path of the created directory, e.g.
|
||||
``"/Users/corey/projects/new-app"``. ``None`` when the
|
||||
directory was not created.
|
||||
:param error: Filesystem error, e.g. ``"directory already
|
||||
exists"`` or ``"permission denied"``. ``None`` on success.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
status: str
|
||||
path: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
HostFrame = (
|
||||
HostHelloFrame
|
||||
| HostLaunchRunnerFrame
|
||||
@@ -441,6 +487,8 @@ HostFrame = (
|
||||
| HostCreateWorktreeResultFrame
|
||||
| HostRemoveWorktreeFrame
|
||||
| HostRemoveWorktreeResultFrame
|
||||
| HostCreateDirFrame
|
||||
| HostCreateDirResultFrame
|
||||
)
|
||||
|
||||
|
||||
@@ -602,6 +650,24 @@ def encode_host_frame(frame: HostFrame) -> str:
|
||||
"error": frame.error,
|
||||
}
|
||||
)
|
||||
if isinstance(frame, HostCreateDirFrame):
|
||||
return json.dumps(
|
||||
{
|
||||
"kind": HostFrameKind.CREATE_DIR.value,
|
||||
"request_id": frame.request_id,
|
||||
"path": frame.path,
|
||||
}
|
||||
)
|
||||
if isinstance(frame, HostCreateDirResultFrame):
|
||||
return json.dumps(
|
||||
{
|
||||
"kind": HostFrameKind.CREATE_DIR_RESULT.value,
|
||||
"request_id": frame.request_id,
|
||||
"status": frame.status,
|
||||
"path": frame.path,
|
||||
"error": frame.error,
|
||||
}
|
||||
)
|
||||
raise TypeError(f"unknown host frame type: {type(frame).__name__}")
|
||||
|
||||
|
||||
@@ -690,6 +756,10 @@ def _decode_known_host_frame(
|
||||
return _decode_remove_worktree(msg)
|
||||
case HostFrameKind.REMOVE_WORKTREE_RESULT:
|
||||
return _decode_remove_worktree_result(msg)
|
||||
case HostFrameKind.CREATE_DIR:
|
||||
return _decode_create_dir(msg)
|
||||
case HostFrameKind.CREATE_DIR_RESULT:
|
||||
return _decode_create_dir_result(msg)
|
||||
raise ValueError(f"unhandled host frame kind: {kind.value!r}") # pragma: no cover
|
||||
|
||||
|
||||
@@ -936,6 +1006,32 @@ def _decode_remove_worktree_result(
|
||||
)
|
||||
|
||||
|
||||
def _decode_create_dir(msg: dict[str, Any]) -> HostCreateDirFrame:
|
||||
"""Decode a host.create_dir request frame.
|
||||
|
||||
:param msg: Decoded frame object.
|
||||
:returns: Typed host.create_dir frame.
|
||||
"""
|
||||
return HostCreateDirFrame(
|
||||
request_id=_required_str(msg, "request_id"),
|
||||
path=_required_str(msg, "path"),
|
||||
)
|
||||
|
||||
|
||||
def _decode_create_dir_result(msg: dict[str, Any]) -> HostCreateDirResultFrame:
|
||||
"""Decode a host.create_dir_result frame.
|
||||
|
||||
:param msg: Decoded frame object.
|
||||
:returns: Typed host.create_dir_result frame.
|
||||
"""
|
||||
return HostCreateDirResultFrame(
|
||||
request_id=_required_str(msg, "request_id"),
|
||||
status=_required_str(msg, "status"),
|
||||
path=_optional_nullable_str(msg, "path"),
|
||||
error=_optional_nullable_str(msg, "error"),
|
||||
)
|
||||
|
||||
|
||||
# ── Field validators ─────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -180,6 +180,11 @@ class HostConnection:
|
||||
in-flight ``host.remove_worktree`` requests. Resolved when
|
||||
the host sends ``host.remove_worktree_result``. Values
|
||||
carry ``status`` and ``error``.
|
||||
:param pending_create_dirs: Per-``request_id`` futures for
|
||||
in-flight ``host.create_dir`` requests. Resolved when the
|
||||
host sends ``host.create_dir_result``. Values carry the
|
||||
result fields (``status``, ``path``, ``error``). Same
|
||||
``Any`` typing rationale as ``pending_stats``.
|
||||
"""
|
||||
|
||||
host_id: str
|
||||
@@ -207,6 +212,9 @@ class HostConnection:
|
||||
pending_remove_worktrees: dict[str, asyncio.Future[dict[str, Any]]] = field(
|
||||
default_factory=dict,
|
||||
)
|
||||
pending_create_dirs: dict[str, asyncio.Future[dict[str, Any]]] = field(
|
||||
default_factory=dict,
|
||||
)
|
||||
|
||||
|
||||
class HostRegistry:
|
||||
|
||||
@@ -26,6 +26,7 @@ from collections.abc import Awaitable, Callable
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from omnigent.host.frames import (
|
||||
HostCreateDirResultFrame,
|
||||
HostCreateWorktreeResultFrame,
|
||||
HostHelloFrame,
|
||||
HostLaunchRunnerResultFrame,
|
||||
@@ -464,6 +465,18 @@ async def _receive_loop(
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(frame, HostCreateDirResultFrame):
|
||||
create_dir_future = conn.pending_create_dirs.pop(frame.request_id, None)
|
||||
if create_dir_future is not None and not create_dir_future.done():
|
||||
create_dir_future.set_result(
|
||||
{
|
||||
"status": frame.status,
|
||||
"path": frame.path,
|
||||
"error": frame.error,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
_logger.debug(
|
||||
"Host %s sent unexpected frame type: %s",
|
||||
host_id,
|
||||
|
||||
@@ -30,6 +30,7 @@ from omnigent.errors import ErrorCode, OmnigentError
|
||||
from omnigent.harness_aliases import canonicalize_harness
|
||||
from omnigent.host.frames import (
|
||||
HARNESS_NOT_CONFIGURED_ERROR_CODE,
|
||||
HostCreateDirFrame,
|
||||
HostLaunchRunnerFrame,
|
||||
HostListDirFrame,
|
||||
encode_host_frame,
|
||||
@@ -54,6 +55,10 @@ _LAUNCH_RESULT_TIMEOUT_S = 30.0
|
||||
_LIST_DIR_TIMEOUT_S = 5.0
|
||||
_LIST_DIR_DEFAULT_LIMIT = 20
|
||||
_LIST_DIR_MAX_LIMIT = 1000
|
||||
# Per-call timeout for host.create_dir round-trips. mkdir is a single
|
||||
# fast syscall on the host side; 5s matches list_dir and is generous
|
||||
# for transient network slowness without making the picker feel hung.
|
||||
_CREATE_DIR_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
async def _proxy_list_dir(
|
||||
@@ -128,6 +133,78 @@ async def _proxy_list_dir(
|
||||
host_conn.pending_list_dirs.pop(request_id, None)
|
||||
|
||||
|
||||
async def _proxy_create_dir(
|
||||
*,
|
||||
host_registry: HostRegistry,
|
||||
host_conn: HostConnection,
|
||||
path: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send a ``host.create_dir`` frame and await the result.
|
||||
|
||||
Mirrors :func:`_proxy_list_dir`: enqueue the frame, register a
|
||||
future on the host connection's ``pending_create_dirs`` map, await
|
||||
with a timeout, and clean up in a finally block. The host's WS
|
||||
receive loop in ``host_tunnel.py`` resolves the future when the
|
||||
result frame arrives.
|
||||
|
||||
:param host_registry: Server-side registry; used to enqueue the
|
||||
outbound frame on the host's send queue.
|
||||
:param host_conn: Live host connection.
|
||||
:param path: Absolute or tilde-prefixed directory to create. The
|
||||
host expands ``~`` itself.
|
||||
:returns: Dict with the result fields: ``status`` (``"ok"`` or
|
||||
``"failed"``), ``path`` (created absolute path or ``None``),
|
||||
``error`` (string or ``None``).
|
||||
:raises HTTPException: 504 on timeout, 502 on connection drop.
|
||||
"""
|
||||
request_id = secrets.token_hex(8)
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||
host_conn.pending_create_dirs[request_id] = future
|
||||
|
||||
frame = encode_host_frame(
|
||||
HostCreateDirFrame(
|
||||
request_id=request_id,
|
||||
path=path,
|
||||
)
|
||||
)
|
||||
try:
|
||||
try:
|
||||
host_registry.send_text(host_conn, frame)
|
||||
except ConnectionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"host '{host_conn.host_id}' connection lost",
|
||||
) from exc
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=_CREATE_DIR_TIMEOUT_S)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=(
|
||||
f"host '{host_conn.host_id}' did not respond to create_dir "
|
||||
f"within {_CREATE_DIR_TIMEOUT_S:.0f}s"
|
||||
),
|
||||
) from exc
|
||||
finally:
|
||||
# Cleanup runs on every path so a cancelled caller doesn't
|
||||
# leave an orphan in the pending dict.
|
||||
host_conn.pending_create_dirs.pop(request_id, None)
|
||||
|
||||
|
||||
class CreateDirectoryRequest(BaseModel):
|
||||
"""Request body for ``POST /v1/hosts/{host_id}/directories``.
|
||||
|
||||
:param path: Absolute path of the directory to create on the host
|
||||
machine, e.g. ``"/Users/corey/projects/new-app"``, or a
|
||||
tilde-prefixed path (``"~/scratch"``) the host expands against
|
||||
its own process owner. Missing parents are created.
|
||||
"""
|
||||
|
||||
path: str
|
||||
|
||||
|
||||
class LaunchRunnerRequest(BaseModel):
|
||||
"""Request body for ``POST /v1/hosts/{host_id}/runners``.
|
||||
|
||||
@@ -756,4 +833,88 @@ def create_hosts_router(
|
||||
"has_more": bool(result.get("has_more", False)),
|
||||
}
|
||||
|
||||
@router.post("/hosts/{host_id}/directories")
|
||||
async def create_host_directory(
|
||||
request: Request,
|
||||
host_id: str,
|
||||
body: CreateDirectoryRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a new directory on a host.
|
||||
|
||||
Backs the Web UI workspace picker's "New folder" action so a
|
||||
user can make a fresh directory to start a session in without
|
||||
dropping to a terminal. Owner-scoped exactly like the
|
||||
filesystem browse endpoints (``GET /v1/hosts/{id}/filesystem``):
|
||||
only the host owner can create directories, and — like browse —
|
||||
this is NOT scoped to a session. The workspace-boundary check
|
||||
still runs at session-create time, so creating a directory here
|
||||
does not by itself grant an agent access to it.
|
||||
|
||||
:param request: FastAPI request (for auth).
|
||||
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
|
||||
:param body: Request body carrying the absolute (or
|
||||
tilde-prefixed) ``path`` to create.
|
||||
:returns: ``{"object": "directory", "path": "<created abs path>"}``.
|
||||
:raises HTTPException: 404 if host not found, 403 if not owned
|
||||
by caller, 409 if host is offline or the directory could not
|
||||
be created (already exists / permission denied), 400 on path
|
||||
validation, 504 on host timeout, 502 on host I/O failure.
|
||||
"""
|
||||
# require_user: unauthenticated callers 401 instead of slipping
|
||||
# past the owner check below as None.
|
||||
user_id = require_user(request, auth_provider)
|
||||
|
||||
host = await asyncio.to_thread(host_store.get_host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="host not found")
|
||||
if user_id is not None and host.owner != user_id:
|
||||
raise HTTPException(status_code=403, detail="not your host")
|
||||
|
||||
path = body.path
|
||||
if not path.strip():
|
||||
raise HTTPException(status_code=400, detail="path must not be empty")
|
||||
if "\x00" in path:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="path must not contain NUL bytes",
|
||||
)
|
||||
# Absolute or tilde-prefixed only — the host needs a path it can
|
||||
# resolve on its own; a relative path has no stable meaning here.
|
||||
if not path.startswith(("/", "~")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="path must be absolute or tilde-prefixed",
|
||||
)
|
||||
|
||||
conn = host_registry.get(host.host_id)
|
||||
if conn is None:
|
||||
raise HTTPException(status_code=409, detail="host is offline")
|
||||
|
||||
result = await _proxy_create_dir(
|
||||
host_registry=host_registry,
|
||||
host_conn=conn,
|
||||
path=path,
|
||||
)
|
||||
|
||||
if result.get("status") == "failed":
|
||||
# Unexpected I/O failure on the host.
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"host create_dir failed: {result.get('error') or 'unknown error'}",
|
||||
)
|
||||
# Expected filesystem error (already exists / permission denied /
|
||||
# parent is a file) → 409 Conflict with the host's message, so
|
||||
# the picker can show "directory already exists" inline.
|
||||
if result.get("error"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=str(result.get("error")),
|
||||
)
|
||||
|
||||
return {
|
||||
"object": "directory",
|
||||
"path": result.get("path"),
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
@@ -413,6 +413,20 @@
|
||||
"title": "CreateDefaultPolicyRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"CreateDirectoryRequest": {
|
||||
"description": "Request body for ``POST /v1/hosts/{host_id}/directories``.\n\n:param path: Absolute path of the directory to create on the host\n machine, e.g. ``\"/Users/corey/projects/new-app\"``, or a\n tilde-prefixed path (``\"~/scratch\"``) the host expands against\n its own process owner. Missing parents are created.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"title": "Path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"title": "CreateDirectoryRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"CreateSessionPolicyRequest": {
|
||||
"description": "Request body for ``POST /v1/sessions/{session_id}/policies``.\n\n:param name: Human-readable policy name. Must be unique\n within the session, e.g.\n ``\"block_non_feature_branch_push\"``.\n:param type: Handler discriminator: ``\"python\"`` or\n ``\"url\"``.\n:param handler: Dotted import path (python) or HTTPS URL\n (url), e.g.\n ``\"github_mcp_policy.block_non_misc_push\"``\n or ``\"https://example.com/policies/eval\"``.\n:param factory_params: Optional dict of kwargs passed to the\n handler when it is a factory function. Only valid for\n ``type=\"python\"``, e.g. ``{\"limit\": 10}``.",
|
||||
"properties": {
|
||||
@@ -3849,6 +3863,61 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/hosts/{host_id}/directories": {
|
||||
"post": {
|
||||
"description": "Create a new directory on a host.\n\nBacks the Web UI workspace picker's \"New folder\" action so a\nuser can make a fresh directory to start a session in without\ndropping to a terminal. Owner-scoped exactly like the\nfilesystem browse endpoints (``GET /v1/hosts/{id}/filesystem``):\nonly the host owner can create directories, and \u2014 like browse \u2014\nthis is NOT scoped to a session. The workspace-boundary check\nstill runs at session-create time, so creating a directory here\ndoes not by itself grant an agent access to it.\n\n:param request: FastAPI request (for auth).\n:param host_id: Host identifier, e.g. ``\"host_a1b2c3d4...\"``.\n:param body: Request body carrying the absolute (or\n tilde-prefixed) ``path`` to create.\n:returns: ``{\"object\": \"directory\", \"path\": \"<created abs path>\"}``.\n:raises HTTPException: 404 if host not found, 403 if not owned\n by caller, 409 if host is offline or the directory could not\n be created (already exists / permission denied), 400 on path\n validation, 504 on host timeout, 502 on host I/O failure.",
|
||||
"operationId": "create_host_directory_v1_hosts__host_id__directories_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "host_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Host Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateDirectoryRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"title": "Response Create Host Directory V1 Hosts Host Id Directories Post",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Create Host Directory",
|
||||
"tags": [
|
||||
"hosts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/hosts/{host_id}/filesystem": {
|
||||
"get": {
|
||||
"description": "List the contents of the host daemon's home directory.\n\nEmpty trailing path \u2192 forward ``~`` to the host (the host\nexpands against its own process owner). Used by the\nWeb UI's directory picker to show the \"root\" view.\n\n:param request: FastAPI request (for auth).\n:param host_id: Host identifier, e.g.\n ``\"host_a1b2c3d4...\"``.\n:param limit: Max entries per page.\n:param after: Optional forward pagination cursor (entry\n path), e.g. ``\"/Users/corey/projects/m\"``.\n:param before: Optional backward pagination cursor.\n:returns: ``{\"object\": \"list\", \"data\": [...], \"has_more\": bool}``\n mirroring the existing session-scoped filesystem\n endpoint shape.\n:raises HTTPException: 404 if host not found, 403 if not\n owned by caller, 409 if host is offline, 504 on host\n timeout, 502 on host I/O failure.",
|
||||
|
||||
@@ -713,6 +713,127 @@ async def _drive_folder_selection(base_url: str, session_id: str) -> None:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_start_session_create_folder(seeded_session: tuple[str, str]) -> None:
|
||||
"""Creating a folder in the picker makes it the session's workspace.
|
||||
|
||||
The user opens the file browser, navigates into a folder, clicks "New
|
||||
folder", names it, and confirms. The picker POSTs
|
||||
``/v1/hosts/{id}/directories``, drops into the freshly created
|
||||
directory, and the working-directory chip follows. On Send the new
|
||||
folder's path must reach ``POST /v1/sessions`` as ``workspace`` — i.e.
|
||||
the agent's working directory is the folder the user just made.
|
||||
|
||||
Like the other tests here, the tunneled runner registers no host, so
|
||||
``/v1/hosts/{id}/directories`` is faked: the handler captures the
|
||||
requested path and echoes it back as the created absolute path (the
|
||||
real ``os.makedirs`` never runs in this harness).
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
_run_in_fresh_loop(_drive_create_folder(base_url, session_id))
|
||||
|
||||
|
||||
async def _drive_create_folder(base_url: str, session_id: str) -> None:
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch()
|
||||
page = await browser.new_page()
|
||||
try:
|
||||
create_bodies: list[dict[str, Any]] = []
|
||||
await _register_common_routes(
|
||||
page, created_session_id=session_id, create_bodies=create_bodies
|
||||
)
|
||||
|
||||
async def handle_filesystem(route: Route) -> None:
|
||||
# Home shows "projects"; "/home/e2e/projects" shows its child;
|
||||
# the freshly created "/home/e2e/projects/new-app" lists empty.
|
||||
# Deepest match first so the new folder isn't shadowed.
|
||||
path_part = route.request.url.split("?")[0]
|
||||
if path_part.endswith("/filesystem/home/e2e/projects/new-app"):
|
||||
entries: list[dict[str, Any]] = []
|
||||
elif path_part.endswith("/filesystem/home/e2e/projects"):
|
||||
entries = [
|
||||
{
|
||||
"name": "src",
|
||||
"path": "/home/e2e/projects/src",
|
||||
"type": "directory",
|
||||
"bytes": None,
|
||||
"modified_at": 0,
|
||||
}
|
||||
]
|
||||
else:
|
||||
entries = [
|
||||
{
|
||||
"name": "projects",
|
||||
"path": "/home/e2e/projects",
|
||||
"type": "directory",
|
||||
"bytes": None,
|
||||
"modified_at": 0,
|
||||
}
|
||||
]
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"object": "list", "data": entries, "has_more": False}),
|
||||
)
|
||||
|
||||
create_dir_bodies: list[dict[str, Any]] = []
|
||||
|
||||
async def handle_create_dir(route: Route) -> None:
|
||||
# Mirror the server's success shape: echo the requested path
|
||||
# back as the created absolute path. Capturing the body lets
|
||||
# the test assert the picker sent the joined parent + name.
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
create_dir_bodies.append(body)
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"object": "directory", "path": body["path"]}),
|
||||
)
|
||||
|
||||
# Registered after the broad globs so these win for their URLs.
|
||||
await page.route(_FILESYSTEM_RE, handle_filesystem)
|
||||
await page.route(re.compile(r"/v1/hosts/[^/]+/directories$"), handle_create_dir)
|
||||
|
||||
await page.goto(f"{base_url}/")
|
||||
await page.get_by_test_id("new-chat-landing-input").wait_for(
|
||||
state="visible", timeout=30_000
|
||||
)
|
||||
await expect(page.get_by_test_id("new-chat-landing-workspace-chip")).to_contain_text(
|
||||
"e2e"
|
||||
)
|
||||
|
||||
# Open the picker and navigate into "projects" so the new folder
|
||||
# has a resolved absolute parent to be created under.
|
||||
await page.get_by_test_id("new-chat-landing-workspace-chip").click()
|
||||
await expect(page.get_by_test_id("workspace-picker")).to_be_visible()
|
||||
await page.get_by_test_id("workspace-picker-entry-projects").click()
|
||||
await expect(page.get_by_test_id("workspace-picker-entry-src")).to_be_visible()
|
||||
|
||||
# Create a new folder under /home/e2e/projects.
|
||||
await page.get_by_test_id("workspace-picker-new-folder").click()
|
||||
await page.get_by_test_id("workspace-picker-new-folder-input").fill("new-app")
|
||||
await page.get_by_test_id("workspace-picker-new-folder-create").click()
|
||||
|
||||
# The picker POSTs the joined path and drops into the new folder.
|
||||
await _wait_until(lambda: len(create_dir_bodies) == 1)
|
||||
assert create_dir_bodies[0]["path"] == "/home/e2e/projects/new-app", create_dir_bodies
|
||||
|
||||
# Filling the message closes the popover; the chip now shows the
|
||||
# folder we just created.
|
||||
await page.get_by_test_id("new-chat-landing-input").fill("set up the project")
|
||||
await expect(page.get_by_test_id("new-chat-landing-workspace-chip")).to_contain_text(
|
||||
"new-app"
|
||||
)
|
||||
|
||||
await page.get_by_test_id("new-chat-landing-submit").click()
|
||||
|
||||
await _wait_until(lambda: len(create_bodies) == 1)
|
||||
body = create_bodies[0]
|
||||
assert body["host_id"] == _HOST_ID, body
|
||||
assert body["workspace"] == "/home/e2e/projects/new-app", body
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_start_session_add_worktree(seeded_session: tuple[str, str]) -> None:
|
||||
"""Naming a branch attaches a git worktree spec to the create call.
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ from omnigent.host.connect import (
|
||||
)
|
||||
from omnigent.host.frames import (
|
||||
HARNESS_NOT_CONFIGURED_ERROR_CODE,
|
||||
HostCreateDirFrame,
|
||||
HostCreateDirResultFrame,
|
||||
HostHelloFrame,
|
||||
HostLaunchRunnerFrame,
|
||||
HostLaunchRunnerResultFrame,
|
||||
@@ -1463,6 +1465,120 @@ def test_handle_list_dir_pagination_last_page_has_more_false(
|
||||
assert result.has_more is False
|
||||
|
||||
|
||||
# ── host.create_dir handler ─────────────────────────────
|
||||
|
||||
|
||||
def test_handle_create_dir_creates_directory(tmp_path: Path) -> None:
|
||||
"""
|
||||
Verify ``_handle_create_dir`` makes the directory and returns its
|
||||
absolute path.
|
||||
|
||||
This is the picker's "New folder" happy path — the returned path
|
||||
is what the picker navigates into afterward.
|
||||
"""
|
||||
host = _make_host_process()
|
||||
target = tmp_path / "new-app"
|
||||
|
||||
result = host._handle_create_dir(HostCreateDirFrame(request_id="m1", path=str(target)))
|
||||
|
||||
assert isinstance(result, HostCreateDirResultFrame)
|
||||
assert result.status == "ok"
|
||||
assert result.error is None
|
||||
assert result.path == str(target)
|
||||
assert target.is_dir()
|
||||
|
||||
|
||||
def test_handle_create_dir_creates_missing_parents(tmp_path: Path) -> None:
|
||||
"""
|
||||
Verify missing parent directories are created (``os.makedirs``).
|
||||
|
||||
Lets the picker accept a nested name like ``a/b/c`` in one go
|
||||
rather than forcing the user to create each level.
|
||||
"""
|
||||
host = _make_host_process()
|
||||
target = tmp_path / "a" / "b" / "c"
|
||||
|
||||
result = host._handle_create_dir(HostCreateDirFrame(request_id="m2", path=str(target)))
|
||||
|
||||
assert result.status == "ok"
|
||||
assert target.is_dir()
|
||||
|
||||
|
||||
def test_handle_create_dir_existing_returns_error_not_failed(tmp_path: Path) -> None:
|
||||
"""
|
||||
Verify creating an existing directory returns ``status: "ok"`` with
|
||||
an "already exists" error rather than ``status: "failed"``.
|
||||
|
||||
The route maps a non-empty ``error`` to a 409 so the picker shows
|
||||
"directory already exists" inline; surfacing ``failed`` would 502
|
||||
instead.
|
||||
"""
|
||||
host = _make_host_process()
|
||||
existing = tmp_path / "dup"
|
||||
existing.mkdir()
|
||||
|
||||
result = host._handle_create_dir(HostCreateDirFrame(request_id="m3", path=str(existing)))
|
||||
|
||||
assert result.status == "ok"
|
||||
assert result.error == "directory already exists"
|
||||
assert result.path is None
|
||||
|
||||
|
||||
def test_handle_create_dir_leaf_is_file_reports_file_not_directory(tmp_path: Path) -> None:
|
||||
"""
|
||||
Verify a regular file at the target path reports a file, not a
|
||||
directory.
|
||||
|
||||
``os.makedirs`` raises ``FileExistsError`` for both an existing
|
||||
directory and an existing file; the handler must distinguish them
|
||||
so the picker doesn't mislabel "a file is in the way" as
|
||||
"directory already exists".
|
||||
"""
|
||||
host = _make_host_process()
|
||||
a_file = tmp_path / "taken"
|
||||
a_file.write_text("hi")
|
||||
|
||||
result = host._handle_create_dir(HostCreateDirFrame(request_id="m3b", path=str(a_file)))
|
||||
|
||||
assert result.status == "ok"
|
||||
assert result.error == "a file already exists at that path"
|
||||
assert result.path is None
|
||||
|
||||
|
||||
def test_handle_create_dir_parent_is_file_returns_error(tmp_path: Path) -> None:
|
||||
"""
|
||||
Verify creating under a path whose parent is a regular file returns
|
||||
a clean error rather than crashing.
|
||||
"""
|
||||
host = _make_host_process()
|
||||
a_file = tmp_path / "file.txt"
|
||||
a_file.write_text("hi")
|
||||
target = a_file / "child"
|
||||
|
||||
result = host._handle_create_dir(HostCreateDirFrame(request_id="m4", path=str(target)))
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "not a directory" in (result.error or "")
|
||||
assert result.path is None
|
||||
|
||||
|
||||
def test_handle_create_dir_expands_tilde(tmp_path: Path, monkeypatch) -> None:
|
||||
"""
|
||||
Verify ``~`` expands against the host process owner's home.
|
||||
|
||||
The host owns ``~`` resolution; without expansion ``~/scratch``
|
||||
would become a literal ``~`` subdir of the process cwd.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
host = _make_host_process()
|
||||
result = host._handle_create_dir(HostCreateDirFrame(request_id="m5", path="~/scratch"))
|
||||
|
||||
assert result.status == "ok"
|
||||
assert (tmp_path / "scratch").is_dir()
|
||||
assert result.path == str(tmp_path / "scratch")
|
||||
|
||||
|
||||
# --- Fail-loud on permanent tunnel failures ----------------------------
|
||||
#
|
||||
# Before the fix, HostProcess.run() caught every connection exception and
|
||||
|
||||
@@ -8,6 +8,8 @@ import pytest
|
||||
|
||||
from omnigent.host.frames import (
|
||||
HARNESS_NOT_CONFIGURED_ERROR_CODE,
|
||||
HostCreateDirFrame,
|
||||
HostCreateDirResultFrame,
|
||||
HostCreateWorktreeFrame,
|
||||
HostCreateWorktreeResultFrame,
|
||||
HostHelloFrame,
|
||||
@@ -796,3 +798,85 @@ def test_remove_worktree_result_frame_round_trip() -> None:
|
||||
decoded = decode_host_frame(encode_host_frame(original))
|
||||
assert isinstance(decoded, HostRemoveWorktreeResultFrame)
|
||||
assert decoded == original
|
||||
|
||||
|
||||
# ── host.create_dir frames ──────────────────────────────
|
||||
|
||||
|
||||
def test_create_dir_frame_round_trip() -> None:
|
||||
"""
|
||||
Verify HostCreateDirFrame request frame survives encode → decode.
|
||||
|
||||
Pins the wire shape used by the picker's "New folder" action:
|
||||
``request_id`` plus the directory ``path`` to create.
|
||||
"""
|
||||
original = HostCreateDirFrame(
|
||||
request_id="req_mkdir_1",
|
||||
path="/Users/corey/projects/new-app",
|
||||
)
|
||||
decoded = decode_host_frame(encode_host_frame(original))
|
||||
assert isinstance(decoded, HostCreateDirFrame)
|
||||
assert decoded == original
|
||||
|
||||
|
||||
def test_create_dir_frame_accepts_tilde_path() -> None:
|
||||
"""
|
||||
Verify a tilde-prefixed path round-trips verbatim.
|
||||
|
||||
The host (not the server) expands ``~``, same rules as
|
||||
``host.list_dir`` — so the tilde must survive the wire.
|
||||
"""
|
||||
original = HostCreateDirFrame(request_id="req_mkdir_tilde", path="~/scratch")
|
||||
decoded = decode_host_frame(encode_host_frame(original))
|
||||
assert isinstance(decoded, HostCreateDirFrame)
|
||||
assert decoded.path == "~/scratch"
|
||||
|
||||
|
||||
def test_create_dir_request_missing_path_raises() -> None:
|
||||
"""
|
||||
Verify decoding a create_dir without ``path`` raises ValueError.
|
||||
|
||||
Without ``path`` the host has nothing to create; failing loud
|
||||
beats silently creating something under the process cwd.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="missing required string field"):
|
||||
decode_host_frame('{"kind": "host.create_dir", "request_id": "r"}')
|
||||
|
||||
|
||||
def test_create_dir_result_success_round_trip() -> None:
|
||||
"""
|
||||
Verify a successful create-dir result round-trips with the created
|
||||
absolute path intact.
|
||||
|
||||
The picker navigates into ``path`` after creating it; a dropped
|
||||
field would leave the user staring at the old directory.
|
||||
"""
|
||||
original = HostCreateDirResultFrame(
|
||||
request_id="req_mkdir_2",
|
||||
status="ok",
|
||||
path="/Users/corey/projects/new-app",
|
||||
)
|
||||
decoded = decode_host_frame(encode_host_frame(original))
|
||||
assert isinstance(decoded, HostCreateDirResultFrame)
|
||||
assert decoded == original
|
||||
|
||||
|
||||
def test_create_dir_result_error_round_trip() -> None:
|
||||
"""
|
||||
Verify an expected filesystem error round-trips with the message
|
||||
intact and ``path`` left ``None``.
|
||||
|
||||
The route maps a non-empty ``error`` to a 409 so the picker can
|
||||
show "directory already exists" — that hinges on the message
|
||||
surviving the wire.
|
||||
"""
|
||||
original = HostCreateDirResultFrame(
|
||||
request_id="req_mkdir_3",
|
||||
status="ok",
|
||||
error="directory already exists",
|
||||
)
|
||||
decoded = decode_host_frame(encode_host_frame(original))
|
||||
assert isinstance(decoded, HostCreateDirResultFrame)
|
||||
assert decoded.status == "ok"
|
||||
assert decoded.path is None
|
||||
assert decoded.error == "directory already exists"
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Integration tests for ``POST /v1/hosts/{id}/directories``.
|
||||
|
||||
Wires up a real host tunnel + REST router pair, drives a fake host
|
||||
that auto-replies to ``host.create_dir`` frames, and exercises the
|
||||
endpoint's contract end-to-end. Mirrors the structure of
|
||||
``test_hosts_filesystem.py`` (the browse endpoint) — the create-folder
|
||||
action shares the same owner-scoped, host-forwarded design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from asgiref.testing import ApplicationCommunicator
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from omnigent.host.frames import (
|
||||
HostCreateDirFrame,
|
||||
HostCreateDirResultFrame,
|
||||
HostHelloFrame,
|
||||
decode_host_frame,
|
||||
encode_host_frame,
|
||||
)
|
||||
from omnigent.server.host_registry import HostRegistry
|
||||
from omnigent.server.routes.host_tunnel import create_host_tunnel_router
|
||||
from omnigent.server.routes.hosts import create_hosts_router
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||||
SqlAlchemyConversationStore,
|
||||
)
|
||||
from omnigent.stores.host_store import HostStore
|
||||
|
||||
# Same liveness-race flake guard as test_hosts_filesystem.py: the mock
|
||||
# WS host can be starved + deregistered under parallel CI load. Tests
|
||||
# are sub-second; rerun rather than fail.
|
||||
pytestmark = [
|
||||
pytest.mark.asyncio,
|
||||
pytest.mark.flaky(reruns=2, reruns_delay=1),
|
||||
]
|
||||
|
||||
_HOST_ID = "host_mkdir_test"
|
||||
_HOST_NAME = "mkdir-test-laptop"
|
||||
|
||||
|
||||
def _websocket_scope(path: str) -> dict[str, object]:
|
||||
"""Build a minimal ASGI WebSocket scope.
|
||||
|
||||
:param path: WebSocket path, e.g. ``"/v1/hosts/X/tunnel"``.
|
||||
:returns: ASGI scope dict.
|
||||
"""
|
||||
return {
|
||||
"type": "websocket",
|
||||
"asgi": {"version": "3.0"},
|
||||
"scheme": "ws",
|
||||
"path": path,
|
||||
"raw_path": path.encode("ascii"),
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 50000),
|
||||
"server": ("testserver", 80),
|
||||
"subprotocols": [],
|
||||
}
|
||||
|
||||
|
||||
def _hello_text(name: str = _HOST_NAME) -> str:
|
||||
"""Encode a hello frame for tests.
|
||||
|
||||
:param name: Host name reported in the hello frame.
|
||||
:returns: JSON-encoded hello frame.
|
||||
"""
|
||||
return encode_host_frame(
|
||||
HostHelloFrame(
|
||||
version="0.1.0-test",
|
||||
frame_protocol_version=1,
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mkdir_app(
|
||||
db_uri: str,
|
||||
) -> tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore]:
|
||||
"""
|
||||
App with host tunnel + REST routes for create-directory tests.
|
||||
|
||||
:param db_uri: SQLite URI fixture.
|
||||
:returns: (app, registry, host_store, conv_store).
|
||||
"""
|
||||
registry = HostRegistry()
|
||||
host_store = HostStore(db_uri)
|
||||
conv_store = SqlAlchemyConversationStore(db_uri)
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
create_host_tunnel_router(registry, host_store),
|
||||
prefix="/v1",
|
||||
)
|
||||
app.include_router(
|
||||
create_hosts_router(registry, host_store, conv_store),
|
||||
prefix="/v1",
|
||||
)
|
||||
return app, registry, host_store, conv_store
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def mkdir_setup(
|
||||
mkdir_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
||||
) -> AsyncIterator[
|
||||
tuple[
|
||||
FastAPI,
|
||||
HostRegistry,
|
||||
ApplicationCommunicator,
|
||||
dict[str, dict[str, Any]],
|
||||
asyncio.Task[None],
|
||||
]
|
||||
]:
|
||||
"""
|
||||
Connect a mock host and start an auto-replier for create_dir frames.
|
||||
|
||||
Tests register fake replies in ``replies`` (path → reply dict)
|
||||
before calling the REST endpoint. The auto-replier consumes the
|
||||
``host.create_dir`` frames the route pushes through the registry,
|
||||
decodes them, and feeds the configured result back — mirroring what
|
||||
``host_tunnel.py`` does in production. An unregistered path defaults
|
||||
to a successful create echoing the requested path.
|
||||
|
||||
:param mkdir_app: The fixture above.
|
||||
:returns: Async iterator yielding the wired-up state.
|
||||
"""
|
||||
app, registry, _hs, _cs = mkdir_app
|
||||
path = f"/v1/hosts/{_HOST_ID}/tunnel"
|
||||
comm = ApplicationCommunicator(app, _websocket_scope(path))
|
||||
await comm.send_input({"type": "websocket.connect"})
|
||||
accepted = await comm.receive_output(timeout=1.0)
|
||||
assert accepted["type"] == "websocket.accept"
|
||||
await comm.send_input({"type": "websocket.receive", "text": _hello_text()})
|
||||
while registry.get(_HOST_ID) is None:
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
conn = registry.get(_HOST_ID)
|
||||
assert conn is not None
|
||||
replies: dict[str, dict[str, Any]] = {}
|
||||
stop_drain = asyncio.Event()
|
||||
|
||||
async def _drain() -> None:
|
||||
"""Drain outbound WS frames and reply to create_dir frames.
|
||||
|
||||
:returns: None when ``stop_drain`` is set or no events arrive
|
||||
within the per-iteration timeout.
|
||||
"""
|
||||
while not stop_drain.is_set():
|
||||
try:
|
||||
output = await comm.receive_output(timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
if output.get("type") != "websocket.send":
|
||||
continue
|
||||
text = output.get("text")
|
||||
if not isinstance(text, str):
|
||||
continue
|
||||
frame = decode_host_frame(text)
|
||||
if not isinstance(frame, HostCreateDirFrame):
|
||||
continue
|
||||
reply = replies.get(frame.path)
|
||||
if reply is None:
|
||||
# Default: success, echoing the requested path (the host
|
||||
# would return the created absolute path).
|
||||
reply_frame = HostCreateDirResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
path=frame.path,
|
||||
)
|
||||
else:
|
||||
reply_frame = HostCreateDirResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status=reply.get("status", "ok"),
|
||||
path=reply.get("path"),
|
||||
error=reply.get("error"),
|
||||
)
|
||||
await comm.send_input(
|
||||
{
|
||||
"type": "websocket.receive",
|
||||
"text": encode_host_frame(reply_frame),
|
||||
}
|
||||
)
|
||||
|
||||
drain_task = asyncio.create_task(_drain())
|
||||
try:
|
||||
yield app, registry, comm, replies, drain_task
|
||||
finally:
|
||||
stop_drain.set()
|
||||
try:
|
||||
await asyncio.wait_for(drain_task, timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
drain_task.cancel()
|
||||
|
||||
|
||||
# ── Happy path ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_create_directory_returns_created_path(
|
||||
mkdir_setup: tuple[
|
||||
FastAPI,
|
||||
HostRegistry,
|
||||
ApplicationCommunicator,
|
||||
dict[str, dict[str, Any]],
|
||||
asyncio.Task[None],
|
||||
],
|
||||
) -> None:
|
||||
"""
|
||||
A valid create request returns the created absolute path.
|
||||
|
||||
This is what the picker navigates into after creating the folder,
|
||||
so the path must round-trip through the endpoint intact.
|
||||
"""
|
||||
app, _reg, _comm, replies, _drain = mkdir_setup
|
||||
target = "/Users/corey/projects/new-app"
|
||||
replies[target] = {"status": "ok", "path": target}
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/v1/hosts/{_HOST_ID}/directories",
|
||||
json={"path": target},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["object"] == "directory"
|
||||
assert body["path"] == target
|
||||
|
||||
|
||||
async def test_create_directory_already_exists_returns_409(
|
||||
mkdir_setup: tuple[
|
||||
FastAPI,
|
||||
HostRegistry,
|
||||
ApplicationCommunicator,
|
||||
dict[str, dict[str, Any]],
|
||||
asyncio.Task[None],
|
||||
],
|
||||
) -> None:
|
||||
"""
|
||||
An "already exists" host result maps to 409 with the message.
|
||||
|
||||
The picker shows this inline so the user knows the name is taken
|
||||
rather than seeing a generic failure.
|
||||
"""
|
||||
app, _reg, _comm, replies, _drain = mkdir_setup
|
||||
target = "/Users/corey/projects/dup"
|
||||
replies[target] = {"status": "ok", "error": "directory already exists"}
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/v1/hosts/{_HOST_ID}/directories",
|
||||
json={"path": target},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert "already exists" in resp.json()["detail"]
|
||||
|
||||
|
||||
async def test_create_directory_relative_path_rejected(
|
||||
mkdir_setup: tuple[
|
||||
FastAPI,
|
||||
HostRegistry,
|
||||
ApplicationCommunicator,
|
||||
dict[str, dict[str, Any]],
|
||||
asyncio.Task[None],
|
||||
],
|
||||
) -> None:
|
||||
"""
|
||||
A relative path is rejected with 400 before reaching the host.
|
||||
|
||||
The host needs a path it can resolve on its own; a relative path
|
||||
has no stable meaning across host process cwds.
|
||||
"""
|
||||
app, _reg, _comm, _replies, _drain = mkdir_setup
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/v1/hosts/{_HOST_ID}/directories",
|
||||
json={"path": "relative/dir"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_create_directory_unknown_host_returns_404(
|
||||
mkdir_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
||||
) -> None:
|
||||
"""
|
||||
Creating under an unknown host returns 404 (don't leak existence).
|
||||
"""
|
||||
app, _reg, _hs, _cs = mkdir_app
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/v1/hosts/host_does_not_exist/directories",
|
||||
json={"path": "/tmp/x"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user