mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix History restore and diff truncation edge cases
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
closeWorkspaceAgentTab,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
fetchAgentArchivedAt,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
@@ -105,25 +108,31 @@ test.describe("Archive tab reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
|
||||
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
|
||||
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `reopen-control-${randomUUID().slice(0, 8)}`,
|
||||
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await closeWorkspaceAgentTab(page, archived.id);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, archived.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
@@ -154,6 +154,67 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readSupervisorPidLock(home: string): Promise<number | null> {
|
||||
try {
|
||||
const content = await readFile(path.join(home, "paseo.pid"), "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
return typeof parsed.pid === "number" ? parsed.pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcessByPid(pid: number): Promise<void> {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (isProcessRunning(pid)) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCurrentDaemonFromPidLock(): Promise<void> {
|
||||
if (!paseoHome) {
|
||||
return;
|
||||
}
|
||||
if (process.env.E2E_DAEMON_PORT === "6767") {
|
||||
throw new Error("Refusing to clean up daemon PID lock for developer daemon port 6767.");
|
||||
}
|
||||
|
||||
const pid = await readSupervisorPidLock(paseoHome);
|
||||
if (pid === null) {
|
||||
return;
|
||||
}
|
||||
await stopProcessByPid(pid);
|
||||
}
|
||||
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
@@ -665,6 +726,7 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
stopProcess(metroProcess),
|
||||
stopProcess(relayProcess),
|
||||
]);
|
||||
await stopCurrentDaemonFromPidLock();
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
|
||||
@@ -93,6 +93,22 @@ export async function archiveAgentFromDaemon(
|
||||
await client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function fetchAgentArchivedAt(
|
||||
client: {
|
||||
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
},
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
return result?.agent.archivedAt ?? null;
|
||||
}
|
||||
|
||||
export function getWorktreeRestoreFeature(client: {
|
||||
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
|
||||
}): boolean {
|
||||
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
|
||||
}
|
||||
|
||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
@@ -214,7 +230,7 @@ export async function openSessions(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Agent history", { exact: true }).last()).toBeVisible({
|
||||
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -233,6 +249,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
|
||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSessionRowNotArchived(page: Page, title: string): Promise<void> {
|
||||
await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
||||
const row = getSessionRowByTitle(page, title);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
157
packages/app/e2e/helpers/daemon-restart.ts
Normal file
157
packages/app/e2e/helpers/daemon-restart.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
* persisted state reloads and existing clients can reconnect. This exercises the
|
||||
* post-restart rehydration path (the daemon rebuilding workspace/agent links
|
||||
* from disk), which is where the worktree-branch regression lives.
|
||||
*
|
||||
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
|
||||
* handle in module scope we can't reach from a spec. Instead we drive it the
|
||||
* same way an operator would: read the supervisor PID from
|
||||
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
|
||||
* worker and releases the lock), wait for the port to free, then re-spawn the
|
||||
* supervisor with the identical environment globalSetup used. The relay and
|
||||
* Metro processes are untouched, so we reuse their already-published ports.
|
||||
*
|
||||
* This NEVER targets the developer daemon: the port comes from
|
||||
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
|
||||
* home globalSetup created.
|
||||
*/
|
||||
|
||||
function getEnvOrThrow(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readSupervisorPid(paseoHome: string): Promise<number> {
|
||||
const pidPath = path.join(paseoHome, "paseo.pid");
|
||||
const content = await readFile(pidPath, "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
if (typeof parsed.pid !== "number") {
|
||||
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
|
||||
}
|
||||
return parsed.pid;
|
||||
}
|
||||
|
||||
function isPidRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean> | boolean,
|
||||
options: { timeoutMs: number; label: string },
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
|
||||
}
|
||||
|
||||
function spawnSupervisor(args: {
|
||||
paseoHome: string;
|
||||
port: string;
|
||||
relayPort: string;
|
||||
metroPort: string;
|
||||
editorRecordPath: string;
|
||||
}): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
|
||||
// Run the supervisor through the resolved tsx CLI under the current node
|
||||
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Detach our handles so the spawned supervisor outlives this spec process and
|
||||
// is reaped by globalSetup's cleanup (the original process tree), not us.
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function restartTestDaemon(): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
|
||||
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
|
||||
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
|
||||
const editorRecordPath =
|
||||
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
|
||||
|
||||
const pid = await readSupervisorPid(paseoHome);
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
await waitUntil(() => !isPidRunning(pid), {
|
||||
timeoutMs: 15_000,
|
||||
label: `supervisor PID ${pid} to exit`,
|
||||
});
|
||||
await waitUntil(async () => !(await isPortListening(Number(port))), {
|
||||
timeoutMs: 15_000,
|
||||
label: `port ${port} to free`,
|
||||
});
|
||||
|
||||
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
|
||||
|
||||
await waitUntil(async () => isPortListening(Number(port)), {
|
||||
timeoutMs: 30_000,
|
||||
label: `restarted daemon to listen on port ${port}`,
|
||||
});
|
||||
}
|
||||
@@ -103,8 +103,12 @@ export async function openProjectViaDaemon(
|
||||
export async function archiveWorkspaceFromDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceDirectory: string,
|
||||
options?: { scope?: "workspace" | "worktree" },
|
||||
): Promise<void> {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory });
|
||||
const payload = await client.archivePaseoWorktree({
|
||||
worktreePath: workspaceDirectory,
|
||||
...(options?.scope !== undefined ? { scope: options.scope } : {}),
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,12 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgent(
|
||||
agentId: string,
|
||||
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { worktreeRestore?: boolean } | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
|
||||
95
packages/app/e2e/worktree-restore-after-restart.spec.ts
Normal file
95
packages/app/e2e/worktree-restore-after-restart.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
|
||||
import { restartTestDaemon } from "./helpers/daemon-restart";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree restore after daemon restart", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ retries: 0, timeout: 180_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
tempRepo = await createTempGitRepo("wt-restart-");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// A paseo worktree is cut on its own branch named after the slug, and the
|
||||
// worktree workspace is displayed under the same name. These are the values
|
||||
// the History table cells must show after restore — never "main".
|
||||
const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: worktreeSlug,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
title: `restart-restore-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
// Archive through the default production path (no scope): the worktree dir is deleted.
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
// Bounce the isolated test daemon on the SAME home and port so it rebuilds
|
||||
// all workspace/agent links from persisted state. Then reconnect both clients.
|
||||
await client.close().catch(() => undefined);
|
||||
await worktreeClient.close().catch(() => undefined);
|
||||
await restartTestDaemon();
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
// KEY ASSERTION: reproduce the screenshot state. Right after the daemon
|
||||
// restart, with NO restore and NO row click, the rendered History table cells
|
||||
// (fed by each agent row's projectPlacement via fetch_agent_history) must read
|
||||
// the worktree branch and the worktree workspace name — never "main".
|
||||
const branchCell = page.getByTestId(`agent-row-branch-${serverId}-${agent.id}`);
|
||||
const workspaceCell = page.getByTestId(`agent-row-workspace-${serverId}-${agent.id}`);
|
||||
|
||||
await expect(branchCell).toBeVisible({ timeout: 60_000 });
|
||||
await expect(branchCell).toHaveText(worktreeSlug, { timeout: 60_000 });
|
||||
await expect(workspaceCell).toHaveText(worktree.workspaceName, { timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
127
packages/app/e2e/worktree-restore.spec.ts
Normal file
127
packages/app/e2e/worktree-restore.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
createIdleAgent,
|
||||
expectSessionRowArchived,
|
||||
fetchAgentArchivedAt,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree restore", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving an agent, then clicking it in History unarchives it in place (worktree dir untouched)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
await archiveAgentFromDaemon(client, agent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
// The History list is a cached react-query snapshot, so the cleared Archived
|
||||
// badge only renders after a cold refetch. Reload to remount the query fresh.
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
const row = page
|
||||
.locator('[data-testid^="agent-row-"]')
|
||||
.filter({ hasText: agent.title })
|
||||
.first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).not.toContainText("Archived", { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("archiving a worktree (dir deleted), then clicking its agent in History recreates the worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
// Archive through the default production path the sidebar uses (no explicit
|
||||
// scope). With the restore prune fix, this default path frees the kept branch
|
||||
// so the daemon can re-check-out the worktree on restore.
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,14 @@ import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { agentHistoryQueryKey } from "@/hooks/agent-history-query-key";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
@@ -49,65 +49,6 @@ type FlatListItem =
|
||||
| { type: "header"; key: string; section: DateSectionKey }
|
||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||
|
||||
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
|
||||
return {
|
||||
serverId: agent.serverId,
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
status: agent.status,
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.lastActivityAt,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: agent.provider,
|
||||
sessionId: null,
|
||||
},
|
||||
title: agent.title,
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
attentionTimestamp: agent.attentionTimestamp,
|
||||
archivedAt: agent.archivedAt,
|
||||
labels: agent.labels,
|
||||
parentAgentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rememberArchivedAgentDetail(agent: AggregatedAgent) {
|
||||
if (!agent.archivedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => {
|
||||
const existing = previous.get(agent.id);
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, {
|
||||
...buildHistoricalAgentDetail(agent),
|
||||
...existing,
|
||||
archivedAt: existing?.archivedAt ?? agent.archivedAt,
|
||||
cwd: existing?.cwd ?? agent.cwd,
|
||||
workspaceId: existing?.workspaceId ?? agent.workspaceId,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
@@ -151,23 +92,6 @@ function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string {
|
||||
switch (status) {
|
||||
case "initializing":
|
||||
return t("agentList.status.initializing");
|
||||
case "idle":
|
||||
return t("agentList.status.idle");
|
||||
case "running":
|
||||
return t("agentList.status.running");
|
||||
case "error":
|
||||
return t("agentList.status.error");
|
||||
case "closed":
|
||||
return t("agentList.status.closed");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function SessionBadge({
|
||||
label,
|
||||
icon,
|
||||
@@ -221,8 +145,9 @@ function SessionRow({
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(t, agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||
const branch = agent.projectPlacement?.checkout.currentBranch ?? "";
|
||||
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
|
||||
@@ -279,32 +204,61 @@ function SessionRow({
|
||||
</View>
|
||||
{isMobile && (
|
||||
<View style={styles.rowMetaRow}>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{projectPath}
|
||||
<Text
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{projectName}
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
|
||||
<Text
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{branch}
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{workspaceName}
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
{agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Text style={styles.columnMeta} numberOfLines={1}>
|
||||
{projectPath}
|
||||
<View style={styles.rowColumns}>
|
||||
<Text
|
||||
style={styles.columnMeta}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{projectName}
|
||||
</Text>
|
||||
<Text style={styles.columnMetaFixed}>{statusLabel}</Text>
|
||||
<Text style={styles.columnMetaFixed}>{timeAgo}</Text>
|
||||
</>
|
||||
<Text
|
||||
style={styles.columnMeta}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{branch}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.columnMeta}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{workspaceName}
|
||||
</Text>
|
||||
<Text style={styles.columnMetaFixed} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<View style={styles.rowTrailing}>
|
||||
@@ -330,6 +284,7 @@ export function AgentList({
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const actionClient = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||
@@ -346,17 +301,35 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
const openAgent = () => {
|
||||
onAgentSelect?.();
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: agent.workspaceId,
|
||||
pin: false,
|
||||
});
|
||||
};
|
||||
|
||||
onAgentSelect?.();
|
||||
if (agent.archivedAt) {
|
||||
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
|
||||
if (client) {
|
||||
void client
|
||||
.refreshAgent(agentId)
|
||||
.then(() => {
|
||||
openAgent();
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: agentHistoryQueryKey(serverId),
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
rememberArchivedAgentDetail(agent);
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
openAgent();
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
[isActionSheetVisible, onAgentSelect, queryClient],
|
||||
);
|
||||
|
||||
const handleAgentLongPress = useCallback(
|
||||
@@ -561,12 +534,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
rowTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
flexWrap: "nowrap",
|
||||
gap: theme.spacing[2],
|
||||
overflow: "hidden",
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: theme.iconSize.md,
|
||||
@@ -594,6 +569,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
sessionTitle: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
color: theme.colors.foreground,
|
||||
@@ -612,13 +588,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.7,
|
||||
},
|
||||
rowColumns: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
columnMeta: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
marginLeft: theme.spacing[4],
|
||||
flexShrink: 0,
|
||||
width: 132,
|
||||
},
|
||||
columnMetaFixed: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -630,6 +610,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function fetchAgentHistoryPage(input: {
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
createdAt: agent.createdAt,
|
||||
labels: agent.labels,
|
||||
projectPlacement: agent.projectPlacement,
|
||||
})),
|
||||
pageInfo: payload.pageInfo,
|
||||
};
|
||||
|
||||
@@ -87,6 +87,7 @@ export function useAggregatedAgents(options?: {
|
||||
archivedAt: agent.archivedAt,
|
||||
createdAt: agent.createdAt,
|
||||
labels: agent.labels,
|
||||
projectPlacement: agent.projectPlacement,
|
||||
};
|
||||
const cacheKey = `${serverId}:${agent.id}`;
|
||||
const prev = prevAgentsRef.current.get(cacheKey);
|
||||
|
||||
@@ -291,7 +291,7 @@ describe("translation resources", () => {
|
||||
});
|
||||
|
||||
it("includes sessions and agent list keys for the Batch 4H migration", () => {
|
||||
expect(en.sessions.title).toBe("Agent history");
|
||||
expect(en.sessions.title).toBe("History");
|
||||
expect(en.sessions.empty).toBe("No sessions yet");
|
||||
expect(en.sessions.actions.loadMore).toBe("Load more");
|
||||
expect(en.agentList.fallbackTitle).toBe("New session");
|
||||
|
||||
@@ -206,7 +206,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "سجل الوكلاء",
|
||||
title: "السجل",
|
||||
empty: "لا توجد جلسات بعد",
|
||||
actions: {
|
||||
loadMore: "تحميل المزيد",
|
||||
|
||||
@@ -205,7 +205,7 @@ export const en = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "Agent history",
|
||||
title: "History",
|
||||
empty: "No sessions yet",
|
||||
actions: {
|
||||
loadMore: "Load more",
|
||||
|
||||
@@ -209,7 +209,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "Historial de agentes",
|
||||
title: "Historial",
|
||||
empty: "Aún no hay sesiones",
|
||||
actions: {
|
||||
loadMore: "Cargar más",
|
||||
|
||||
@@ -209,7 +209,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "Historique des agents",
|
||||
title: "Historique",
|
||||
empty: "Aucune séance pour l'instant",
|
||||
actions: {
|
||||
loadMore: "Charger plus",
|
||||
|
||||
@@ -208,7 +208,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "История агентов",
|
||||
title: "История",
|
||||
empty: "Сеансов пока нет",
|
||||
actions: {
|
||||
loadMore: "Загрузить больше",
|
||||
|
||||
@@ -206,7 +206,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: "Agent 历史",
|
||||
title: "历史",
|
||||
empty: "还没有会话",
|
||||
actions: {
|
||||
loadMore: "加载更多",
|
||||
|
||||
@@ -16,6 +16,7 @@ export type AgentDirectoryEntry = Pick<
|
||||
| "archivedAt"
|
||||
| "createdAt"
|
||||
| "labels"
|
||||
| "projectPlacement"
|
||||
> & {
|
||||
pendingPermissionCount?: number;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl
|
||||
return {
|
||||
projectKey,
|
||||
projectName: deriveProjectName(projectKey),
|
||||
workspaceName: null,
|
||||
checkout: {
|
||||
cwd: normalizedCwd,
|
||||
isGit: false,
|
||||
|
||||
@@ -2430,6 +2430,7 @@ export const ProjectCheckoutLitePayloadSchema = z.union([
|
||||
export const ProjectPlacementPayloadSchema = z.object({
|
||||
projectKey: z.string(),
|
||||
projectName: z.string(),
|
||||
workspaceName: z.string().nullable().optional(),
|
||||
checkout: ProjectCheckoutLitePayloadSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -561,6 +561,76 @@ test.skipIf(isPlatform("win32"))(
|
||||
},
|
||||
);
|
||||
|
||||
// The default archive path (scope "workspace", worktreePath only) resolves
|
||||
// repoRoot=null, so deletePaseoWorktree's `git worktree remove`/`prune` is
|
||||
// skipped: the directory is rm-ed but the admin registration survives, pinning
|
||||
// the branch as "already checked out". Restore must self-heal by pruning the
|
||||
// stale registration before recreating, regardless of how it was archived.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"recreates a worktree whose dir was rm-ed without git worktree remove (stale registration)",
|
||||
async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
|
||||
execFileSync("git", ["branch", "restore-me"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restore-me",
|
||||
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
|
||||
// Simulate the default-archive teardown: remove the working directory but
|
||||
// leave the git worktree registration intact.
|
||||
rmSync(created.worktreePath, { recursive: true, force: true });
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
|
||||
const worktreeList = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}).toString();
|
||||
expect(worktreeList).toContain(created.worktreePath);
|
||||
|
||||
// Recreating without pruning fails with the stale registration pinning the
|
||||
// branch — this is the case restore must heal.
|
||||
await expect(
|
||||
createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restore-me",
|
||||
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" });
|
||||
|
||||
// The restore-side prune frees the stale registration; recreate then succeeds.
|
||||
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const recreated = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restore-me",
|
||||
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(recreated.worktreePath).toBe(created.worktreePath);
|
||||
expect(existsSync(recreated.worktreePath)).toBe(true);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: recreated.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("restore-me");
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"rejects with UnknownBranchError when the kept branch no longer exists",
|
||||
async () => {
|
||||
|
||||
@@ -277,6 +277,7 @@ import {
|
||||
toWorktreeWireError,
|
||||
} from "./worktree-errors.js";
|
||||
import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
||||
|
||||
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||
@@ -1689,6 +1690,7 @@ export class Session {
|
||||
return {
|
||||
projectKey: project.projectId,
|
||||
projectName: resolveProjectDisplayName(project),
|
||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
||||
checkout,
|
||||
};
|
||||
}
|
||||
@@ -7118,6 +7120,18 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
// Archiving through the default path (scope "workspace", worktreePath only)
|
||||
// resolves repoRoot=null, so deletePaseoWorktree's `git worktree remove`/
|
||||
// `prune` is skipped and the admin registration survives — pinning the
|
||||
// branch as "already checked out". Prune here frees any stale registration
|
||||
// whose working dir is missing (a no-op for live worktrees) so the recreate
|
||||
// below succeeds regardless of how the worktree was archived.
|
||||
try {
|
||||
await runGitCommand(["worktree", "prune"], { cwd: project.rootPath, timeout: 30_000 });
|
||||
} catch {
|
||||
// not critical; git will prune lazily
|
||||
}
|
||||
|
||||
let result: WorktreeConfig;
|
||||
try {
|
||||
result = await createWorktree({
|
||||
|
||||
@@ -1156,6 +1156,40 @@ const x = 1;
|
||||
expect(metrics.maxConcurrent).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("marks tracked files omitted by the total diff budget as too_large", async () => {
|
||||
for (let i = 1; i <= 4; i += 1) {
|
||||
writeFileSync(join(repoDir, `budget-${i}.txt`), "old\n");
|
||||
}
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add budget files"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const largeLine = "x".repeat(700_000);
|
||||
for (let i = 1; i <= 4; i += 1) {
|
||||
writeFileSync(join(repoDir, `budget-${i}.txt`), `${largeLine}-${i}\n`);
|
||||
}
|
||||
|
||||
const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true });
|
||||
|
||||
expect(
|
||||
diff.structured?.map((file) => ({
|
||||
path: file.path,
|
||||
status: file.status,
|
||||
hunks: file.hunks.length,
|
||||
})),
|
||||
).toEqual([
|
||||
{ path: "budget-1.txt", status: "ok", hunks: 1 },
|
||||
{ path: "budget-2.txt", status: "ok", hunks: 1 },
|
||||
{ path: "budget-3.txt", status: "too_large", hunks: 0 },
|
||||
{ path: "budget-4.txt", status: "too_large", hunks: 0 },
|
||||
]);
|
||||
expect(diff.diff).toContain("budget-1.txt");
|
||||
expect(diff.diff).toContain("budget-2.txt");
|
||||
expect(diff.diff).toContain("# budget-3.txt: diff too large omitted");
|
||||
expect(diff.diff).toContain("# budget-4.txt: diff too large omitted");
|
||||
});
|
||||
|
||||
it("short-circuits tracked binary files", async () => {
|
||||
const trackedBinaryPath = join(repoDir, "tracked-blob.bin");
|
||||
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00]));
|
||||
|
||||
@@ -2194,6 +2194,10 @@ async function processTrackedChanges(
|
||||
}
|
||||
const diffBytes = Buffer.byteLength(fileDiff.text, "utf8");
|
||||
if (trackedDiffBytes + diffBytes > TOTAL_DIFF_MAX_BYTES) {
|
||||
trackedPlaceholderByPath.set(fileDiff.path, {
|
||||
status: "too_large",
|
||||
stat: trackedNumstatByPath.get(fileDiff.path) ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
trackedDiffBytes += diffBytes;
|
||||
|
||||
Reference in New Issue
Block a user