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 { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace"; import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { import {
connectWorkspaceSetupClient, connectWorkspaceSetupClient,
createWorkspaceFromSidebar, createWorkspaceFromSidebar,
createWorkspaceThroughDaemon, createWorkspaceThroughDaemon,
expectSetupLogContains,
expectSetupPanel, expectSetupPanel,
expectSetupStatus,
openHomeWithProject, openHomeWithProject,
seedProjectForWorkspaceSetup, seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress, 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 () => { test("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient(); const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", { const repo = await createTempGitRepo("setup-success-", {
@@ -46,7 +102,14 @@ test.describe("Workspace setup streaming", () => {
try { try {
await seedProjectForWorkspaceSetup(client, repo.path); 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( const completed = waitForWorkspaceSetupProgress(
client, client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
@@ -57,9 +120,11 @@ test.describe("Workspace setup streaming", () => {
worktreeSlug: "workspace-setup-success", worktreeSlug: "workspace-setup-success",
}); });
const runningPayload = await running; const initialPayload = await initialRunning;
const runningPayload = await runningWithOutput;
const completedPayload = await completed; const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup"); expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete"); expect(completedPayload.detail.log).toContain("setup complete");
expect(completedPayload.error).toBeNull(); expect(completedPayload.error).toBeNull();
+2 -3
View File
@@ -5557,9 +5557,8 @@ export class Session {
private async createPaseoWorktreeInBackground(options: { private async createPaseoWorktreeInBackground(options: {
requestCwd: string; requestCwd: string;
repoRoot: string; repoRoot: string;
baseBranch: string; worktree: { branchName: string; worktreePath: string };
slug: string; shouldBootstrap: boolean;
worktreePath: string;
}): Promise<void> { }): Promise<void> {
return createWorktreeInBackgroundSession( 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({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
services: { services: {
@@ -141,8 +141,20 @@ describe("createPaseoWorktreeInBackground", () => {
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> => (message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress", message.type === "workspace_setup_progress",
); );
expect(progressMessages).toHaveLength(1); expect(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload).toMatchObject({ 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, workspaceId: worktreePath,
status: "completed", status: "completed",
error: null, error: null,
@@ -200,10 +212,12 @@ describe("createPaseoWorktreeInBackground", () => {
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> => (message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress", message.type === "workspace_setup_progress",
); );
expect(progressMessages).toHaveLength(1); expect(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload.status).toBe("failed"); expect(progressMessages[0]?.payload.status).toBe("running");
expect(progressMessages[0]?.payload.error).toContain("does-not-exist"); expect(progressMessages[0]?.payload.error).toBeNull();
expect(progressMessages[0]?.payload.detail.commands).toEqual([]); 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(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
}); });
@@ -250,6 +264,18 @@ describe("createPaseoWorktreeInBackground", () => {
message.type === "workspace_setup_progress", message.type === "workspace_setup_progress",
); );
expect(progressMessages.length).toBeGreaterThan(1); 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"); expect(progressMessages.at(-1)?.payload.status).toBe("completed");
const runningMessages = progressMessages.filter((message) => message.payload.status === "running"); const runningMessages = progressMessages.filter((message) => message.payload.status === "running");
@@ -258,8 +284,11 @@ describe("createPaseoWorktreeInBackground", () => {
progressMessages.findIndex((message) => message.payload.status === "completed"), progressMessages.findIndex((message) => message.payload.status === "completed"),
); );
expect(runningMessages[0]?.payload.detail.log).toContain("phase-one"); const setupOutputMessage = runningMessages.find((message) =>
expect(runningMessages[0]?.payload.detail.commands[0]).toMatchObject({ message.payload.detail.log.includes("phase-one"),
);
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
index: 1, index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"', command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
status: "running", status: "running",
@@ -284,71 +313,7 @@ describe("createPaseoWorktreeInBackground", () => {
}); });
}); });
test("keeps setup completed when service launch fails afterward", async () => { test("emits completed when reusing an existing worktree without bootstrapping", 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({ const { tempDir, repoDir } = createGitRepo({
paseoConfig: { paseoConfig: {
worktree: { worktree: {
@@ -400,9 +365,28 @@ describe("createPaseoWorktreeInBackground", () => {
}, },
); );
expect( const progressMessages = emitted.filter(
emitted.some((message) => message.type === "workspace_setup_progress"), (message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
).toBe(false); 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(routeStore.listRoutes()).toEqual([]);
expect(terminalManager.terminals).toHaveLength(0); expect(terminalManager.terminals).toHaveLength(0);
expect( expect(
@@ -412,4 +396,71 @@ describe("createPaseoWorktreeInBackground", () => {
expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath); 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, createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults, getWorktreeSetupProgressResults,
spawnWorktreeServices, spawnWorktreeServices,
type CreateAgentWorktreeResult,
} from "./worktree-bootstrap.js"; } 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 type { ServiceRouteStore } from "./service-proxy.js";
@@ -139,9 +140,8 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
createPaseoWorktreeInBackground: (options: { createPaseoWorktreeInBackground: (options: {
requestCwd: string; requestCwd: string;
repoRoot: string; repoRoot: string;
baseBranch: string; worktree: WorktreeConfig;
slug: string; shouldBootstrap: boolean;
worktreePath: string;
}) => Promise<void>; }) => Promise<void>;
}; };
@@ -598,10 +598,17 @@ export async function handleCreatePaseoWorktreeRequest(
} }
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome); 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({ const workspace = await dependencies.registerPendingWorktreeWorkspace({
repoRoot, repoRoot,
worktreePath, worktreePath: createdWorktree.worktree.worktreePath,
branchName: normalizedSlug, branchName: createdWorktree.worktree.branchName,
}); });
const descriptor = await dependencies.describeWorkspaceRecord(workspace); const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({ dependencies.emit({
@@ -617,9 +624,8 @@ export async function handleCreatePaseoWorktreeRequest(
void dependencies.createPaseoWorktreeInBackground({ void dependencies.createPaseoWorktreeInBackground({
requestCwd: request.cwd, requestCwd: request.cwd,
repoRoot, repoRoot,
baseBranch, worktree: createdWorktree.worktree,
slug: normalizedSlug, shouldBootstrap: createdWorktree.shouldBootstrap,
worktreePath,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to create worktree"; const message = error instanceof Error ? error.message : "Failed to create worktree";
@@ -644,15 +650,11 @@ export async function createPaseoWorktreeInBackground(
options: { options: {
requestCwd: string; requestCwd: string;
repoRoot: string; repoRoot: string;
baseBranch: string; worktree: WorktreeConfig;
slug: string; shouldBootstrap: boolean;
worktreePath: string;
}, },
): Promise<void> { ): Promise<void> {
let worktree: WorktreeConfig = { let worktree: WorktreeConfig = options.worktree;
branchName: options.slug,
worktreePath: options.worktreePath,
};
let setupResults: WorktreeSetupCommandResult[] = []; let setupResults: WorktreeSetupCommandResult[] = [];
let setupStarted = false; let setupStarted = false;
const progressAccumulator = createWorktreeSetupProgressAccumulator(); const progressAccumulator = createWorktreeSetupProgressAccumulator();
@@ -676,16 +678,10 @@ export async function createPaseoWorktreeInBackground(
try { try {
try { try {
const createdWorktree = await createAgentWorktree({ emitSetupProgress("running", null);
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
paseoHome: dependencies.paseoHome,
});
worktree = createdWorktree.worktree;
if (!createdWorktree.shouldBootstrap) { if (!options.shouldBootstrap) {
emitSetupProgress("completed", null);
return; return;
} }
@@ -725,11 +721,7 @@ export async function createPaseoWorktreeInBackground(
emitSetupProgress("failed", message); emitSetupProgress("failed", message);
if (!setupStarted) { if (!setupStarted) {
await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(options.worktreePath)); await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(worktree.worktreePath));
worktree = {
...worktree,
worktreePath: options.worktreePath,
};
} }
dependencies.sessionLogger.error( dependencies.sessionLogger.error(
@@ -737,8 +729,8 @@ export async function createPaseoWorktreeInBackground(
err: error, err: error,
cwd: options.requestCwd, cwd: options.requestCwd,
repoRoot: options.repoRoot, repoRoot: options.repoRoot,
worktreeSlug: options.slug, worktreeSlug: worktree.branchName,
worktreePath: options.worktreePath, worktreePath: worktree.worktreePath,
setupStarted, setupStarted,
}, },
"Background worktree creation failed", "Background worktree creation failed",
@@ -771,7 +763,7 @@ export async function createPaseoWorktreeInBackground(
); );
} }
} finally { } finally {
await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath); await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
} }
} }