mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'fix-agent-archive-non-worktree'
This commit is contained in:
67
packages/app/e2e/delete-agent-persists.spec.ts
Normal file
67
packages/app/e2e/delete-agent-persists.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
async function longPress(page: Page, locator: Locator, durationMs = 1100) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('Expected long-press target to have a bounding box.');
|
||||
}
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(durationMs);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test('deleting an agent via long-press persists after reload', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const nonce = Math.random().toString(36).slice(2, 10);
|
||||
const prompt = `delete-agent-persists-${nonce}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
// Create agent (via message input) so it shows up in the sidebar list.
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
|
||||
const match = page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /agent/:serverId/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
|
||||
// Return home and delete via long-press in the agent list.
|
||||
await gotoHome(page);
|
||||
const rowTestId = `agent-row-${serverId}-${agentId}`;
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await longPress(page, agentRow, 1200);
|
||||
|
||||
const deleteButton = page.getByTestId('agent-action-delete').first();
|
||||
await expect(deleteButton).toBeVisible({ timeout: 10000 });
|
||||
await deleteButton.click({ force: true });
|
||||
await expect(page.getByTestId('agent-action-cancel')).toHaveCount(0, { timeout: 10000 });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
// A full reload should not bring the agent back.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -174,6 +174,7 @@ export function AgentList({
|
||||
]}
|
||||
onPress={() => handleAgentPress(agent.serverId, agent.id)}
|
||||
onLongPress={() => handleAgentLongPress(agent)}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<View style={styles.agentContent}>
|
||||
@@ -278,6 +279,7 @@ export function AgentList({
|
||||
disabled={!deleteAgent || isActionDaemonUnavailable}
|
||||
style={[styles.sheetButton, styles.sheetDeleteButton]}
|
||||
onPress={handleDeleteAgent}
|
||||
testID="agent-action-delete"
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -292,6 +294,7 @@ export function AgentList({
|
||||
<Pressable
|
||||
style={[styles.sheetButton, styles.sheetCancelButton]}
|
||||
onPress={handleCloseActionSheet}
|
||||
testID="agent-action-cancel"
|
||||
>
|
||||
<Text style={styles.sheetCancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
@@ -280,4 +281,67 @@ describe("AgentStorage", () => {
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.internal).toBe(true);
|
||||
});
|
||||
|
||||
test("remove deletes all duplicate record files across project directories", async () => {
|
||||
const agentId = "agent-duplicate";
|
||||
|
||||
// Create a valid record file in two different project directories to simulate
|
||||
// storage migrations/duplication. Only one copy will be referenced in-memory,
|
||||
// but deletion should remove *all* copies on disk.
|
||||
const recordA = await (async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: agentId,
|
||||
cwd: "/tmp/project-a",
|
||||
provider: "codex",
|
||||
})
|
||||
);
|
||||
const record = await storage.get(agentId);
|
||||
expect(record).not.toBeNull();
|
||||
return record!;
|
||||
})();
|
||||
|
||||
const projectDirB = path.join(storagePath, "tmp-project-b");
|
||||
await fs.mkdir(projectDirB, { recursive: true });
|
||||
const duplicatePathB = path.join(projectDirB, `${agentId}.json`);
|
||||
await fs.writeFile(
|
||||
duplicatePathB,
|
||||
JSON.stringify({ ...recordA, cwd: "/tmp/project-b" }, null, 2),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// Force a reload so the registry has to discover from disk (and may choose either copy).
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const before = await reloaded.list();
|
||||
expect(before.map((r) => r.id)).toContain(agentId);
|
||||
|
||||
await reloaded.remove(agentId);
|
||||
|
||||
const hasAnyRecordFile = async () => {
|
||||
try {
|
||||
const projects = await fs.readdir(storagePath, { withFileTypes: true });
|
||||
for (const project of projects) {
|
||||
if (!project.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(storagePath, project.name, `${agentId}.json`);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return true;
|
||||
} catch {
|
||||
// not here
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
expect(await hasAnyRecordFile()).toBe(false);
|
||||
|
||||
const afterReload = new AgentStorage(storagePath, logger);
|
||||
const after = await afterReload.list();
|
||||
expect(after.some((r) => r.id === agentId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,8 @@ export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
export class AgentStorage {
|
||||
private cache: Map<string, StoredAgentRecord> = new Map();
|
||||
private pathById: Map<string, string> = new Map();
|
||||
private pendingWrites: Map<string, Promise<void>> = new Map();
|
||||
private deleting: Set<string> = new Set();
|
||||
private loaded = false;
|
||||
private baseDir: string;
|
||||
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
|
||||
@@ -106,33 +108,102 @@ export class AgentStorage {
|
||||
|
||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||
await this.load();
|
||||
const nextPath = this.buildRecordPath(record);
|
||||
const previousPath = this.pathById.get(record.id);
|
||||
|
||||
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
||||
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
||||
|
||||
if (previousPath && previousPath !== nextPath) {
|
||||
try {
|
||||
await fs.unlink(previousPath);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
const agentId = record.id;
|
||||
const prev = this.pendingWrites.get(agentId) ?? Promise.resolve();
|
||||
const next = prev.then(async () => {
|
||||
if (this.deleting.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(record.id, record);
|
||||
this.pathById.set(record.id, nextPath);
|
||||
const nextPath = this.buildRecordPath(record);
|
||||
const previousPath = this.pathById.get(agentId);
|
||||
|
||||
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
||||
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
||||
|
||||
if (previousPath && previousPath !== nextPath) {
|
||||
try {
|
||||
await fs.unlink(previousPath);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(agentId, record);
|
||||
this.pathById.set(agentId, nextPath);
|
||||
});
|
||||
|
||||
this.pendingWrites.set(
|
||||
agentId,
|
||||
next.finally(() => {
|
||||
if (this.pendingWrites.get(agentId) === next) {
|
||||
this.pendingWrites.delete(agentId);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await next;
|
||||
}
|
||||
|
||||
beginDelete(agentId: string): void {
|
||||
this.deleting.add(agentId);
|
||||
}
|
||||
|
||||
async remove(agentId: string): Promise<void> {
|
||||
await this.load();
|
||||
this.beginDelete(agentId);
|
||||
await (this.pendingWrites.get(agentId) ?? Promise.resolve());
|
||||
const candidates = new Set<string>();
|
||||
|
||||
const existingPath =
|
||||
this.pathById.get(agentId) ?? (await this.findAgentPathById(agentId));
|
||||
if (existingPath) {
|
||||
candidates.add(existingPath);
|
||||
}
|
||||
|
||||
// Remove any stray duplicate record files across project directories.
|
||||
// This can happen across storage layout migrations or when a record path changes.
|
||||
try {
|
||||
const projects = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||
for (const project of projects) {
|
||||
if (!project.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const projectDir = path.join(this.baseDir, project.name);
|
||||
candidates.add(path.join(projectDir, `${agentId}.json`));
|
||||
|
||||
// Support one more nesting layer (e.g. provider/version subfolders).
|
||||
let entries: Array<import("node:fs").Dirent> = [];
|
||||
try {
|
||||
entries = await fs.readdir(projectDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
candidates.add(path.join(projectDir, entry.name, `${agentId}.json`));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore scan errors
|
||||
}
|
||||
|
||||
// Support legacy flat layouts: baseDir/<agentId>.json
|
||||
candidates.add(path.join(this.baseDir, `${agentId}.json`));
|
||||
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
await fs.unlink(existingPath);
|
||||
} catch {
|
||||
// ignore removal errors
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && code !== "ENOENT") {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId, filePath },
|
||||
"Failed to remove agent record file"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,12 +78,13 @@ import {
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
listPaseoWorktrees,
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
} from "../utils/worktree.js";
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
listPaseoWorktrees,
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
} from "../utils/worktree.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
@@ -1148,6 +1149,9 @@ export class Session {
|
||||
`Deleting agent ${agentId} from registry`
|
||||
);
|
||||
|
||||
// Prevent the persistence hook from re-creating the record while we close/delete.
|
||||
this.agentStorage.beginDelete(agentId);
|
||||
|
||||
try {
|
||||
await this.agentManager.closeAgent(agentId);
|
||||
} catch (error: any) {
|
||||
@@ -3298,12 +3302,12 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoWorktreeArchiveRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>
|
||||
): Promise<void> {
|
||||
const { requestId } = msg;
|
||||
let targetPath = msg.worktreePath;
|
||||
let repoRoot = msg.repoRoot ?? null;
|
||||
private async handlePaseoWorktreeArchiveRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>
|
||||
): Promise<void> {
|
||||
const { requestId } = msg;
|
||||
let targetPath = msg.worktreePath;
|
||||
let repoRoot = msg.repoRoot ?? null;
|
||||
|
||||
try {
|
||||
if (!targetPath) {
|
||||
@@ -3335,16 +3339,23 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
repoRoot = ownership.repoRoot ?? repoRoot ?? null;
|
||||
if (!repoRoot) {
|
||||
throw new Error("Unable to resolve repo root for worktree");
|
||||
}
|
||||
repoRoot = ownership.repoRoot ?? repoRoot ?? null;
|
||||
if (!repoRoot) {
|
||||
throw new Error("Unable to resolve repo root for worktree");
|
||||
}
|
||||
|
||||
const removedAgents = new Set<string>();
|
||||
const agents = this.agentManager.listAgents();
|
||||
for (const agent of agents) {
|
||||
if (this.isPathWithinRoot(targetPath, agent.cwd)) {
|
||||
removedAgents.add(agent.id);
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||||
paseoHome: this.paseoHome,
|
||||
});
|
||||
if (resolvedWorktree) {
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
}
|
||||
|
||||
const removedAgents = new Set<string>();
|
||||
const agents = this.agentManager.listAgents();
|
||||
for (const agent of agents) {
|
||||
if (this.isPathWithinRoot(targetPath, agent.cwd)) {
|
||||
removedAgents.add(agent.id);
|
||||
try {
|
||||
await this.agentManager.closeAgent(agent.id);
|
||||
} catch {
|
||||
@@ -3370,11 +3381,11 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreePath: targetPath,
|
||||
paseoHome: this.paseoHome,
|
||||
});
|
||||
await deletePaseoWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreePath: targetPath,
|
||||
paseoHome: this.paseoHome,
|
||||
});
|
||||
|
||||
for (const agentId of removedAgents) {
|
||||
this.emit({
|
||||
|
||||
@@ -305,6 +305,25 @@ describe("paseo worktree manager", () => {
|
||||
expect(remaining.map((worktree) => worktree.path)).toEqual([second.worktreePath]);
|
||||
});
|
||||
|
||||
it("deletes a paseo worktree even when given a subdirectory path", async () => {
|
||||
const created = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "alpha",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const nestedDir = join(created.worktreePath, "nested", "dir");
|
||||
execSync(`mkdir -p ${nestedDir}`);
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome });
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
|
||||
const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome });
|
||||
expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("ensures .paseo is ignored in .gitignore", async () => {
|
||||
await ensurePaseoIgnored(repoDir);
|
||||
await ensurePaseoIgnored(repoDir);
|
||||
|
||||
@@ -514,6 +514,57 @@ export async function listPaseoWorktrees({
|
||||
.filter((entry) => entry.path.startsWith(rootPrefix));
|
||||
}
|
||||
|
||||
export async function resolvePaseoWorktreeRootForCwd(
|
||||
cwd: string,
|
||||
options?: { paseoHome?: string }
|
||||
): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> {
|
||||
let repoInfo: RepoInfo;
|
||||
try {
|
||||
repoInfo = await detectRepoInfo(cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(repoInfo.path, options?.paseoHome);
|
||||
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
|
||||
|
||||
let worktreeRoot: string | null = null;
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
"git rev-parse --path-format=absolute --show-toplevel",
|
||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
||||
);
|
||||
const trimmed = stdout.trim();
|
||||
worktreeRoot = trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
worktreeRoot = null;
|
||||
}
|
||||
|
||||
if (!worktreeRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedWorktreeRoot = normalizePathForOwnership(worktreeRoot);
|
||||
if (!resolvedWorktreeRoot.startsWith(resolvedRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const knownWorktrees = await listPaseoWorktrees({
|
||||
cwd: repoInfo.path,
|
||||
paseoHome: options?.paseoHome,
|
||||
});
|
||||
const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot: repoInfo.path,
|
||||
worktreeRoot: worktreesRoot,
|
||||
worktreePath: match.path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePaseoWorktree({
|
||||
cwd,
|
||||
worktreePath,
|
||||
@@ -531,21 +582,23 @@ export async function deletePaseoWorktree({
|
||||
|
||||
const repoInfo = await detectRepoInfo(cwd);
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(repoInfo.path, paseoHome);
|
||||
const targetPath = worktreePath ?? join(worktreesRoot, worktreeSlug!);
|
||||
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
|
||||
const resolvedTarget = normalizePathForOwnership(targetPath);
|
||||
const requestedPath = worktreePath ?? join(worktreesRoot, worktreeSlug!);
|
||||
const resolvedRequested = normalizePathForOwnership(requestedPath);
|
||||
const resolvedWorktree =
|
||||
(await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ??
|
||||
resolvedRequested;
|
||||
|
||||
if (!resolvedTarget.startsWith(resolvedRoot)) {
|
||||
if (!resolvedWorktree.startsWith(resolvedRoot)) {
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
|
||||
const canonicalTargetPath = normalizePathForOwnership(targetPath);
|
||||
await execAsync(`git worktree remove "${canonicalTargetPath}" --force`, {
|
||||
await execAsync(`git worktree remove "${resolvedWorktree}" --force`, {
|
||||
cwd: repoInfo.path,
|
||||
});
|
||||
|
||||
if (existsSync(canonicalTargetPath)) {
|
||||
rmSync(canonicalTargetPath, { recursive: true, force: true });
|
||||
if (existsSync(resolvedWorktree)) {
|
||||
rmSync(resolvedWorktree, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user