Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { SelectWorktree } from "@superset/local-db";
import { getAgentHome, getAgentWorktreePath } from "main/lib/agent-home";
import { track } from "main/lib/analytics";
import { workspaceInitManager } from "main/lib/workspace-init-manager";
import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime";
Expand All @@ -22,7 +24,11 @@ import {
hasUnpushedCommits,
worktreeExists,
} from "../utils/git";
import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown";
import {
removeAgentHomeFromDisk,
removeWorktreeFromDisk,
runTeardown,
} from "../utils/teardown";

export const createDeleteProcedures = () => {
return router({
Expand All @@ -44,6 +50,7 @@ export const createDeleteProcedures = () => {
activeTerminalCount: 0,
hasChanges: false,
hasUnpushedCommits: false,
isAgent: false,
};
}

Expand All @@ -55,6 +62,7 @@ export const createDeleteProcedures = () => {
activeTerminalCount: 0,
hasChanges: false,
hasUnpushedCommits: false,
isAgent: false,
};
}

Expand All @@ -71,6 +79,10 @@ export const createDeleteProcedures = () => {
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
isAgent:
!!workspace.worktreeId &&
getWorktree(workspace.worktreeId)?.path ===
getAgentWorktreePath(workspace.id),
};
}

Expand All @@ -83,6 +95,10 @@ export const createDeleteProcedures = () => {
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
isAgent:
!!workspace.worktreeId &&
getWorktree(workspace.worktreeId)?.path ===
getAgentWorktreePath(workspace.id),
};
}

Expand All @@ -92,22 +108,27 @@ export const createDeleteProcedures = () => {
const project = getProject(workspace.projectId);

if (worktree && project) {
// Papyrus Agents own a standalone clone at <agent-home>/worktree;
// it is NOT a `git worktree` of the Category's mainRepoPath, so
// check the clone directly instead of `git worktree list`.
const isAgent = worktree.path === getAgentWorktreePath(workspace.id);
try {
const exists = await worktreeExists(
project.mainRepoPath,
worktree.path,
);
const exists = isAgent
? existsSync(join(worktree.path, ".git"))
: await worktreeExists(project.mainRepoPath, worktree.path);

if (!exists) {
return {
canDelete: true,
reason: null,
workspace,
warning:
"Worktree not found in git (may have been manually removed)",
warning: isAgent
? "Agent repo not found on disk"
: "Worktree not found in git (may have been manually removed)",
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
isAgent,
};
}

Expand All @@ -124,6 +145,7 @@ export const createDeleteProcedures = () => {
activeTerminalCount,
hasChanges,
hasUnpushedCommits: unpushedCommits,
isAgent,
};
} catch (error) {
return {
Expand All @@ -133,6 +155,7 @@ export const createDeleteProcedures = () => {
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
isAgent,
};
}
}
Expand All @@ -145,6 +168,7 @@ export const createDeleteProcedures = () => {
activeTerminalCount,
hasChanges: false,
hasUnpushedCommits: false,
isAgent: false,
};
}),

Expand Down Expand Up @@ -248,14 +272,22 @@ export const createDeleteProcedures = () => {
}
}

// Agents own a standalone clone under their home dir — remove the
// whole home (worktree + memory + .codex) rather than `git worktree
// remove` against the Category's mainRepoPath (not a repo).
const isAgentRepo =
!!worktree && worktree.path === getAgentWorktreePath(input.id);

