Emit running state when workspace setup begins

This commit is contained in:
Mohamed Boudra
2026-03-31 16:22:51 +07:00
parent 1f6ffa9e0e
commit 806079c7ad
4 changed files with 220 additions and 113 deletions
@@ -1,10 +1,14 @@
import { rm, writeFile } from "node:fs/promises";
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import {
connectWorkspaceSetupClient,
createWorkspaceFromSidebar,
createWorkspaceThroughDaemon,
expectSetupLogContains,
expectSetupPanel,
expectSetupStatus,
openHomeWithProject,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
@@ -34,6 +38,58 @@ test.describe("Workspace setup streaming", () => {
}
});
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
const setupTriggerPath = `/tmp/setup-trigger-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-ui-flow-", {
paseoConfig: {
worktree: {
setup: [
`sh -c 'while [ ! -f "${setupTriggerPath}" ]; do sleep 0.2; done; echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'`,
],
},
},
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await openHomeWithProject(page, repo.path);
await createWorkspaceFromSidebar(page, repo.path);
await expectSetupPanel(page);
await expectSetupStatus(page, "Running");
await writeFile(setupTriggerPath, "start\n");
await expectSetupLogContains(page, "starting setup");
await expectSetupLogContains(page, "loading dependencies");
await expectSetupStatus(page, "Completed");
await expectSetupLogContains(page, "setup complete");
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
if ((await explorerToggle.getAttribute("aria-expanded")) !== "true") {
await explorerToggle.click();
}
await expect(explorerToggle).toHaveAttribute("aria-expanded", "true", { timeout: 30_000 });
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await rm(setupTriggerPath, { force: true });
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-", {
@@ -46,7 +102,14 @@ test.describe("Workspace setup streaming", () => {
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const running = waitForWorkspaceSetupProgress(client, (payload) => payload.status === "running");
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
@@ -57,9 +120,11 @@ test.describe("Workspace setup streaming", () => {
worktreeSlug: "workspace-setup-success",
});
const runningPayload = await running;
const initialPayload = await initialRunning;
const runningPayload = await runningWithOutput;
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(completedPayload.error).toBeNull();
+2 -3
View File
@@ -5557,9 +5557,8 @@ export class Session {
private async createPaseoWorktreeInBackground(options: {
requestCwd: string;
repoRoot: string;
baseBranch: string;
slug: string;
worktreePath: string;
worktree: { branchName: string; worktreePath: string };
shouldBootstrap: boolean;
}): Promise<void> {
return createWorktreeInBackgroundSession(
{
@@ -96,7 +96,7 @@ describe("createPaseoWorktreeInBackground", () => {
}
});
test("emits a single completed snapshot for no-setup workspaces and then launches services", async () => {
test("emits running then completed snapshots for no-setup workspaces and then launches services", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
services: {
@@ -141,8 +141,20 @@ describe("createPaseoWorktreeInBackground", () => {
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(1);
expect(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload).toMatchObject({
workspaceId: worktreePath,
status: "running",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-no-setup",
log: "",
commands: [],
},
});
expect(progressMessages[1]?.payload).toMatchObject({
workspaceId: worktreePath,
status: "completed",
error: null,
@@ -200,10 +212,12 @@ describe("createPaseoWorktreeInBackground", () => {
(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(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload.status).toBe("running");
expect(progressMessages[0]?.payload.error).toBeNull();
expect(progressMessages[1]?.payload.status).toBe("failed");
expect(progressMessages[1]?.payload.error).toContain("does-not-exist");
expect(progressMessages[1]?.payload.detail.commands).toEqual([]);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
@@ -250,6 +264,18 @@ describe("createPaseoWorktreeInBackground", () => {
message.type === "workspace_setup_progress",
);
expect(progressMessages.length).toBeGreaterThan(1);
expect(progressMessages[0]?.payload).toMatchObject({
workspaceId: worktreePath,
status: "running",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-running-setup",
log: "",
commands: [],
},
});
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
const runningMessages = progressMessages.filter((message) => message.payload.status === "running");
@@ -258,8 +284,11 @@ describe("createPaseoWorktreeInBackground", () => {
progressMessages.findIndex((message) => message.payload.status === "completed"),
);
expect(runningMessages[0]?.payload.detail.log).toContain("phase-one");
expect(runningMessages[0]?.payload.detail.commands[0]).toMatchObject({
const setupOutputMessage = runningMessages.find((message) =>
message.payload.detail.log.includes("phase-one"),
);
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
status: "running",
@@ -284,71 +313,7 @@ describe("createPaseoWorktreeInBackground", () => {
});
});
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 () => {
test("emits completed when reusing an existing worktree without bootstrapping", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
worktree: {
@@ -400,9 +365,28 @@ describe("createPaseoWorktreeInBackground", () => {
},
);
expect(
emitted.some((message) => message.type === "workspace_setup_progress"),
).toBe(false);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload).toMatchObject({
workspaceId: existingWorktree.worktreePath,
status: "running",
error: null,
});
expect(progressMessages[1]?.payload).toMatchObject({
workspaceId: existingWorktree.worktreePath,
status: "completed",
error: null,
detail: {
type: "worktree_setup",
worktreePath: existingWorktree.worktreePath,
branchName: "reused-worktree",
log: "",
commands: [],
},
});
expect(routeStore.listRoutes()).toEqual([]);
expect(terminalManager.terminals).toHaveLength(0);
expect(
@@ -412,4 +396,71 @@ describe("createPaseoWorktreeInBackground", () => {
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
});
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(2);
expect(progressMessages[0]?.payload.status).toBe("running");
expect(progressMessages[0]?.payload.error).toBeNull();
expect(progressMessages[1]?.payload.status).toBe("completed");
expect(progressMessages[1]?.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);
});
});
+24 -32
View File
@@ -27,6 +27,7 @@ import {
createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults,
spawnWorktreeServices,
type CreateAgentWorktreeResult,
} from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ServiceRouteStore } from "./service-proxy.js";
@@ -139,9 +140,8 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
createPaseoWorktreeInBackground: (options: {
requestCwd: string;
repoRoot: string;
baseBranch: string;
slug: string;
worktreePath: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}) => Promise<void>;
};
@@ -598,10 +598,17 @@ export async function handleCreatePaseoWorktreeRequest(
}
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
const createdWorktree = await createAgentWorktree({
cwd: repoRoot,
branchName: normalizedSlug,
baseBranch,
worktreeSlug: normalizedSlug,
paseoHome: dependencies.paseoHome,
});
const workspace = await dependencies.registerPendingWorktreeWorkspace({
repoRoot,
worktreePath,
branchName: normalizedSlug,
worktreePath: createdWorktree.worktree.worktreePath,
branchName: createdWorktree.worktree.branchName,
});
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({
@@ -617,9 +624,8 @@ export async function handleCreatePaseoWorktreeRequest(
void dependencies.createPaseoWorktreeInBackground({
requestCwd: request.cwd,
repoRoot,
baseBranch,
slug: normalizedSlug,
worktreePath,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.shouldBootstrap,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create worktree";
@@ -644,15 +650,11 @@ export async function createPaseoWorktreeInBackground(
options: {
requestCwd: string;
repoRoot: string;
baseBranch: string;
slug: string;
worktreePath: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
},
): Promise<void> {
let worktree: WorktreeConfig = {
branchName: options.slug,
worktreePath: options.worktreePath,
};
let worktree: WorktreeConfig = options.worktree;
let setupResults: WorktreeSetupCommandResult[] = [];
let setupStarted = false;
const progressAccumulator = createWorktreeSetupProgressAccumulator();
@@ -676,16 +678,10 @@ export async function createPaseoWorktreeInBackground(
try {
try {
const createdWorktree = await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
paseoHome: dependencies.paseoHome,
});
worktree = createdWorktree.worktree;
emitSetupProgress("running", null);
if (!createdWorktree.shouldBootstrap) {
if (!options.shouldBootstrap) {
emitSetupProgress("completed", null);
return;
}
@@ -725,11 +721,7 @@ export async function createPaseoWorktreeInBackground(
emitSetupProgress("failed", message);
if (!setupStarted) {
await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(options.worktreePath));
worktree = {
...worktree,
worktreePath: options.worktreePath,
};
await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(worktree.worktreePath));
}
dependencies.sessionLogger.error(
@@ -737,8 +729,8 @@ export async function createPaseoWorktreeInBackground(
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: options.slug,
worktreePath: options.worktreePath,
worktreeSlug: worktree.branchName,
worktreePath: worktree.worktreePath,
setupStarted,
},
"Background worktree creation failed",
@@ -771,7 +763,7 @@ export async function createPaseoWorktreeInBackground(
);
}
} finally {
await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath);
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
}
}