mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
perf(workspaces): stop duplicating setup output in progress snapshots
Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly.
This commit is contained in:
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
setupCommandOutput,
|
||||
createWorkspaceThroughDaemon,
|
||||
openWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
setupCommandOutput,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const initialRunning = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log === "",
|
||||
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
|
||||
);
|
||||
const runningWithOutput = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
|
||||
(payload) =>
|
||||
payload.status === "running" &&
|
||||
setupCommandOutput(payload.detail).includes("starting setup"),
|
||||
);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completedPayload = await completed;
|
||||
|
||||
expect(initialPayload.detail.log).toBe("");
|
||||
expect(runningPayload.detail.log).toContain("starting setup");
|
||||
expect(completedPayload.detail.log).toContain("setup complete");
|
||||
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
|
||||
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
|
||||
expect(completedPayload.error).toBeNull();
|
||||
} finally {
|
||||
await client.close();
|
||||
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
(payload) =>
|
||||
payload.status === "failed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup failed"),
|
||||
);
|
||||
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
});
|
||||
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
|
||||
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
|
||||
const result = await client.createPaseoWorktree({
|
||||
|
||||
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
|
||||
return detail.commands.map((command) => command.log).join("\n");
|
||||
}
|
||||
|
||||
@@ -474,3 +474,54 @@ test("usage entries accept older failures and additive successful snapshots", ()
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Frozen setup detail shape from v0.8.0: log is required, command output is optional.
|
||||
const ReleasedSetupDetailSchema = 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(),
|
||||
log: z.string().optional().default(""),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
exitCode: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
test("released setup clients parse empty combined logs and retain command output", () => {
|
||||
const detail = {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/workspace",
|
||||
branchName: "feature",
|
||||
log: "",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "echo ready",
|
||||
cwd: "/workspace",
|
||||
log: "ready\n",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
const parsed = ReleasedSetupDetailSchema.parse(JSON.parse(JSON.stringify(detail)));
|
||||
expect(parsed).toEqual(detail);
|
||||
expect(parsed.commands[0]?.log).toBe("ready\n");
|
||||
const { log: _log, ...withoutLegacyField } = detail;
|
||||
expect(ReleasedSetupDetailSchema.safeParse(withoutLegacyField).success).toBe(false);
|
||||
expect(
|
||||
WorkspaceSetupSnapshotSchema.parse({
|
||||
status: "completed",
|
||||
detail: { ...detail, log: "old daemon combined output" },
|
||||
error: null,
|
||||
}).detail.log,
|
||||
).toBe("old daemon combined output");
|
||||
});
|
||||
|
||||
@@ -604,12 +604,18 @@ async function expectMissingLocalMergeTarget({ client, cwd }: RestorableWorkspac
|
||||
).toContain("No local merge target is recorded");
|
||||
}
|
||||
|
||||
function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
|
||||
return detail.commands.map((command) => command.log).join("\n");
|
||||
}
|
||||
|
||||
// Setup output is opt-in even for multiple sockets sharing one logical client.
|
||||
test("setup details follow one workspace/source and restore after reconnect with minimal directory status", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: true });
|
||||
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
||||
const clients: SocketDaemonClient[] = [];
|
||||
const sockets = new Map<string, WebSocket[]>();
|
||||
const setupWireTypes = new Set<string>();
|
||||
const setupWireDetails: Array<{ log: string; commands: Array<{ log: string }> }> = [];
|
||||
const observations: Array<{ release(): Promise<void> }> = [];
|
||||
const workspaces: Array<{ id: string; workspaceDirectory?: string }> = [];
|
||||
const mcp = new Client({ name: "setup-test", version: "1.0.0" });
|
||||
@@ -653,6 +659,24 @@ const timer = setInterval(() => {
|
||||
reconnect: { baseDelayMs: 10, maxDelayMs: 10 },
|
||||
webSocketFactory: (url) => {
|
||||
const socket = new WebSocket(url);
|
||||
socket.on("message", (data, isBinary) => {
|
||||
if (isBinary) return;
|
||||
const frame = JSON.parse(data.toString());
|
||||
const message = frame.message ?? frame;
|
||||
if (message.type === "workspace_setup_progress") {
|
||||
setupWireTypes.add(message.type);
|
||||
setupWireDetails.push(message.payload.detail);
|
||||
}
|
||||
if (
|
||||
message.type === "workspace_setup_status_response" ||
|
||||
message.type === "workspace.setup.subscribe.response"
|
||||
) {
|
||||
if (message.payload.snapshot) {
|
||||
setupWireTypes.add(message.type);
|
||||
setupWireDetails.push(message.payload.snapshot.detail);
|
||||
}
|
||||
}
|
||||
});
|
||||
sockets.set(clientId, [...(sockets.get(clientId) ?? []), socket]);
|
||||
return socket as unknown as WebSocketLike;
|
||||
},
|
||||
@@ -683,7 +707,10 @@ const timer = setInterval(() => {
|
||||
})
|
||||
.toMatchObject({
|
||||
status: "running",
|
||||
detail: { log: expect.stringContaining("setup started") },
|
||||
detail: {
|
||||
log: "",
|
||||
commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })],
|
||||
},
|
||||
});
|
||||
};
|
||||
for (const worktreeSlug of ["noisy-a", "noisy-b", "noisy-c"]) {
|
||||
@@ -712,7 +739,10 @@ const timer = setInterval(() => {
|
||||
const initial = await details.ready;
|
||||
expect(initial.snapshot).toMatchObject({
|
||||
status: "running",
|
||||
detail: { log: expect.stringContaining("setup started") },
|
||||
detail: {
|
||||
log: "",
|
||||
commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })],
|
||||
},
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
@@ -745,7 +775,7 @@ const timer = setInterval(() => {
|
||||
viewerMessages.filter(
|
||||
(message) =>
|
||||
message.type === "workspace_setup_progress" &&
|
||||
message.payload.detail.log.includes("wave-0:"),
|
||||
setupCommandOutput(message.payload.detail).includes("wave-0:"),
|
||||
).length,
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
@@ -776,10 +806,12 @@ const timer = setInterval(() => {
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "workspace_setup_progress" &&
|
||||
message.payload.detail.log.includes("wave-0:"),
|
||||
setupCommandOutput(message.payload.detail).includes("wave-0:"),
|
||||
)
|
||||
.map((message) =>
|
||||
message.type === "workspace_setup_progress" ? message.payload.detail.log : "",
|
||||
message.type === "workspace_setup_progress"
|
||||
? setupCommandOutput(message.payload.detail)
|
||||
: "",
|
||||
);
|
||||
expect(waveLogs.length).toBe(new Set(waveLogs).size);
|
||||
const restoredSnapshots: unknown[] = [];
|
||||
@@ -792,7 +824,12 @@ const timer = setInterval(() => {
|
||||
await expect.poll(() => restoredSnapshots.length, { timeout: 10000 }).toBe(2);
|
||||
expect(details.subscriptionId).not.toBe(beforeReconnect);
|
||||
expect(restoredSnapshots[1]).toMatchObject({
|
||||
snapshot: { detail: { log: expect.stringContaining("wave-0:") } },
|
||||
snapshot: {
|
||||
detail: {
|
||||
log: "",
|
||||
commands: [expect.objectContaining({ log: expect.stringContaining("wave-0:") })],
|
||||
},
|
||||
},
|
||||
});
|
||||
const subscriptionId = details.subscriptionId;
|
||||
await details.release();
|
||||
@@ -804,7 +841,8 @@ const timer = setInterval(() => {
|
||||
// Wait until the daemon has consumed the output before checking wire silence.
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.log,
|
||||
async () =>
|
||||
(await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.commands[0]?.log,
|
||||
{
|
||||
timeout: 30000,
|
||||
},
|
||||
@@ -815,8 +853,22 @@ const timer = setInterval(() => {
|
||||
);
|
||||
const resumed = viewer.observeWorkspaceSetup(workspace.id);
|
||||
observations.push(resumed);
|
||||
expect((await resumed.ready).snapshot?.detail.log).toContain("wave-1:");
|
||||
expect((await resumed.ready).snapshot?.detail.commands[0]?.log).toContain("wave-1:");
|
||||
expect(resumed.subscriptionId).not.toBe(subscriptionId);
|
||||
expect(setupWireTypes).toEqual(
|
||||
new Set([
|
||||
"workspace_setup_progress",
|
||||
"workspace_setup_status_response",
|
||||
"workspace.setup.subscribe.response",
|
||||
]),
|
||||
);
|
||||
expect(setupWireDetails.length).toBeGreaterThan(0);
|
||||
expect(setupWireDetails.every((detail) => detail.log === "")).toBe(true);
|
||||
expect(
|
||||
setupWireDetails.some((detail) =>
|
||||
detail.commands.some((command) => command.log.includes("wave-1:")),
|
||||
),
|
||||
).toBe(true);
|
||||
writeFileSync(path.join(workspaces[1]!.workspaceDirectory!, "finish.txt"), "fail");
|
||||
await expect
|
||||
.poll(
|
||||
@@ -849,3 +901,58 @@ const timer = setInterval(() => {
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
test("legacy agent git creation retains setup output in its readable chat timeline", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
writeFileSync(
|
||||
path.join(repoDir, "paseo.json"),
|
||||
JSON.stringify({ worktree: { setup: ["echo legacy-setup-output"] } }),
|
||||
);
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "setup fixture"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await client.connect();
|
||||
const agent = await client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "legacy-setup",
|
||||
worktreeSlug: "legacy-setup",
|
||||
},
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
|
||||
return timeline.entries
|
||||
.map((entry) => entry.item)
|
||||
.find(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "paseo_worktree_setup" &&
|
||||
item.status === "completed",
|
||||
);
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toMatchObject({
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
log: expect.stringContaining("legacy-setup-output"),
|
||||
commands: [expect.objectContaining({ log: "legacy-setup-output\n" })],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,11 @@ import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
||||
import {
|
||||
buildWorktreeSetupDetail,
|
||||
runAsyncWorktreeBootstrap,
|
||||
spawnWorkspaceScript,
|
||||
} from "./worktree-bootstrap.js";
|
||||
import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
|
||||
@@ -63,6 +67,30 @@ async function createBootstrapWorktreeForTest(
|
||||
return { worktree, shouldBootstrap: true };
|
||||
}
|
||||
|
||||
describe("workspace setup detail", () => {
|
||||
it("serializes output only in command logs while retaining the legacy string field", () => {
|
||||
const detail = buildWorktreeSetupDetail({
|
||||
worktree: { worktreePath: "/workspace", branchName: "feature" },
|
||||
results: [
|
||||
{
|
||||
command: "echo ready",
|
||||
cwd: "/workspace",
|
||||
stdout: "ready\n",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
durationMs: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const serialized = JSON.stringify(detail);
|
||||
expect(JSON.parse(serialized)).toMatchObject({
|
||||
log: "",
|
||||
commands: [{ log: "ready\n", status: "completed", exitCode: 0 }],
|
||||
});
|
||||
expect(serialized.split("ready\\n")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAsyncWorktreeBootstrap", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
|
||||
@@ -325,18 +325,15 @@ export function buildWorktreeSetupDetail(input: {
|
||||
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
|
||||
};
|
||||
});
|
||||
const renderedLog = buildWorktreeSetupLog({
|
||||
results: input.results,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
type: "worktree_setup",
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
log: renderedLog.log,
|
||||
// COMPAT(workspaceSetupCombinedLog): added in v0.12.0, remove after 2027-04-20 once legacy wire schemas no longer require log.
|
||||
// Setup clients render commands[].log; retain the required wire field without duplicating output.
|
||||
log: "",
|
||||
commands,
|
||||
...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}),
|
||||
...(anyCommandTruncated ? { truncated: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,6 +351,10 @@ function buildSetupTimelineItem(input: {
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
|
||||
// Legacy agent creation still emits timeline cards whose released renderers read only detail.log.
|
||||
// Keep their output here rather than concatenating every workspace setup snapshot.
|
||||
detail.log = buildWorktreeSetupLog(input).log;
|
||||
|
||||
if (input.status === "running") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
|
||||
@@ -983,7 +983,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
const setupOutputMessage = runningMessages.find((message) =>
|
||||
message.payload.detail.commands[0]?.log.includes("phase-one"),
|
||||
);
|
||||
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
|
||||
expect(setupOutputMessage?.payload.detail.log).toBe("");
|
||||
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
|
||||
@@ -1001,7 +1001,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
branchName: "feature-running-setup",
|
||||
},
|
||||
});
|
||||
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
|
||||
expect(progressMessages.at(-1)?.payload.detail.log).toBe("");
|
||||
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'\"",
|
||||
|
||||
Reference in New Issue
Block a user