if (worktree && project) {
await workspaceInitManager.acquireProjectLock(project.id);

try {
const removeResult = await removeWorktreeFromDisk({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
});
const removeResult = isAgentRepo
? await removeAgentHomeFromDisk(getAgentHome(input.id))
: await removeWorktreeFromDisk({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
});
if (!removeResult.success) {
clearWorkspaceDeletingStatus(input.id);
return removeResult;
Expand All @@ -264,7 +296,7 @@ export const createDeleteProcedures = () => {
workspaceInitManager.releaseProjectLock(project.id);
}

if (input.deleteLocalBranch && workspace.branch) {
if (!isAgentRepo && input.deleteLocalBranch && workspace.branch) {
try {
await deleteLocalBranch({
mainRepoPath: project.mainRepoPath,
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { rename } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
getCommandShellArgs,
getShellEnv,
Expand Down Expand Up @@ -129,6 +133,42 @@ export async function runTeardown({
}
}

/**
* Papyrus: delete an Agent's entire home dir (worktree + memory + .codex).
* Agents own a standalone clone, not a `git worktree` of a main repo, so
* there is no worktree metadata to prune — just remove the directory.
* Same rename-then-background-rm pattern as removeWorktree in git.ts.
*/
export async function removeAgentHomeFromDisk(
agentHome: string,
): Promise<{ success: true } | { success: false; error: string }> {
try {
if (!existsSync(agentHome)) {
return { success: true };
}
const tempPath = join(
dirname(agentHome),
`.papyrus-delete-${randomUUID()}`,
);
await rename(agentHome, tempPath);
const child = spawn("/bin/rm", ["-rf", tempPath], {
detached: true,
stdio: "ignore",
});
child.on("error", (err: Error) => {
console.error(
`[removeAgentHome] Failed to spawn rm for ${tempPath}:`,
err.message,
);
});
return { success: true };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error("Failed to remove agent home:", msg);
return { success: false, error: `Failed to remove agent files: ${msg}` };
}
}

export async function removeWorktreeFromDisk({
mainRepoPath,
worktreePath,
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// MUST be the first import: registers local-db host hooks before any module
// (e.g. server-core's agent-memory-backfill → ./local-db) opens the DB and
// runs migrations at module scope. Relying on the ./lib/local-db shim is not
// enough — server-core modules import local-db relatively, bypassing the shim.
import "./lib/local-db/register-host-hooks";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { settings } from "@superset/local-db";
Expand Down Expand Up @@ -152,19 +157,24 @@ export function setSkipQuitConfirmation(): void {

export function quitWithoutConfirmation(): void {
skipConfirmation = true;
app.exit(0);
// Route through app.quit() so the before-quit teardown runs;
// app.exit() would bypass it.
app.quit();
}

app.on("before-quit", async (event) => {
if (isQuitting) return;

// Always preventDefault: this handler is async, and without it Electron
// exits before the teardown below runs on the non-confirm path. The
// app.exit(0) at the end performs the real quit.
event.preventDefault();

const isDev = process.env.NODE_ENV === "development";
const shouldConfirm =
!skipConfirmation && !isDev && getConfirmOnQuitSetting();

if (shouldConfirm) {
event.preventDefault();

try {
const { response } = await dialog.showMessageBox({
type: "question",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export function NewAgentModal() {
open={isOpen}
onOpenChange={(open) => !open && closeModal()}
>
<DialogContent className="sm:max-w-[440px]">
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>New agent</DialogTitle>
</DialogHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function DeleteWorkspaceDialog({
};

const canDelete = canDeleteData?.canDelete ?? true;
const isAgent = canDeleteData?.isAgent ?? false;
const reason = canDeleteData?.reason;
const hasChanges = canDeleteData?.hasChanges ?? false;
const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false;
Expand Down Expand Up @@ -196,8 +197,9 @@ export function DeleteWorkspaceDialog({
<span className="text-destructive">{reason}</span>
) : (
<span className="block">
Deleting will permanently remove the worktree. You can hide
instead to keep files on disk.
{isAgent
? "Deleting will permanently remove the agent's repo and memory from disk. You can hide instead to keep files on disk."
: "Deleting will permanently remove the worktree. You can hide instead to keep files on disk."}
</span>
)}
</div>
Expand All @@ -216,7 +218,7 @@ export function DeleteWorkspaceDialog({
</div>
)}

{!isLoading && canDelete && (
{!isLoading && canDelete && !isAgent && (
<div className="px-4 pb-2">
<div className="flex items-center gap-2">
<Checkbox
Expand Down Expand Up @@ -267,7 +269,9 @@ export function DeleteWorkspaceDialog({
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[200px]">
Permanently delete agent and git worktree from disk.
{isAgent
? "Permanently delete the agent, its repo, and its memory from disk."
: "Permanently delete agent and git worktree from disk."}
</TooltipContent>
</Tooltip>
</AlertDialogFooter>
Expand Down
Binary file modified apps/desktop/src/resources/build/icons/icon.icns
Binary file not shown.
Binary file modified apps/desktop/src/resources/build/icons/icon.ico
Binary file not shown.
Binary file modified apps/desktop/src/resources/build/icons/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 53 additions & 4 deletions packages/server-core/src/github-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const GHRepoListItemSchema = z.object({

export type GitHubRepoListItem = z.infer<typeof GHRepoListItemSchema>;

// REST /user/repos item shape (only the fields the picker needs).
const GHApiRepoSchema = z.object({
full_name: z.string(),
html_url: z.string(),
description: z.string().nullable(),
updated_at: z.string(),
});

export interface ListGitHubReposResult {
repos: GitHubRepoListItem[];
authenticated: boolean;
Expand All @@ -27,12 +35,53 @@ export interface ListGitHubReposResult {
const REPO_LIST_LIMIT = 200;

/**
* Lists the authenticated user's GitHub repos via `gh repo list`. Never
* throws — `gh` missing, not authenticated, or any other failure (network,
* malformed output) all degrade to `{ repos: [], authenticated: false }` so
* callers can render a "run `gh auth login`" hint instead of erroring.
* Lists the authenticated user's GitHub repos — personal AND org/collaborator
* repos — via the REST `/user/repos` endpoint (`gh repo list` only returns
* repos the user owns, which hides organization repos; see the affiliation
* param). `--paginate --slurp` yields a JSON array of pages. Never throws —
* `gh` missing, not authenticated, or any other failure (network, malformed
* output) all degrade to `{ repos: [], authenticated: false }` so callers can
* render a "run `gh auth login`" hint instead of erroring.
*/
export async function listGitHubRepos(): Promise<ListGitHubReposResult> {
try {
const { stdout } = await execWithShellEnv("gh", [
"api",
"--paginate",
"--slurp",
"user/repos?affiliation=owner,collaborator,organization_member&sort=updated&per_page=100",
]);

const raw: unknown = JSON.parse(stdout);
if (!Array.isArray(raw)) {
return { repos: [], authenticated: false };
}

const repos: GitHubRepoListItem[] = [];
for (const page of raw) {
if (!Array.isArray(page)) continue;
for (const item of page) {
const result = GHApiRepoSchema.safeParse(item);
if (result.success) {
repos.push({
nameWithOwner: result.data.full_name,
url: result.data.html_url,
description: result.data.description,
updatedAt: result.data.updated_at,
});
}
}
}

return { repos: repos.slice(0, REPO_LIST_LIMIT), authenticated: true };
} catch {
// `gh api --slurp` needs a reasonably recent gh; fall back to the
// owner-only listing rather than losing the picker entirely.
return listOwnedGitHubRepos();
}
}

async function listOwnedGitHubRepos(): Promise<ListGitHubReposResult> {
try {
const { stdout } = await execWithShellEnv("gh", [
"repo",
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid grid-cols-[minmax(0,1fr)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
Expand Down