Add workspace setup streaming and setup tab
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { gotoAppShell } from "./app";
|
||||||
|
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||||
|
|
||||||
|
type WorkspaceSetupDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
openProject(
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
|
||||||
|
createPaseoWorktree(
|
||||||
|
input: { cwd: string; worktreeSlug?: string },
|
||||||
|
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
|
||||||
|
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceSetupProgressPayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "workspace_setup_progress" }
|
||||||
|
>["payload"];
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => WorkspaceSetupDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `workspace-setup-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedProjectForWorkspaceSetup(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await client.openProject(repoPath);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectNameFromPath(repoPath: string): string {
|
||||||
|
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspaceButton(page: Page, repoPath: string) {
|
||||||
|
return page.getByRole("button", {
|
||||||
|
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
|
||||||
|
await page.getByTestId(`sidebar-project-row-${repoPath}`).hover();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
|
||||||
|
await revealWorkspaceButton(page, repoPath);
|
||||||
|
await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await createWorkspaceButton(page, repoPath).click();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupPanel(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupStatus(
|
||||||
|
page: Page,
|
||||||
|
status: "Running" | "Completed" | "Failed",
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceThroughDaemon(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
input: { cwd: string; worktreeSlug: string },
|
||||||
|
): Promise<{ id: string; name: string }> {
|
||||||
|
const result = await client.createPaseoWorktree(input);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||||
|
}
|
||||||
|
return result.workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForWorkspaceSetupProgress(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<WorkspaceSetupProgressPayload> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
unsubscribe();
|
||||||
|
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||||
|
if (message.type !== "workspace_setup_progress") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!predicate(message.payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
unsubscribe();
|
||||||
|
resolve(message.payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@ type TempRepo = {
|
|||||||
|
|
||||||
export const createTempGitRepo = async (
|
export const createTempGitRepo = async (
|
||||||
prefix = "paseo-e2e-",
|
prefix = "paseo-e2e-",
|
||||||
options?: { withRemote?: boolean },
|
options?: {
|
||||||
|
withRemote?: boolean;
|
||||||
|
paseoConfig?: Record<string, unknown>;
|
||||||
|
files?: Array<{ path: string; content: string }>;
|
||||||
|
},
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||||
@@ -22,7 +26,24 @@ export const createTempGitRepo = async (
|
|||||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||||
|
if (options?.paseoConfig) {
|
||||||
|
await writeFile(
|
||||||
|
path.join(repoPath, "paseo.json"),
|
||||||
|
JSON.stringify(options.paseoConfig, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of options?.files ?? []) {
|
||||||
|
const filePath = path.join(repoPath, file.path);
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, file.content);
|
||||||
|
}
|
||||||
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
if (options?.paseoConfig) {
|
||||||
|
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
}
|
||||||
|
for (const file of options?.files ?? []) {
|
||||||
|
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
|
}
|
||||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
createWorkspaceFromSidebar,
|
||||||
|
createWorkspaceThroughDaemon,
|
||||||
|
expectSetupPanel,
|
||||||
|
openHomeWithProject,
|
||||||
|
seedProjectForWorkspaceSetup,
|
||||||
|
waitForWorkspaceSetupProgress,
|
||||||
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
test.describe("Workspace setup streaming", () => {
|
||||||
|
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-open-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await createWorkspaceFromSidebar(page, repo.path);
|
||||||
|
|
||||||
|
await expectSetupPanel(page);
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams running and completed setup snapshots for a successful setup", async () => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-success-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const running = waitForWorkspaceSetupProgress(client, (payload) => payload.status === "running");
|
||||||
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: "workspace-setup-success",
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningPayload = await running;
|
||||||
|
const completedPayload = await completed;
|
||||||
|
|
||||||
|
expect(runningPayload.detail.log).toContain("starting setup");
|
||||||
|
expect(completedPayload.detail.log).toContain("setup complete");
|
||||||
|
expect(completedPayload.error).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams a failed setup snapshot when setup fails", async () => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-failure-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const failed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: "workspace-setup-failure",
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedPayload = await failed;
|
||||||
|
expect(failedPayload.detail.log).toContain("starting setup");
|
||||||
|
expect(failedPayload.detail.log).toContain("setup failed");
|
||||||
|
expect(failedPayload.error).toMatch(/failed/i);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-none-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) =>
|
||||||
|
payload.status === "completed" &&
|
||||||
|
payload.detail.commands.length === 0 &&
|
||||||
|
payload.detail.log === "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: "workspace-setup-none",
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedPayload = await completed;
|
||||||
|
expect(completedPayload.error).toBeNull();
|
||||||
|
expect(completedPayload.detail.commands).toEqual([]);
|
||||||
|
expect(completedPayload.detail.log).toBe("");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,9 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
|||||||
if (openIntent.kind === "file") {
|
if (openIntent.kind === "file") {
|
||||||
return { kind: "file", path: openIntent.path };
|
return { kind: "file", path: openIntent.path };
|
||||||
}
|
}
|
||||||
|
if (openIntent.kind === "setup") {
|
||||||
|
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
||||||
|
}
|
||||||
return { kind: "draft", draftId: openIntent.draftId };
|
return { kind: "draft", draftId: openIntent.draftId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -695,7 +695,7 @@ function ProjectHeaderRow({
|
|||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId: serverId!,
|
serverId: serverId!,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
target: { kind: "draft", draftId: "new" },
|
target: { kind: "setup", workspaceId: workspace.id },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||||
import { sendOsNotification } from "@/utils/os-notifications";
|
import { sendOsNotification } from "@/utils/os-notifications";
|
||||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||||
@@ -159,6 +160,10 @@ type WorkspaceUpdatePayload = Extract<
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "workspace_update" }
|
{ type: "workspace_update" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
|
type WorkspaceSetupProgressPayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "workspace_setup_progress" }
|
||||||
|
>["payload"];
|
||||||
|
|
||||||
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
||||||
update.kind === "remove" ? update.agentId : update.agent.id;
|
update.kind === "remove" ? update.agentId : update.agent.id;
|
||||||
@@ -264,6 +269,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||||
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
||||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||||
|
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||||
|
const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace);
|
||||||
|
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
|
||||||
|
|
||||||
// Track focused agent for heartbeat
|
// Track focused agent for heartbeat
|
||||||
const focusedAgentId = useSessionStore(
|
const focusedAgentId = useSessionStore(
|
||||||
@@ -748,6 +756,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const applyWorkspaceSetupProgress = useCallback(
|
||||||
|
(payload: WorkspaceSetupProgressPayload) => {
|
||||||
|
upsertWorkspaceSetupProgress({ serverId, payload });
|
||||||
|
},
|
||||||
|
[serverId, upsertWorkspaceSetupProgress],
|
||||||
|
);
|
||||||
|
|
||||||
const requestCanonicalCatchUp = useCallback(
|
const requestCanonicalCatchUp = useCallback(
|
||||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
||||||
void client
|
void client
|
||||||
@@ -1090,12 +1105,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||||
if (message.type !== "workspace_update") return;
|
if (message.type !== "workspace_update") return;
|
||||||
if (message.payload.kind === "remove") {
|
if (message.payload.kind === "remove") {
|
||||||
|
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
|
||||||
removeWorkspace(serverId, message.payload.id);
|
removeWorkspace(serverId, message.payload.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
|
||||||
|
if (message.type !== "workspace_setup_progress") return;
|
||||||
|
applyWorkspaceSetupProgress(message.payload);
|
||||||
|
});
|
||||||
|
|
||||||
const unsubStatus = client.on("status", (message) => {
|
const unsubStatus = client.on("status", (message) => {
|
||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||||
@@ -1444,6 +1465,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
unsubAgentStream();
|
unsubAgentStream();
|
||||||
unsubAgentTimeline();
|
unsubAgentTimeline();
|
||||||
unsubWorkspaceUpdate();
|
unsubWorkspaceUpdate();
|
||||||
|
unsubWorkspaceSetupProgress();
|
||||||
unsubStatus();
|
unsubStatus();
|
||||||
unsubPermissionRequest();
|
unsubPermissionRequest();
|
||||||
unsubPermissionResolved();
|
unsubPermissionResolved();
|
||||||
@@ -1471,6 +1493,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
setAgents,
|
setAgents,
|
||||||
mergeWorkspaces,
|
mergeWorkspaces,
|
||||||
removeWorkspace,
|
removeWorkspace,
|
||||||
|
removeWorkspaceSetup,
|
||||||
setAgentLastActivity,
|
setAgentLastActivity,
|
||||||
setPendingPermissions,
|
setPendingPermissions,
|
||||||
setHasHydratedAgents,
|
setHasHydratedAgents,
|
||||||
@@ -1478,6 +1501,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
notifyAgentAttention,
|
notifyAgentAttention,
|
||||||
requestCanonicalCatchUp,
|
requestCanonicalCatchUp,
|
||||||
applyAgentUpdatePayload,
|
applyAgentUpdatePayload,
|
||||||
|
applyWorkspaceSetupProgress,
|
||||||
applyTimelineResponse,
|
applyTimelineResponse,
|
||||||
voiceRuntime,
|
voiceRuntime,
|
||||||
voiceAudioEngine,
|
voiceAudioEngine,
|
||||||
@@ -1681,9 +1705,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
clearWorkspaceSetupServer(serverId);
|
||||||
clearSession(serverId);
|
clearSession(serverId);
|
||||||
};
|
};
|
||||||
}, [clearSession, serverId]);
|
}, [clearSession, clearWorkspaceSetupServer, serverId]);
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { agentPanelRegistration } from "@/panels/agent-panel";
|
|||||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||||
import { filePanelRegistration } from "@/panels/file-panel";
|
import { filePanelRegistration } from "@/panels/file-panel";
|
||||||
import { registerPanel } from "@/panels/panel-registry";
|
import { registerPanel } from "@/panels/panel-registry";
|
||||||
|
import { setupPanelRegistration } from "@/panels/setup-panel";
|
||||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||||
|
|
||||||
let panelsRegistered = false;
|
let panelsRegistered = false;
|
||||||
@@ -12,6 +13,7 @@ export function ensurePanelsRegistered(): void {
|
|||||||
}
|
}
|
||||||
registerPanel(draftPanelRegistration);
|
registerPanel(draftPanelRegistration);
|
||||||
registerPanel(agentPanelRegistration);
|
registerPanel(agentPanelRegistration);
|
||||||
|
registerPanel(setupPanelRegistration);
|
||||||
registerPanel(terminalPanelRegistration);
|
registerPanel(terminalPanelRegistration);
|
||||||
registerPanel(filePanelRegistration);
|
registerPanel(filePanelRegistration);
|
||||||
panelsRegistered = true;
|
panelsRegistered = true;
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { CheckCircle2, CircleAlert, SquareTerminal } from "lucide-react-native";
|
||||||
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Fonts } from "@/constants/theme";
|
||||||
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
|
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||||
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
|
|
||||||
|
function useSetupPanelDescriptor(
|
||||||
|
target: { kind: "setup"; workspaceId: string },
|
||||||
|
context: { serverId: string; workspaceId: string },
|
||||||
|
): PanelDescriptor {
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({
|
||||||
|
serverId: context.serverId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
});
|
||||||
|
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
|
||||||
|
|
||||||
|
if (snapshot?.status === "completed") {
|
||||||
|
return {
|
||||||
|
label: "Setup",
|
||||||
|
subtitle: "Setup completed",
|
||||||
|
titleState: "ready",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
statusBucket: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot?.status === "failed") {
|
||||||
|
return {
|
||||||
|
label: "Setup",
|
||||||
|
subtitle: "Setup failed",
|
||||||
|
titleState: "ready",
|
||||||
|
icon: CircleAlert,
|
||||||
|
statusBucket: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "Setup",
|
||||||
|
subtitle: "Workspace setup",
|
||||||
|
titleState: "ready",
|
||||||
|
icon: SquareTerminal,
|
||||||
|
statusBucket: snapshot?.status === "running" ? "running" : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommandStatus(status: "running" | "completed" | "failed"): string {
|
||||||
|
if (status === "running") {
|
||||||
|
return "Running";
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return "Completed";
|
||||||
|
}
|
||||||
|
return "Failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSetupStatus(status: "running" | "completed" | "failed" | null): string {
|
||||||
|
if (status === "running") {
|
||||||
|
return "Running";
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return "Completed";
|
||||||
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return "Failed";
|
||||||
|
}
|
||||||
|
return "Waiting for setup output";
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetupPanel() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const { serverId, target } = usePaneContext();
|
||||||
|
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||||
|
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({
|
||||||
|
serverId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
});
|
||||||
|
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
|
||||||
|
|
||||||
|
const commands = snapshot?.detail.commands ?? [];
|
||||||
|
const log = snapshot?.detail.log ?? "";
|
||||||
|
const statusLabel = formatSetupStatus(snapshot?.status ?? null);
|
||||||
|
const hasNoSetupCommands =
|
||||||
|
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="workspace-setup-panel">
|
||||||
|
<View
|
||||||
|
style={styles.header}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Workspace setup status: ${statusLabel}`}
|
||||||
|
testID="workspace-setup-status"
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>Workspace setup</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
snapshot?.status === "completed" && {
|
||||||
|
backgroundColor: theme.colors.palette.green[100],
|
||||||
|
},
|
||||||
|
snapshot?.status === "failed" && {
|
||||||
|
backgroundColor: theme.colors.palette.red[100],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusBadgeText,
|
||||||
|
snapshot?.status === "completed" && {
|
||||||
|
color: theme.colors.palette.green[600],
|
||||||
|
},
|
||||||
|
snapshot?.status === "failed" && {
|
||||||
|
color: theme.colors.palette.red[600],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{snapshot?.error ? (
|
||||||
|
<View style={styles.errorCard}>
|
||||||
|
<Text style={styles.errorTitle}>Setup error</Text>
|
||||||
|
<Text selectable style={styles.errorBody}>
|
||||||
|
{snapshot.error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{commands.length > 0 ? (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Commands</Text>
|
||||||
|
<View style={styles.commandList}>
|
||||||
|
{commands.map((command) => (
|
||||||
|
<View key={`${command.index}:${command.command}`} style={styles.commandRow}>
|
||||||
|
<Text style={styles.commandIndex}>{command.index}.</Text>
|
||||||
|
<View style={styles.commandTextColumn}>
|
||||||
|
<Text selectable style={styles.commandText}>
|
||||||
|
{command.command}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.commandMeta}>
|
||||||
|
{formatCommandStatus(command.status)}
|
||||||
|
{typeof command.exitCode === "number" ? ` · exit ${command.exitCode}` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.sectionFill}>
|
||||||
|
<Text style={styles.sectionTitle}>Log</Text>
|
||||||
|
{hasNoSetupCommands ? (
|
||||||
|
<View style={styles.emptyCard}>
|
||||||
|
<Text
|
||||||
|
style={styles.emptyText}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="No setup commands ran for this workspace"
|
||||||
|
>
|
||||||
|
No setup commands ran for this workspace.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.logContainer}
|
||||||
|
contentContainerStyle={styles.logContent}
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
testID="workspace-setup-log"
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Workspace setup log"
|
||||||
|
>
|
||||||
|
<Text selectable style={styles.logText}>
|
||||||
|
{log.trim().length > 0 ? log : "Waiting for setup output..."}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setupPanelRegistration: PanelRegistration<"setup"> = {
|
||||||
|
kind: "setup",
|
||||||
|
component: SetupPanel,
|
||||||
|
useDescriptor: useSetupPanelDescriptor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
statusBadgeText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
errorCard: {
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.palette.red[200],
|
||||||
|
backgroundColor: theme.colors.palette.red[100],
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.palette.red[800],
|
||||||
|
},
|
||||||
|
errorBody: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.palette.red[800],
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
sectionFill: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
commandList: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
commandRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
},
|
||||||
|
commandIndex: {
|
||||||
|
width: 18,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
commandTextColumn: {
|
||||||
|
flex: 1,
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
commandText: {
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
commandMeta: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
logContainer: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
logContent: {
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
},
|
||||||
|
logText: {
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
emptyCard: {
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -79,6 +79,9 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
|||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return "New Agent";
|
return "New Agent";
|
||||||
}
|
}
|
||||||
|
if (tab.target.kind === "setup") {
|
||||||
|
return "Setup";
|
||||||
|
}
|
||||||
if (tab.target.kind === "terminal") {
|
if (tab.target.kind === "terminal") {
|
||||||
return "Terminal";
|
return "Terminal";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
|||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return "New Agent";
|
return "New Agent";
|
||||||
}
|
}
|
||||||
|
if (tab.target.kind === "setup") {
|
||||||
|
return "Setup";
|
||||||
|
}
|
||||||
if (tab.target.kind === "terminal") {
|
if (tab.target.kind === "terminal") {
|
||||||
return "Terminal";
|
return "Terminal";
|
||||||
}
|
}
|
||||||
@@ -152,6 +155,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
|||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return "New Agent";
|
return "New Agent";
|
||||||
}
|
}
|
||||||
|
if (tab.target.kind === "setup") {
|
||||||
|
return "Workspace setup";
|
||||||
|
}
|
||||||
if (tab.target.kind === "agent") {
|
if (tab.target.kind === "agent") {
|
||||||
return "Agent";
|
return "Agent";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
|||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return `workspace-draft-close-${tab.target.draftId}`;
|
return `workspace-draft-close-${tab.target.draftId}`;
|
||||||
}
|
}
|
||||||
|
if (tab.target.kind === "setup") {
|
||||||
|
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`;
|
||||||
|
}
|
||||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
|
export type WorkspaceSetupProgressPayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "workspace_setup_progress" }
|
||||||
|
>["payload"];
|
||||||
|
|
||||||
|
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceSetupStoreState {
|
||||||
|
snapshots: Record<string, WorkspaceSetupSnapshot>;
|
||||||
|
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
|
||||||
|
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
|
||||||
|
clearServer: (serverId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceSetupKey(input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}): string | null {
|
||||||
|
return buildWorkspaceTabPersistenceKey(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
|
||||||
|
snapshots: {},
|
||||||
|
upsertProgress: ({ serverId, payload }) => {
|
||||||
|
const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId });
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
snapshots: {
|
||||||
|
...state.snapshots,
|
||||||
|
[key]: {
|
||||||
|
...payload,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
removeWorkspace: ({ serverId, workspaceId }) => {
|
||||||
|
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
if (!(key in state.snapshots)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const next = { ...state.snapshots };
|
||||||
|
delete next[key];
|
||||||
|
return { snapshots: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearServer: (serverId) => {
|
||||||
|
set((state) => {
|
||||||
|
const nextEntries = Object.entries(state.snapshots).filter(
|
||||||
|
([key]) => !key.startsWith(`${serverId}:`),
|
||||||
|
);
|
||||||
|
if (nextEntries.length === Object.keys(state.snapshots).length) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { snapshots: Object.fromEntries(nextEntries) };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -200,4 +200,19 @@ describe("workspace-tabs-store retargetTab", () => {
|
|||||||
expect(reopenedFileTabId).toBe(fileTabId);
|
expect(reopenedFileTabId).toBe(fileTabId);
|
||||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId);
|
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds a deterministic setup tab keyed by workspace id", () => {
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||||
|
expect(key).toBeTruthy();
|
||||||
|
const workspaceKey = key as string;
|
||||||
|
|
||||||
|
const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tabId).toBe(`setup_${WORKSPACE_ID}`);
|
||||||
|
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export type WorkspaceTabTarget =
|
|||||||
| { kind: "draft"; draftId: string }
|
| { kind: "draft"; draftId: string }
|
||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "terminal"; terminalId: string }
|
| { kind: "terminal"; terminalId: string }
|
||||||
| { kind: "file"; path: string };
|
| { kind: "file"; path: string }
|
||||||
|
| { kind: "setup"; workspaceId: string };
|
||||||
|
|
||||||
export type WorkspaceTab = {
|
export type WorkspaceTab = {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -60,6 +61,10 @@ function normalizeTabTarget(
|
|||||||
const path = trimNonEmpty(value.path);
|
const path = trimNonEmpty(value.path);
|
||||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||||
}
|
}
|
||||||
|
if (value.kind === "setup") {
|
||||||
|
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||||
|
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +84,9 @@ function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): b
|
|||||||
if (left.kind === "file" && right.kind === "file") {
|
if (left.kind === "file" && right.kind === "file") {
|
||||||
return left.path === right.path;
|
return left.path === right.path;
|
||||||
}
|
}
|
||||||
|
if (left.kind === "setup" && right.kind === "setup") {
|
||||||
|
return left.workspaceId === right.workspaceId;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +100,9 @@ function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
|||||||
if (target.kind === "terminal") {
|
if (target.kind === "terminal") {
|
||||||
return `terminal_${target.terminalId}`;
|
return `terminal_${target.terminalId}`;
|
||||||
}
|
}
|
||||||
|
if (target.kind === "setup") {
|
||||||
|
return `setup_${target.workspaceId}`;
|
||||||
|
}
|
||||||
return `file_${target.path}`;
|
return `file_${target.path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ describe("workspace route parsing", () => {
|
|||||||
kind: "file",
|
kind: "file",
|
||||||
path: "src/index.ts",
|
path: "src/index.ts",
|
||||||
});
|
});
|
||||||
|
expect(parseWorkspaceOpenIntent("setup:L3RtcC9yZXBv")).toEqual({
|
||||||
|
kind: "setup",
|
||||||
|
workspaceId: "/tmp/repo",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the plain workspace route when workspace context is provided", () => {
|
it("uses the plain workspace route when workspace context is provided", () => {
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ export type WorkspaceOpenIntent =
|
|||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "terminal"; terminalId: string }
|
| { kind: "terminal"; terminalId: string }
|
||||||
| { kind: "file"; path: string }
|
| { kind: "file"; path: string }
|
||||||
| { kind: "draft"; draftId: string };
|
| { kind: "draft"; draftId: string }
|
||||||
|
| { kind: "setup"; workspaceId: string };
|
||||||
|
|
||||||
export function parseWorkspaceOpenIntent(
|
export function parseWorkspaceOpenIntent(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
@@ -136,6 +137,13 @@ export function parseWorkspaceOpenIntent(
|
|||||||
}
|
}
|
||||||
return { kind: "file", path: decodedPath };
|
return { kind: "file", path: decodedPath };
|
||||||
}
|
}
|
||||||
|
if (kind === "setup") {
|
||||||
|
const workspaceId = decodeWorkspaceIdFromPathSegment(payload);
|
||||||
|
if (!workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { kind: "setup", workspaceId };
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget(
|
|||||||
const path = trimNonEmpty(value.path);
|
const path = trimNonEmpty(value.path);
|
||||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||||
}
|
}
|
||||||
|
if (value.kind === "setup") {
|
||||||
|
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||||
|
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +48,9 @@ export function workspaceTabTargetsEqual(
|
|||||||
if (left.kind === "file" && right.kind === "file") {
|
if (left.kind === "file" && right.kind === "file") {
|
||||||
return left.path === right.path;
|
return left.path === right.path;
|
||||||
}
|
}
|
||||||
|
if (left.kind === "setup" && right.kind === "setup") {
|
||||||
|
return left.workspaceId === right.workspaceId;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +64,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
|||||||
if (target.kind === "terminal") {
|
if (target.kind === "terminal") {
|
||||||
return `terminal_${target.terminalId}`;
|
return `terminal_${target.terminalId}`;
|
||||||
}
|
}
|
||||||
|
if (target.kind === "setup") {
|
||||||
|
return `setup_${target.workspaceId}`;
|
||||||
|
}
|
||||||
return `file_${target.path}`;
|
return `file_${target.path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,80 @@ describe("DaemonClient", () => {
|
|||||||
expect(client.getConnectionState().status).toBe("disposed");
|
expect(client.getConnectionState().status).toBe("disposed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const events: Array<Parameters<Parameters<typeof client.subscribe>[0]>[0]> = [];
|
||||||
|
client.subscribe((event) => {
|
||||||
|
events.push(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "workspace_setup_progress",
|
||||||
|
payload: {
|
||||||
|
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||||
|
status: "running",
|
||||||
|
detail: {
|
||||||
|
type: "worktree_setup",
|
||||||
|
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
||||||
|
branchName: "feature-a",
|
||||||
|
log: "phase-one\n",
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
command: "npm install",
|
||||||
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
||||||
|
status: "running",
|
||||||
|
exitCode: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "workspace_setup_progress",
|
||||||
|
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||||
|
payload: {
|
||||||
|
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
|
||||||
|
status: "running",
|
||||||
|
detail: {
|
||||||
|
type: "worktree_setup",
|
||||||
|
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
||||||
|
branchName: "feature-a",
|
||||||
|
log: "phase-one\n",
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
command: "npm install",
|
||||||
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
||||||
|
status: "running",
|
||||||
|
exitCode: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ export type DaemonEvent =
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "workspace_setup_progress";
|
||||||
|
workspaceId: string;
|
||||||
|
payload: Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }>["payload"];
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "agent_stream";
|
type: "agent_stream";
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -3523,6 +3528,12 @@ export class DaemonClient {
|
|||||||
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
|
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
|
||||||
payload: msg.payload,
|
payload: msg.payload,
|
||||||
};
|
};
|
||||||
|
case "workspace_setup_progress":
|
||||||
|
return {
|
||||||
|
type: "workspace_setup_progress",
|
||||||
|
workspaceId: msg.payload.workspaceId,
|
||||||
|
payload: msg.payload,
|
||||||
|
};
|
||||||
case "agent_stream":
|
case "agent_stream":
|
||||||
return {
|
return {
|
||||||
type: "agent_stream",
|
type: "agent_stream",
|
||||||
|
|||||||
@@ -299,21 +299,25 @@ export async function createAgentManagementMcpServer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let resolvedCwd = expandUserPath(cwd);
|
let resolvedCwd = expandUserPath(cwd);
|
||||||
let worktreeConfig: WorktreeConfig | undefined;
|
let worktreeBootstrap:
|
||||||
|
| {
|
||||||
|
worktree: WorktreeConfig;
|
||||||
|
shouldBootstrap: boolean;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
if (worktreeName) {
|
if (worktreeName) {
|
||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
throw new Error("baseBranch is required when creating a worktree");
|
throw new Error("baseBranch is required when creating a worktree");
|
||||||
}
|
}
|
||||||
const worktree = await createAgentWorktree({
|
worktreeBootstrap = await createAgentWorktree({
|
||||||
branchName: worktreeName,
|
branchName: worktreeName,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
worktreeSlug: worktreeName,
|
worktreeSlug: worktreeName,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
});
|
});
|
||||||
resolvedCwd = worktree.worktreePath;
|
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
|
||||||
worktreeConfig = worktree;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider: AgentProvider = agentType ?? "claude";
|
const provider: AgentProvider = agentType ?? "claude";
|
||||||
@@ -325,10 +329,11 @@ export async function createAgentManagementMcpServer(
|
|||||||
title: normalizedTitle ?? undefined,
|
title: normalizedTitle ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (worktreeConfig) {
|
if (worktreeBootstrap) {
|
||||||
void runAsyncWorktreeBootstrap({
|
void runAsyncWorktreeBootstrap({
|
||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
worktree: worktreeConfig,
|
worktree: worktreeBootstrap.worktree,
|
||||||
|
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||||
terminalManager: options.terminalManager ?? null,
|
terminalManager: options.terminalManager ?? null,
|
||||||
appendTimelineItem: (item) =>
|
appendTimelineItem: (item) =>
|
||||||
appendTimelineItemIfAgentKnown({
|
appendTimelineItemIfAgentKnown({
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
let resolvedCwd: string;
|
let resolvedCwd: string;
|
||||||
let resolvedMode: string | undefined;
|
let resolvedMode: string | undefined;
|
||||||
let worktreeConfig: WorktreeConfig | undefined;
|
let worktreeConfig: WorktreeConfig | undefined;
|
||||||
|
let shouldBootstrapWorktree: boolean | undefined;
|
||||||
|
|
||||||
if (callerAgentId) {
|
if (callerAgentId) {
|
||||||
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||||
@@ -467,15 +468,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
throw new Error("baseBranch is required when creating a worktree");
|
throw new Error("baseBranch is required when creating a worktree");
|
||||||
}
|
}
|
||||||
const worktree = await createAgentWorktree({
|
const worktreeBootstrap = await createAgentWorktree({
|
||||||
branchName: worktreeName,
|
branchName: worktreeName,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
worktreeSlug: worktreeName,
|
worktreeSlug: worktreeName,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
});
|
});
|
||||||
resolvedCwd = worktree.worktreePath;
|
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
|
||||||
worktreeConfig = worktree;
|
worktreeConfig = worktreeBootstrap.worktree;
|
||||||
|
shouldBootstrapWorktree = worktreeBootstrap.shouldBootstrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedMode = initialMode;
|
resolvedMode = initialMode;
|
||||||
@@ -500,6 +502,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
void runAsyncWorktreeBootstrap({
|
void runAsyncWorktreeBootstrap({
|
||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
worktree: worktreeConfig,
|
worktree: worktreeConfig,
|
||||||
|
shouldBootstrap: shouldBootstrapWorktree,
|
||||||
terminalManager: terminalManager ?? null,
|
terminalManager: terminalManager ?? null,
|
||||||
appendTimelineItem: (item) =>
|
appendTimelineItem: (item) =>
|
||||||
appendTimelineItemIfAgentKnown({
|
appendTimelineItemIfAgentKnown({
|
||||||
|
|||||||
@@ -2664,7 +2664,7 @@ export class Session {
|
|||||||
...(provisionalTitle ? { title: provisionalTitle } : {}),
|
...(provisionalTitle ? { title: provisionalTitle } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig(
|
const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig(
|
||||||
resolvedConfig,
|
resolvedConfig,
|
||||||
git,
|
git,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
@@ -2724,10 +2724,11 @@ export class Session {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (worktreeConfig) {
|
if (worktreeBootstrap) {
|
||||||
void runAsyncWorktreeBootstrap({
|
void runAsyncWorktreeBootstrap({
|
||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
worktree: worktreeConfig,
|
worktree: worktreeBootstrap.worktree,
|
||||||
|
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
appendTimelineItem: (item) =>
|
appendTimelineItem: (item) =>
|
||||||
appendTimelineItemIfAgentKnown({
|
appendTimelineItemIfAgentKnown({
|
||||||
@@ -2909,7 +2910,10 @@ export class Session {
|
|||||||
gitOptions?: GitSetupOptions,
|
gitOptions?: GitSetupOptions,
|
||||||
legacyWorktreeName?: string,
|
legacyWorktreeName?: string,
|
||||||
_labels?: Record<string, string>,
|
_labels?: Record<string, string>,
|
||||||
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
|
): Promise<{
|
||||||
|
sessionConfig: AgentSessionConfig;
|
||||||
|
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||||
|
}> {
|
||||||
return buildWorktreeAgentSessionConfig(
|
return buildWorktreeAgentSessionConfig(
|
||||||
{
|
{
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
@@ -5562,8 +5566,12 @@ export class Session {
|
|||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
||||||
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
||||||
|
emit: (message) => this.emit(message),
|
||||||
sessionLogger: this.sessionLogger,
|
sessionLogger: this.sessionLogger,
|
||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
|
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||||
|
serviceRouteStore: this.serviceRouteStore,
|
||||||
|
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||||
},
|
},
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktree = await createAgentWorktree({
|
const worktreeBootstrap = await createAgentWorktree({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
branchName: "feature-streaming-setup",
|
branchName: "feature-streaming-setup",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
@@ -69,7 +69,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
|
|
||||||
await runAsyncWorktreeBootstrap({
|
await runAsyncWorktreeBootstrap({
|
||||||
agentId: "agent-test",
|
agentId: "agent-test",
|
||||||
worktree,
|
worktree: worktreeBootstrap.worktree,
|
||||||
|
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||||
terminalManager: null,
|
terminalManager: null,
|
||||||
appendTimelineItem: async (item) => {
|
appendTimelineItem: async (item) => {
|
||||||
persisted.push(item);
|
persisted.push(item);
|
||||||
@@ -160,7 +161,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktree = await createAgentWorktree({
|
const worktreeBootstrap = await createAgentWorktree({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
branchName: "feature-live-failure",
|
branchName: "feature-live-failure",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
@@ -172,7 +173,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
runAsyncWorktreeBootstrap({
|
runAsyncWorktreeBootstrap({
|
||||||
agentId: "agent-live-failure",
|
agentId: "agent-live-failure",
|
||||||
worktree,
|
worktree: worktreeBootstrap.worktree,
|
||||||
|
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||||
terminalManager: null,
|
terminalManager: null,
|
||||||
appendTimelineItem: async (item) => {
|
appendTimelineItem: async (item) => {
|
||||||
persisted.push(item);
|
persisted.push(item);
|
||||||
@@ -210,7 +212,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktree = await createAgentWorktree({
|
const worktreeBootstrap = await createAgentWorktree({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
branchName: "feature-large-output",
|
branchName: "feature-large-output",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
@@ -221,7 +223,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
const persisted: AgentTimelineItem[] = [];
|
const persisted: AgentTimelineItem[] = [];
|
||||||
await runAsyncWorktreeBootstrap({
|
await runAsyncWorktreeBootstrap({
|
||||||
agentId: "agent-large-output",
|
agentId: "agent-large-output",
|
||||||
worktree,
|
worktree: worktreeBootstrap.worktree,
|
||||||
|
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||||
terminalManager: null,
|
terminalManager: null,
|
||||||
appendTimelineItem: async (item) => {
|
appendTimelineItem: async (item) => {
|
||||||
persisted.push(item);
|
persisted.push(item);
|
||||||
@@ -266,7 +269,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktree = await createAgentWorktree({
|
const worktreeBootstrap = await createAgentWorktree({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
branchName: "feature-terminal-readiness",
|
branchName: "feature-terminal-readiness",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
@@ -280,7 +283,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
|
|
||||||
await runAsyncWorktreeBootstrap({
|
await runAsyncWorktreeBootstrap({
|
||||||
agentId: "agent-terminal-readiness",
|
agentId: "agent-terminal-readiness",
|
||||||
worktree,
|
worktree: worktreeBootstrap.worktree,
|
||||||
|
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||||
terminalManager: {
|
terminalManager: {
|
||||||
async getTerminals() {
|
async getTerminals() {
|
||||||
return [];
|
return [];
|
||||||
@@ -357,7 +361,7 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktree = await createAgentWorktree({
|
const worktreeBootstrap = await createAgentWorktree({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
branchName: "feature-shared-runtime-port",
|
branchName: "feature-shared-runtime-port",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
@@ -370,7 +374,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
const persisted: AgentTimelineItem[] = [];
|
const persisted: AgentTimelineItem[] = [];
|
||||||
await runAsyncWorktreeBootstrap({
|
await runAsyncWorktreeBootstrap({
|
||||||
agentId: "agent-shared-runtime-port",
|
agentId: "agent-shared-runtime-port",
|
||||||
worktree,
|
worktree: worktreeBootstrap.worktree,
|
||||||
|
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||||
terminalManager: {
|
terminalManager: {
|
||||||
async getTerminals() {
|
async getTerminals() {
|
||||||
return [];
|
return [];
|
||||||
@@ -416,13 +421,13 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
emitLiveTimelineItem: async () => true,
|
emitLiveTimelineItem: async () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupPortPath = join(worktree.worktreePath, "setup-port.txt");
|
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
|
||||||
await waitForPathExists(setupPortPath);
|
await waitForPathExists(setupPortPath);
|
||||||
|
|
||||||
const setupPort = readFileSync(setupPortPath, "utf8").trim();
|
const setupPort = readFileSync(setupPortPath, "utf8").trim();
|
||||||
expect(setupPort.length).toBeGreaterThan(0);
|
expect(setupPort.length).toBeGreaterThan(0);
|
||||||
expect(registeredEnvs).toHaveLength(1);
|
expect(registeredEnvs).toHaveLength(1);
|
||||||
expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath);
|
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
|
||||||
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||||
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
||||||
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type WorktreeRuntimeEnv,
|
type WorktreeRuntimeEnv,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
import { findFreePort, type ServiceRouteStore } from "./service-proxy.js";
|
import { findFreePort, type ServiceRouteStore } from "./service-proxy.js";
|
||||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||||
|
|
||||||
export interface WorktreeBootstrapTerminalResult {
|
export interface WorktreeBootstrapTerminalResult {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -32,6 +32,7 @@ export interface WorktreeBootstrapTerminalResult {
|
|||||||
export interface RunAsyncWorktreeBootstrapOptions {
|
export interface RunAsyncWorktreeBootstrapOptions {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
|
shouldBootstrap?: boolean;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
serviceRouteStore?: ServiceRouteStore;
|
serviceRouteStore?: ServiceRouteStore;
|
||||||
daemonPort?: number | null;
|
daemonPort?: number | null;
|
||||||
@@ -48,6 +49,11 @@ export interface CreateAgentWorktreeOptions {
|
|||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateAgentWorktreeResult {
|
||||||
|
worktree: WorktreeConfig;
|
||||||
|
shouldBootstrap: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
||||||
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
||||||
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
|
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
|
||||||
@@ -56,8 +62,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
|||||||
GIT_OPTIONAL_LOCKS: "0",
|
GIT_OPTIONAL_LOCKS: "0",
|
||||||
};
|
};
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const worktreeSetupEligibility = new WeakMap<WorktreeConfig, boolean>();
|
|
||||||
|
|
||||||
type MiddleTruncationAccumulator = {
|
type MiddleTruncationAccumulator = {
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
head: string;
|
head: string;
|
||||||
@@ -65,6 +69,12 @@ type MiddleTruncationAccumulator = {
|
|||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorktreeSetupOutputAccumulator = MiddleTruncationAccumulator;
|
||||||
|
export type WorktreeSetupProgressAccumulator = {
|
||||||
|
resultsByIndex: Map<number, WorktreeSetupCommandResult>;
|
||||||
|
outputAccumulatorsByIndex: Map<number, WorktreeSetupOutputAccumulator>;
|
||||||
|
};
|
||||||
|
|
||||||
function byteLength(text: string): number {
|
function byteLength(text: string): number {
|
||||||
return Buffer.byteLength(text, "utf8");
|
return Buffer.byteLength(text, "utf8");
|
||||||
}
|
}
|
||||||
@@ -91,7 +101,7 @@ function sliceLastBytes(text: string, maxBytes: number): string {
|
|||||||
return bytes.subarray(bytes.length - maxBytes).toString("utf8");
|
return bytes.subarray(bytes.length - maxBytes).toString("utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator {
|
export function createWorktreeSetupOutputAccumulator(): WorktreeSetupOutputAccumulator {
|
||||||
return {
|
return {
|
||||||
totalBytes: 0,
|
totalBytes: 0,
|
||||||
head: "",
|
head: "",
|
||||||
@@ -108,8 +118,8 @@ function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: n
|
|||||||
return { headBytes, tailBytes };
|
return { headBytes, tailBytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendToMiddleTruncationAccumulator(
|
export function appendWorktreeSetupOutputAccumulator(
|
||||||
accumulator: MiddleTruncationAccumulator,
|
accumulator: WorktreeSetupOutputAccumulator,
|
||||||
chunk: string,
|
chunk: string,
|
||||||
): void {
|
): void {
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
@@ -166,16 +176,17 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula
|
|||||||
|
|
||||||
export async function createAgentWorktree(
|
export async function createAgentWorktree(
|
||||||
options: CreateAgentWorktreeOptions,
|
options: CreateAgentWorktreeOptions,
|
||||||
): Promise<WorktreeConfig> {
|
): Promise<CreateAgentWorktreeResult> {
|
||||||
const existingWorktree = await findExistingPaseoWorktreeBySlug(options);
|
const existingWorktree = await findExistingPaseoWorktreeBySlug(options);
|
||||||
if (existingWorktree) {
|
if (existingWorktree) {
|
||||||
const branchName = await resolveBranchNameForWorktreePath(existingWorktree.path);
|
const branchName = await resolveBranchNameForWorktreePath(existingWorktree.path);
|
||||||
const reusedWorktree = {
|
return {
|
||||||
branchName,
|
worktree: {
|
||||||
worktreePath: existingWorktree.path,
|
branchName,
|
||||||
|
worktreePath: existingWorktree.path,
|
||||||
|
},
|
||||||
|
shouldBootstrap: false,
|
||||||
};
|
};
|
||||||
worktreeSetupEligibility.set(reusedWorktree, false);
|
|
||||||
return reusedWorktree;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdWorktree = await createWorktree({
|
const createdWorktree = await createWorktree({
|
||||||
@@ -186,8 +197,10 @@ export async function createAgentWorktree(
|
|||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
});
|
});
|
||||||
worktreeSetupEligibility.set(createdWorktree, true);
|
return {
|
||||||
return createdWorktree;
|
worktree: createdWorktree,
|
||||||
|
shouldBootstrap: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptions) {
|
async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptions) {
|
||||||
@@ -226,7 +239,7 @@ function commandStatusFromResult(
|
|||||||
|
|
||||||
function buildWorktreeSetupLog(input: {
|
function buildWorktreeSetupLog(input: {
|
||||||
results: WorktreeSetupCommandResult[];
|
results: WorktreeSetupCommandResult[];
|
||||||
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||||
}): { log: string; truncated: boolean } {
|
}): { log: string; truncated: boolean } {
|
||||||
const { results, outputAccumulatorsByIndex } = input;
|
const { results, outputAccumulatorsByIndex } = input;
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
@@ -266,14 +279,68 @@ function buildWorktreeSetupLog(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSetupTimelineItem(input: {
|
export function createWorktreeSetupProgressAccumulator(): WorktreeSetupProgressAccumulator {
|
||||||
callId: string;
|
return {
|
||||||
status: "running" | "completed" | "failed";
|
resultsByIndex: new Map(),
|
||||||
|
outputAccumulatorsByIndex: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyWorktreeSetupProgressEvent(
|
||||||
|
accumulator: WorktreeSetupProgressAccumulator,
|
||||||
|
event: Parameters<NonNullable<Parameters<typeof runWorktreeSetupCommands>[0]["onEvent"]>>[0],
|
||||||
|
): void {
|
||||||
|
const existing = accumulator.resultsByIndex.get(event.index);
|
||||||
|
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||||
|
command: event.command,
|
||||||
|
cwd: event.cwd,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
exitCode: null,
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.type === "output") {
|
||||||
|
const outputAccumulator =
|
||||||
|
accumulator.outputAccumulatorsByIndex.get(event.index) ??
|
||||||
|
createWorktreeSetupOutputAccumulator();
|
||||||
|
appendWorktreeSetupOutputAccumulator(outputAccumulator, event.chunk);
|
||||||
|
accumulator.outputAccumulatorsByIndex.set(event.index, outputAccumulator);
|
||||||
|
accumulator.resultsByIndex.set(event.index, {
|
||||||
|
...baseResult,
|
||||||
|
stdout: baseResult.stdout,
|
||||||
|
stderr: baseResult.stderr,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "command_completed") {
|
||||||
|
accumulator.resultsByIndex.set(event.index, {
|
||||||
|
...baseResult,
|
||||||
|
stdout: event.stdout,
|
||||||
|
stderr: event.stderr,
|
||||||
|
exitCode: event.exitCode,
|
||||||
|
durationMs: event.durationMs,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator.resultsByIndex.set(event.index, baseResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorktreeSetupProgressResults(
|
||||||
|
accumulator: WorktreeSetupProgressAccumulator,
|
||||||
|
): WorktreeSetupCommandResult[] {
|
||||||
|
return Array.from(accumulator.resultsByIndex.entries())
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([, result]) => result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorktreeSetupDetail(input: {
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
results: WorktreeSetupCommandResult[];
|
results: WorktreeSetupCommandResult[];
|
||||||
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||||
errorMessage: string | null;
|
}): Extract<ToolCallDetail, { type: "worktree_setup" }> {
|
||||||
}): AgentTimelineItem {
|
|
||||||
const commands = input.results.map((result, index) => ({
|
const commands = input.results.map((result, index) => ({
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
command: result.command,
|
command: result.command,
|
||||||
@@ -286,14 +353,30 @@ function buildSetupTimelineItem(input: {
|
|||||||
results: input.results,
|
results: input.results,
|
||||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||||
});
|
});
|
||||||
const detail = {
|
|
||||||
type: "worktree_setup" as const,
|
return {
|
||||||
|
type: "worktree_setup",
|
||||||
worktreePath: input.worktree.worktreePath,
|
worktreePath: input.worktree.worktreePath,
|
||||||
branchName: input.worktree.branchName,
|
branchName: input.worktree.branchName,
|
||||||
log: renderedLog.log,
|
log: renderedLog.log,
|
||||||
commands,
|
commands,
|
||||||
...(renderedLog.truncated ? { truncated: true } : {}),
|
...(renderedLog.truncated ? { truncated: true } : {}),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSetupTimelineItem(input: {
|
||||||
|
callId: string;
|
||||||
|
status: "running" | "completed" | "failed";
|
||||||
|
worktree: WorktreeConfig;
|
||||||
|
results: WorktreeSetupCommandResult[];
|
||||||
|
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}): AgentTimelineItem {
|
||||||
|
const detail = buildWorktreeSetupDetail({
|
||||||
|
worktree: input.worktree,
|
||||||
|
results: input.results,
|
||||||
|
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||||
|
});
|
||||||
|
|
||||||
if (input.status === "running") {
|
if (input.status === "running") {
|
||||||
return {
|
return {
|
||||||
@@ -528,7 +611,7 @@ async function runWorktreeTerminalBootstrap(
|
|||||||
export async function runAsyncWorktreeBootstrap(
|
export async function runAsyncWorktreeBootstrap(
|
||||||
options: RunAsyncWorktreeBootstrapOptions,
|
options: RunAsyncWorktreeBootstrapOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (worktreeSetupEligibility.get(options.worktree) === false) {
|
if (options.shouldBootstrap === false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,17 +619,14 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||||
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||||
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
|
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||||
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
|
|
||||||
let liveEmitQueue = Promise.resolve();
|
let liveEmitQueue = Promise.resolve();
|
||||||
|
|
||||||
const queueLiveRunningEmit = () => {
|
const queueLiveRunningEmit = () => {
|
||||||
if (!emitLiveTimelineItem) {
|
if (!emitLiveTimelineItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const runningResults = Array.from(runningResultsByIndex.entries())
|
const runningResults = getWorktreeSetupProgressResults(progressAccumulator);
|
||||||
.sort((a, b) => a[0] - b[0])
|
|
||||||
.map(([, result]) => result);
|
|
||||||
liveEmitQueue = liveEmitQueue.then(async () => {
|
liveEmitQueue = liveEmitQueue.then(async () => {
|
||||||
try {
|
try {
|
||||||
await emitLiveTimelineItem(
|
await emitLiveTimelineItem(
|
||||||
@@ -555,7 +635,7 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
status: "running",
|
status: "running",
|
||||||
worktree: options.worktree,
|
worktree: options.worktree,
|
||||||
results: runningResults,
|
results: runningResults,
|
||||||
outputAccumulatorsByIndex,
|
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -584,42 +664,7 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
cleanupOnFailure: false,
|
cleanupOnFailure: false,
|
||||||
runtimeEnv,
|
runtimeEnv,
|
||||||
onEvent: (event) => {
|
onEvent: (event) => {
|
||||||
const existing = runningResultsByIndex.get(event.index);
|
applyWorktreeSetupProgressEvent(progressAccumulator, event);
|
||||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
|
||||||
command: event.command,
|
|
||||||
cwd: event.cwd,
|
|
||||||
stdout: "",
|
|
||||||
stderr: "",
|
|
||||||
exitCode: null,
|
|
||||||
durationMs: 0,
|
|
||||||
};
|
|
||||||
if (event.type === "output") {
|
|
||||||
const outputAccumulator =
|
|
||||||
outputAccumulatorsByIndex.get(event.index) ?? createMiddleTruncationAccumulator();
|
|
||||||
appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk);
|
|
||||||
outputAccumulatorsByIndex.set(event.index, outputAccumulator);
|
|
||||||
runningResultsByIndex.set(event.index, {
|
|
||||||
...baseResult,
|
|
||||||
// Keep the timeline command model lightweight; output is carried in
|
|
||||||
// outputAccumulatorsByIndex.
|
|
||||||
stdout: baseResult.stdout,
|
|
||||||
stderr: baseResult.stderr,
|
|
||||||
});
|
|
||||||
queueLiveRunningEmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.type === "command_completed") {
|
|
||||||
runningResultsByIndex.set(event.index, {
|
|
||||||
...baseResult,
|
|
||||||
stdout: event.stdout,
|
|
||||||
stderr: event.stderr,
|
|
||||||
exitCode: event.exitCode,
|
|
||||||
durationMs: event.durationMs,
|
|
||||||
});
|
|
||||||
queueLiveRunningEmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
runningResultsByIndex.set(event.index, baseResult);
|
|
||||||
queueLiveRunningEmit();
|
queueLiveRunningEmit();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -631,7 +676,7 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
worktree: options.worktree,
|
worktree: options.worktree,
|
||||||
results: setupResults,
|
results: setupResults,
|
||||||
outputAccumulatorsByIndex,
|
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -650,7 +695,7 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
worktree: options.worktree,
|
worktree: options.worktree,
|
||||||
results: setupResults,
|
results: setupResults,
|
||||||
outputAccumulatorsByIndex,
|
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||||
errorMessage: message,
|
errorMessage: message,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { SessionOutboundMessage } from "./messages.js";
|
||||||
|
import { ServiceRouteStore } from "./service-proxy.js";
|
||||||
|
import { createPaseoWorktreeInBackground } from "./worktree-session.js";
|
||||||
|
import { computeWorktreePath, createWorktree } from "../utils/worktree.js";
|
||||||
|
|
||||||
|
function createLogger() {
|
||||||
|
return {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerminalManagerStub(options?: {
|
||||||
|
createTerminal?: (input: {
|
||||||
|
cwd: string;
|
||||||
|
name?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}) => Promise<any>;
|
||||||
|
}) {
|
||||||
|
const terminals: Array<{
|
||||||
|
id: string;
|
||||||
|
cwd: string;
|
||||||
|
name: string | undefined;
|
||||||
|
env: Record<string, string> | undefined;
|
||||||
|
sent: string[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
terminals,
|
||||||
|
manager: {
|
||||||
|
registerCwdEnv: vi.fn(),
|
||||||
|
createTerminal: vi.fn(async (input: {
|
||||||
|
cwd: string;
|
||||||
|
name?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}) => {
|
||||||
|
if (options?.createTerminal) {
|
||||||
|
return options.createTerminal(input);
|
||||||
|
}
|
||||||
|
const sent: string[] = [];
|
||||||
|
const terminal = {
|
||||||
|
id: `terminal-${terminals.length + 1}`,
|
||||||
|
getState: () => ({
|
||||||
|
scrollback: [[{ char: "$" }]],
|
||||||
|
grid: [],
|
||||||
|
}),
|
||||||
|
subscribe: () => () => {},
|
||||||
|
send: (message: { type: string; data: string }) => {
|
||||||
|
if (message.type === "input") {
|
||||||
|
sent.push(message.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
terminals.push({
|
||||||
|
id: terminal.id,
|
||||||
|
cwd: input.cwd,
|
||||||
|
name: input.name,
|
||||||
|
env: input.env,
|
||||||
|
sent,
|
||||||
|
});
|
||||||
|
return terminal;
|
||||||
|
}),
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
|
||||||
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
|
||||||
|
const repoDir = path.join(tempDir, "repo");
|
||||||
|
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
|
||||||
|
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||||
|
if (options?.paseoConfig) {
|
||||||
|
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
|
||||||
|
}
|
||||||
|
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
return { tempDir, repoDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createPaseoWorktreeInBackground", () => {
|
||||||
|
const cleanupPaths: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const target of cleanupPaths.splice(0)) {
|
||||||
|
rmSync(target, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits a single completed snapshot for no-setup workspaces and then launches services", async () => {
|
||||||
|
const { tempDir, repoDir } = createGitRepo({
|
||||||
|
paseoConfig: {
|
||||||
|
services: {
|
||||||
|
web: {
|
||||||
|
command: "npm run dev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
|
||||||
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
|
const worktreePath = await computeWorktreePath(repoDir, "feature-no-setup", paseoHome);
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const routeStore = new ServiceRouteStore();
|
||||||
|
const logger = createLogger();
|
||||||
|
const terminalManager = createTerminalManagerStub();
|
||||||
|
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||||
|
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await createPaseoWorktreeInBackground(
|
||||||
|
{
|
||||||
|
paseoHome,
|
||||||
|
emitWorkspaceUpdateForCwd,
|
||||||
|
emit: (message) => emitted.push(message),
|
||||||
|
sessionLogger: logger,
|
||||||
|
terminalManager: terminalManager.manager,
|
||||||
|
archiveWorkspaceRecord,
|
||||||
|
serviceRouteStore: routeStore,
|
||||||
|
daemonPort: 6767,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestCwd: repoDir,
|
||||||
|
repoRoot: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
slug: "feature-no-setup",
|
||||||
|
worktreePath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressMessages = emitted.filter(
|
||||||
|
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||||
|
message.type === "workspace_setup_progress",
|
||||||
|
);
|
||||||
|
expect(progressMessages).toHaveLength(1);
|
||||||
|
expect(progressMessages[0]?.payload).toMatchObject({
|
||||||
|
workspaceId: worktreePath,
|
||||||
|
status: "completed",
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "worktree_setup",
|
||||||
|
worktreePath,
|
||||||
|
branchName: "feature-no-setup",
|
||||||
|
log: "",
|
||||||
|
commands: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(routeStore.listRoutes()).toEqual([
|
||||||
|
{ hostname: "feature-no-setup.web.localhost", port: expect.any(Number) },
|
||||||
|
]);
|
||||||
|
expect(terminalManager.terminals).toHaveLength(1);
|
||||||
|
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath);
|
||||||
|
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
|
||||||
|
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||||
|
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
|
||||||
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
|
||||||
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
|
const worktreePath = await computeWorktreePath(repoDir, "broken-feature", paseoHome);
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const logger = createLogger();
|
||||||
|
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||||
|
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await createPaseoWorktreeInBackground(
|
||||||
|
{
|
||||||
|
paseoHome,
|
||||||
|
emitWorkspaceUpdateForCwd,
|
||||||
|
emit: (message) => emitted.push(message),
|
||||||
|
sessionLogger: logger,
|
||||||
|
terminalManager: null,
|
||||||
|
archiveWorkspaceRecord,
|
||||||
|
serviceRouteStore: null,
|
||||||
|
daemonPort: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestCwd: repoDir,
|
||||||
|
repoRoot: repoDir,
|
||||||
|
baseBranch: "does-not-exist",
|
||||||
|
slug: "broken-feature",
|
||||||
|
worktreePath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressMessages = emitted.filter(
|
||||||
|
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||||
|
message.type === "workspace_setup_progress",
|
||||||
|
);
|
||||||
|
expect(progressMessages).toHaveLength(1);
|
||||||
|
expect(progressMessages[0]?.payload.status).toBe("failed");
|
||||||
|
expect(progressMessages[0]?.payload.error).toContain("does-not-exist");
|
||||||
|
expect(progressMessages[0]?.payload.detail.commands).toEqual([]);
|
||||||
|
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath);
|
||||||
|
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits running setup snapshots before completed for real setup commands", async () => {
|
||||||
|
const { tempDir, repoDir } = createGitRepo({
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ['sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
|
||||||
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
|
const worktreePath = await computeWorktreePath(repoDir, "feature-running-setup", paseoHome);
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const logger = createLogger();
|
||||||
|
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||||
|
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await createPaseoWorktreeInBackground(
|
||||||
|
{
|
||||||
|
paseoHome,
|
||||||
|
emitWorkspaceUpdateForCwd,
|
||||||
|
emit: (message) => emitted.push(message),
|
||||||
|
sessionLogger: logger,
|
||||||
|
terminalManager: null,
|
||||||
|
archiveWorkspaceRecord,
|
||||||
|
serviceRouteStore: null,
|
||||||
|
daemonPort: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestCwd: repoDir,
|
||||||
|
repoRoot: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
slug: "feature-running-setup",
|
||||||
|
worktreePath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressMessages = emitted.filter(
|
||||||
|
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||||
|
message.type === "workspace_setup_progress",
|
||||||
|
);
|
||||||
|
expect(progressMessages.length).toBeGreaterThan(1);
|
||||||
|
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
|
||||||
|
|
||||||
|
const runningMessages = progressMessages.filter((message) => message.payload.status === "running");
|
||||||
|
expect(runningMessages.length).toBeGreaterThan(0);
|
||||||
|
expect(progressMessages.findIndex((message) => message.payload.status === "running")).toBeLessThan(
|
||||||
|
progressMessages.findIndex((message) => message.payload.status === "completed"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runningMessages[0]?.payload.detail.log).toContain("phase-one");
|
||||||
|
expect(runningMessages[0]?.payload.detail.commands[0]).toMatchObject({
|
||||||
|
index: 1,
|
||||||
|
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(progressMessages.at(-1)?.payload).toMatchObject({
|
||||||
|
workspaceId: worktreePath,
|
||||||
|
status: "completed",
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "worktree_setup",
|
||||||
|
worktreePath,
|
||||||
|
branchName: "feature-running-setup",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
|
||||||
|
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
|
||||||
|
index: 1,
|
||||||
|
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
|
||||||
|
status: "completed",
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps setup completed when service launch fails afterward", async () => {
|
||||||
|
const { tempDir, repoDir } = createGitRepo({
|
||||||
|
paseoConfig: {
|
||||||
|
services: {
|
||||||
|
web: {
|
||||||
|
command: "npm run dev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
|
||||||
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
|
const worktreePath = await computeWorktreePath(repoDir, "feature-service-failure", paseoHome);
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const routeStore = new ServiceRouteStore();
|
||||||
|
const logger = createLogger();
|
||||||
|
const terminalManager = createTerminalManagerStub({
|
||||||
|
createTerminal: async () => {
|
||||||
|
throw new Error("terminal spawn failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||||
|
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await createPaseoWorktreeInBackground(
|
||||||
|
{
|
||||||
|
paseoHome,
|
||||||
|
emitWorkspaceUpdateForCwd,
|
||||||
|
emit: (message) => emitted.push(message),
|
||||||
|
sessionLogger: logger,
|
||||||
|
terminalManager: terminalManager.manager,
|
||||||
|
archiveWorkspaceRecord,
|
||||||
|
serviceRouteStore: routeStore,
|
||||||
|
daemonPort: 6767,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestCwd: repoDir,
|
||||||
|
repoRoot: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
slug: "feature-service-failure",
|
||||||
|
worktreePath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressMessages = emitted.filter(
|
||||||
|
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
|
||||||
|
message.type === "workspace_setup_progress",
|
||||||
|
);
|
||||||
|
expect(progressMessages).toHaveLength(1);
|
||||||
|
expect(progressMessages[0]?.payload.status).toBe("completed");
|
||||||
|
expect(progressMessages[0]?.payload.error).toBeNull();
|
||||||
|
expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
err: expect.any(Error),
|
||||||
|
worktreePath,
|
||||||
|
}),
|
||||||
|
"Failed to spawn worktree services after workspace setup completed",
|
||||||
|
);
|
||||||
|
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||||
|
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reused existing worktrees do not rerun setup or spawn services", async () => {
|
||||||
|
const { tempDir, repoDir } = createGitRepo({
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ["printf 'ran' > setup-ran.txt"],
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
web: {
|
||||||
|
command: "npm run dev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
|
||||||
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
|
const existingWorktree = await createWorktree({
|
||||||
|
branchName: "reused-worktree",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
worktreeSlug: "reused-worktree",
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const routeStore = new ServiceRouteStore();
|
||||||
|
const logger = createLogger();
|
||||||
|
const terminalManager = createTerminalManagerStub();
|
||||||
|
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
|
||||||
|
const archiveWorkspaceRecord = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await createPaseoWorktreeInBackground(
|
||||||
|
{
|
||||||
|
paseoHome,
|
||||||
|
emitWorkspaceUpdateForCwd,
|
||||||
|
emit: (message) => emitted.push(message),
|
||||||
|
sessionLogger: logger,
|
||||||
|
terminalManager: terminalManager.manager,
|
||||||
|
archiveWorkspaceRecord,
|
||||||
|
serviceRouteStore: routeStore,
|
||||||
|
daemonPort: 6767,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestCwd: repoDir,
|
||||||
|
repoRoot: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
slug: "reused-worktree",
|
||||||
|
worktreePath: existingWorktree.worktreePath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
emitted.some((message) => message.type === "workspace_setup_progress"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(routeStore.listRoutes()).toEqual([]);
|
||||||
|
expect(terminalManager.terminals).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
|
||||||
|
).toContain("hello");
|
||||||
|
expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow();
|
||||||
|
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
|
||||||
|
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,8 +20,16 @@ import type {
|
|||||||
WorkspaceRegistry,
|
WorkspaceRegistry,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||||
import { createAgentWorktree } from "./worktree-bootstrap.js";
|
import {
|
||||||
|
applyWorktreeSetupProgressEvent,
|
||||||
|
buildWorktreeSetupDetail,
|
||||||
|
createAgentWorktree,
|
||||||
|
createWorktreeSetupProgressAccumulator,
|
||||||
|
getWorktreeSetupProgressResults,
|
||||||
|
spawnWorktreeServices,
|
||||||
|
} from "./worktree-bootstrap.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
|
import type { ServiceRouteStore } from "./service-proxy.js";
|
||||||
import {
|
import {
|
||||||
getCheckoutStatusLite,
|
getCheckoutStatusLite,
|
||||||
resolveRepositoryDefaultBranch,
|
resolveRepositoryDefaultBranch,
|
||||||
@@ -35,9 +43,12 @@ import {
|
|||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
resolvePaseoWorktreeRootForCwd,
|
resolvePaseoWorktreeRootForCwd,
|
||||||
resolveWorktreeRuntimeEnv,
|
resolveWorktreeRuntimeEnv,
|
||||||
|
runWorktreeSetupCommands,
|
||||||
slugify,
|
slugify,
|
||||||
validateBranchSlug,
|
validateBranchSlug,
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
|
type WorktreeSetupCommandResult,
|
||||||
|
WorktreeSetupError,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
||||||
|
|
||||||
@@ -105,8 +116,12 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
options?: { dedupeGitState?: boolean },
|
options?: { dedupeGitState?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
emit: EmitSessionMessage;
|
||||||
sessionLogger: Logger;
|
sessionLogger: Logger;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
|
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||||
|
serviceRouteStore: ServiceRouteStore | null;
|
||||||
|
daemonPort: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||||
@@ -143,10 +158,13 @@ export async function buildAgentSessionConfig(
|
|||||||
gitOptions?: GitSetupOptions,
|
gitOptions?: GitSetupOptions,
|
||||||
legacyWorktreeName?: string,
|
legacyWorktreeName?: string,
|
||||||
_labels?: Record<string, string>,
|
_labels?: Record<string, string>,
|
||||||
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
|
): Promise<{
|
||||||
|
sessionConfig: AgentSessionConfig;
|
||||||
|
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||||
|
}> {
|
||||||
let cwd = expandTilde(config.cwd);
|
let cwd = expandTilde(config.cwd);
|
||||||
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||||
let worktreeConfig: WorktreeConfig | undefined;
|
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined;
|
||||||
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return {
|
return {
|
||||||
@@ -188,8 +206,8 @@ export async function buildAgentSessionConfig(
|
|||||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||||
paseoHome: dependencies.paseoHome,
|
paseoHome: dependencies.paseoHome,
|
||||||
});
|
});
|
||||||
cwd = createdWorktree.worktreePath;
|
cwd = createdWorktree.worktree.worktreePath;
|
||||||
worktreeConfig = createdWorktree;
|
worktreeBootstrap = createdWorktree;
|
||||||
} else if (normalized.createNewBranch) {
|
} else if (normalized.createNewBranch) {
|
||||||
const baseBranch =
|
const baseBranch =
|
||||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||||
@@ -207,7 +225,7 @@ export async function buildAgentSessionConfig(
|
|||||||
...config,
|
...config,
|
||||||
cwd,
|
cwd,
|
||||||
},
|
},
|
||||||
worktreeConfig,
|
worktreeBootstrap,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,54 +649,127 @@ export async function createPaseoWorktreeInBackground(
|
|||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let setupTerminalId: string | null = null;
|
let worktree: WorktreeConfig = {
|
||||||
|
branchName: options.slug,
|
||||||
|
worktreePath: options.worktreePath,
|
||||||
|
};
|
||||||
|
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||||
|
let setupStarted = false;
|
||||||
|
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||||
|
|
||||||
|
const emitSetupProgress = (status: "running" | "completed" | "failed", error: string | null) => {
|
||||||
|
dependencies.emit({
|
||||||
|
type: "workspace_setup_progress",
|
||||||
|
payload: {
|
||||||
|
workspaceId: normalizePersistedWorkspaceId(worktree.worktreePath),
|
||||||
|
status,
|
||||||
|
detail: buildWorktreeSetupDetail({
|
||||||
|
worktree,
|
||||||
|
results:
|
||||||
|
status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults,
|
||||||
|
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||||
|
}),
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createAgentWorktree({
|
try {
|
||||||
cwd: options.repoRoot,
|
const createdWorktree = await createAgentWorktree({
|
||||||
branchName: options.slug,
|
cwd: options.repoRoot,
|
||||||
baseBranch: options.baseBranch,
|
|
||||||
worktreeSlug: options.slug,
|
|
||||||
paseoHome: dependencies.paseoHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
|
|
||||||
if (setupCommands.length > 0 && dependencies.terminalManager) {
|
|
||||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
|
||||||
worktreePath: options.worktreePath,
|
|
||||||
branchName: options.slug,
|
branchName: options.slug,
|
||||||
repoRootPath: options.repoRoot,
|
baseBranch: options.baseBranch,
|
||||||
});
|
|
||||||
dependencies.terminalManager.registerCwdEnv({
|
|
||||||
cwd: options.worktreePath,
|
|
||||||
env: runtimeEnv,
|
|
||||||
});
|
|
||||||
const terminal = await dependencies.terminalManager.createTerminal({
|
|
||||||
cwd: options.worktreePath,
|
|
||||||
name: `setup-${options.slug}`,
|
|
||||||
env: runtimeEnv,
|
|
||||||
});
|
|
||||||
setupTerminalId = terminal.id;
|
|
||||||
|
|
||||||
for (const command of setupCommands) {
|
|
||||||
terminal.send({
|
|
||||||
type: "input",
|
|
||||||
data: `${command}\r`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
dependencies.sessionLogger.error(
|
|
||||||
{
|
|
||||||
err: error,
|
|
||||||
cwd: options.requestCwd,
|
|
||||||
repoRoot: options.repoRoot,
|
|
||||||
worktreeSlug: options.slug,
|
worktreeSlug: options.slug,
|
||||||
worktreePath: options.worktreePath,
|
paseoHome: dependencies.paseoHome,
|
||||||
setupTerminalId,
|
});
|
||||||
},
|
worktree = createdWorktree.worktree;
|
||||||
"Background worktree creation failed",
|
|
||||||
);
|
if (!createdWorktree.shouldBootstrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
|
||||||
|
if (setupCommands.length === 0) {
|
||||||
|
setupStarted = true;
|
||||||
|
emitSetupProgress("completed", null);
|
||||||
|
} else {
|
||||||
|
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||||
|
worktreePath: worktree.worktreePath,
|
||||||
|
branchName: worktree.branchName,
|
||||||
|
repoRootPath: options.repoRoot,
|
||||||
|
});
|
||||||
|
dependencies.terminalManager?.registerCwdEnv({
|
||||||
|
cwd: worktree.worktreePath,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
setupStarted = true;
|
||||||
|
setupResults = await runWorktreeSetupCommands({
|
||||||
|
worktreePath: worktree.worktreePath,
|
||||||
|
branchName: worktree.branchName,
|
||||||
|
cleanupOnFailure: false,
|
||||||
|
repoRootPath: options.repoRoot,
|
||||||
|
runtimeEnv,
|
||||||
|
onEvent: (event) => {
|
||||||
|
applyWorktreeSetupProgressEvent(progressAccumulator, event);
|
||||||
|
emitSetupProgress("running", null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emitSetupProgress("completed", null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WorktreeSetupError) {
|
||||||
|
setupResults = error.results;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
emitSetupProgress("failed", message);
|
||||||
|
|
||||||
|
if (!setupStarted) {
|
||||||
|
await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(options.worktreePath));
|
||||||
|
worktree = {
|
||||||
|
...worktree,
|
||||||
|
worktreePath: options.worktreePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies.sessionLogger.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
cwd: options.requestCwd,
|
||||||
|
repoRoot: options.repoRoot,
|
||||||
|
worktreeSlug: options.slug,
|
||||||
|
worktreePath: options.worktreePath,
|
||||||
|
setupStarted,
|
||||||
|
},
|
||||||
|
"Background worktree creation failed",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!dependencies.terminalManager ||
|
||||||
|
!dependencies.serviceRouteStore ||
|
||||||
|
dependencies.daemonPort === null ||
|
||||||
|
dependencies.daemonPort === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spawnWorktreeServices({
|
||||||
|
repoRoot: worktree.worktreePath,
|
||||||
|
branchName: worktree.branchName,
|
||||||
|
daemonPort: dependencies.daemonPort,
|
||||||
|
routeStore: dependencies.serviceRouteStore,
|
||||||
|
terminalManager: dependencies.terminalManager,
|
||||||
|
logger: dependencies.sessionLogger,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dependencies.sessionLogger.warn(
|
||||||
|
{ err: error, worktreePath: worktree.worktreePath },
|
||||||
|
"Failed to spawn worktree services after workspace setup completed",
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath);
|
await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,26 @@ const NonNullUnknownSchema = z.union([
|
|||||||
z.object({}).passthrough(),
|
z.object({}).passthrough(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const WorktreeSetupCommandSnapshotSchema = z.object({
|
||||||
|
index: z.number().int().positive(),
|
||||||
|
command: z.string(),
|
||||||
|
cwd: z.string(),
|
||||||
|
status: z.enum(["running", "completed", "failed"]),
|
||||||
|
exitCode: z.number().nullable(),
|
||||||
|
durationMs: z.number().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorktreeSetupDetailPayloadSchema = z.object({
|
||||||
|
type: z.literal("worktree_setup"),
|
||||||
|
worktreePath: z.string(),
|
||||||
|
branchName: z.string(),
|
||||||
|
log: z.string(),
|
||||||
|
commands: z.array(WorktreeSetupCommandSnapshotSchema),
|
||||||
|
truncated: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
||||||
|
WorktreeSetupDetailPayloadSchema,
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("shell"),
|
type: z.literal("shell"),
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
@@ -254,23 +273,6 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
|
|||||||
bytes: z.number().optional(),
|
bytes: z.number().optional(),
|
||||||
durationMs: z.number().optional(),
|
durationMs: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
|
||||||
type: z.literal("worktree_setup"),
|
|
||||||
worktreePath: z.string(),
|
|
||||||
branchName: z.string(),
|
|
||||||
log: z.string(),
|
|
||||||
commands: z.array(
|
|
||||||
z.object({
|
|
||||||
index: z.number().int().positive(),
|
|
||||||
command: z.string(),
|
|
||||||
cwd: z.string(),
|
|
||||||
status: z.enum(["running", "completed", "failed"]),
|
|
||||||
exitCode: z.number().nullable(),
|
|
||||||
durationMs: z.number().nonnegative().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
truncated: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("sub_agent"),
|
type: z.literal("sub_agent"),
|
||||||
subAgentType: z.string().optional(),
|
subAgentType: z.string().optional(),
|
||||||
@@ -1680,6 +1682,16 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const WorkspaceSetupProgressMessageSchema = z.object({
|
||||||
|
type: z.literal("workspace_setup_progress"),
|
||||||
|
payload: z.object({
|
||||||
|
workspaceId: z.string(),
|
||||||
|
status: z.enum(["running", "completed", "failed"]),
|
||||||
|
detail: WorktreeSetupDetailPayloadSchema,
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const OpenProjectResponseMessageSchema = z.object({
|
export const OpenProjectResponseMessageSchema = z.object({
|
||||||
type: z.literal("open_project_response"),
|
type: z.literal("open_project_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2251,6 +2263,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ArtifactMessageSchema,
|
ArtifactMessageSchema,
|
||||||
AgentUpdateMessageSchema,
|
AgentUpdateMessageSchema,
|
||||||
WorkspaceUpdateMessageSchema,
|
WorkspaceUpdateMessageSchema,
|
||||||
|
WorkspaceSetupProgressMessageSchema,
|
||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
AgentStatusMessageSchema,
|
AgentStatusMessageSchema,
|
||||||
FetchAgentsResponseMessageSchema,
|
FetchAgentsResponseMessageSchema,
|
||||||
@@ -2334,6 +2347,7 @@ export type ServerInfoStatusPayload = z.infer<typeof ServerInfoStatusPayloadSche
|
|||||||
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
|
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
|
||||||
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
||||||
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
||||||
|
export type WorkspaceSetupProgressMessage = z.infer<typeof WorkspaceSetupProgressMessageSchema>;
|
||||||
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
||||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||||
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
||||||
|
|||||||
@@ -50,4 +50,33 @@ describe("workspace message schemas", () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses workspace_setup_progress payload", () => {
|
||||||
|
const parsed = SessionOutboundMessageSchema.parse({
|
||||||
|
type: "workspace_setup_progress",
|
||||||
|
payload: {
|
||||||
|
workspaceId: "/repo/.paseo/worktrees/feature-a",
|
||||||
|
status: "completed",
|
||||||
|
detail: {
|
||||||
|
type: "worktree_setup",
|
||||||
|
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||||
|
branchName: "feature-a",
|
||||||
|
log: "done",
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
command: "npm install",
|
||||||
|
cwd: "/repo/.paseo/worktrees/feature-a",
|
||||||
|
status: "completed",
|
||||||
|
exitCode: 0,
|
||||||
|
durationMs: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.type).toBe("workspace_setup_progress");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user