diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index a7bf9ee2abe..7b17e9632dd 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -208,7 +208,8 @@ export const PROJECT_HANDLERS: Record = { client, 'projectHostSetup.delete', { - setupId: getRequiredStringFlag(flags, 'setup') + setupId: getRequiredStringFlag(flags, 'setup'), + force: flags.get('force') === true } ) printResult(result, json, formatProjectHostSetupDeleteResult) diff --git a/src/cli/index-project-setup.test.ts b/src/cli/index-project-setup.test.ts index f104e45c39a..abc668e5fb9 100644 --- a/src/cli/index-project-setup.test.ts +++ b/src/cli/index-project-setup.test.ts @@ -776,7 +776,10 @@ describe('orca cli worktree awareness', () => { }) }) - it('deletes project host setup metadata through the project-first runtime API', async () => { + it.each([ + { flags: [], force: false }, + { flags: ['--force'], force: true } + ])('deletes project host setup metadata with force=$force', async ({ flags, force }) => { queueFixtures( callMock, okFixture('req_project_setup_delete', { @@ -806,10 +809,11 @@ describe('orca cli worktree awareness', () => { ) vi.spyOn(console, 'log').mockImplementation(() => {}) - await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo') + await main(['project', 'setup-delete', '--setup', 'setup-gpu', ...flags, '--json'], '/tmp/repo') expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', { - setupId: 'setup-gpu' + setupId: 'setup-gpu', + force }) }) }) diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 4b61499ffc1..c34ab0884af 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -74,7 +74,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' orca project setup-clone --project --host --url --destination [--display-name ] [--json]', ' orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', ' orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', - ' orca project setup-delete --setup [--json]', + ' orca project setup-delete --setup [--force] [--json]', ' orca repo list [--json]', ' orca repo add --path [--json]', ' orca repo show --repo [--json]', diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index 56b33827c4b..064d0188044 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -115,11 +115,12 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ path: ['project', 'setup-delete'], destructive: true, summary: 'Remove a project host setup', - usage: 'orca project setup-delete --setup [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'setup'], + usage: 'orca project setup-delete --setup [--force] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'setup', 'force'], notes: [ 'Independent setups are removed directly.', - 'Repo-backed setups remove the registered repo compatibility record.' + 'Repo-backed setups remove the registered repo compatibility record.', + 'Refuses while the project has open terminals or saved workspace details; --force removes it anyway.' ], examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json'] } diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index be727819a06..18604c368b7 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -45,6 +45,7 @@ import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-c import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown' +import { countRepoTerminalsNotKnownExited } from './project-removal-impact' export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTerminalDrivers { protected readonly preservedBranchCleanup = new RuntimePreservedBranchCleanup(() => @@ -265,7 +266,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin (this as RuntimeCommandSurfaceHost).cloneRepo(url, destination, hostId), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId), - notifyReposChanged: () => this.notifyReposChanged() + notifyReposChanged: () => this.notifyReposChanged(), + countLiveTerminalsForRepo: (repo) => + countRepoTerminalsNotKnownExited(repo, this.ptysById.values(), (ptyId) => + this.isPtyKnownExited(ptyId) + ) }) protected readonly projectGroups = new RuntimeProjectGroupController({ diff --git a/src/main/runtime/project-host-setup-delete-guard.test.ts b/src/main/runtime/project-host-setup-delete-guard.test.ts new file mode 100644 index 00000000000..37c2e5d7368 --- /dev/null +++ b/src/main/runtime/project-host-setup-delete-guard.test.ts @@ -0,0 +1,150 @@ +// `orca project setup-delete` used to drop a repo-backed project even while its terminals were +// running and its workspaces had saved details, orphaning both with no warning. +import { describe, expect, it, vi } from 'vitest' +import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { Repo } from '../../shared/repo-types' +import { + countRepoTerminalsNotKnownExited, + countRepoWorkspacesWithMetadata +} from './project-removal-impact' + +const repo: Repo = { + id: 'repo-1', + path: '/work/app', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' +} + +function makeController(options: { + liveTerminals: number + metaIds: string[] + canonicalMetaIdsForHost?: string[] + targetRepo?: Repo +}) { + const targetRepo = options.targetRepo ?? repo + const { projects, setups: projectHostSetups } = projectHostSetupProjectionFromRepos([targetRepo]) + const deleteProjectHostSetup = vi.fn(() => ({ + project: projects[0], + setup: projectHostSetups[0], + repo: targetRepo + })) + const store = { + getProjects: () => projects, + getProjectHostSetups: () => projectHostSetups, + getAllWorktreeMeta: () => + Object.fromEntries(options.metaIds.map((id) => [id, { hostId: undefined }])), + getAllWorktreeMetaForHost: (hostId: string) => + options.canonicalMetaIdsForHost + ? Object.fromEntries(options.canonicalMetaIdsForHost.map((id) => [id, { hostId }])) + : undefined, + deleteProjectHostSetup + } + const controller = new RuntimeProjectHostSetupController({ + getStore: () => store as never, + listRepos: () => [targetRepo], + addRepo: vi.fn(), + addRemoteRepo: vi.fn(), + cloneRepo: vi.fn(), + invalidateResolvedWorktrees: vi.fn(), + invalidateWorktreeScan: vi.fn(), + notifyReposChanged: vi.fn(), + countLiveTerminalsForRepo: () => options.liveTerminals + }) + return { controller, deleteProjectHostSetup, setupId: projectHostSetups[0].id } +} + +describe('project setup delete guard', () => { + it('refuses a CLI delete while terminals are live, and deletes with --force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 2, + metaIds: [] + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /2 terminals still open.*--force/ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + + controller.deleteSetup({ setupId, force: true }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) + + it('refuses while workspaces have saved details', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['repo-1::/work/app', 'other-repo::/work/other'] + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /saved details for 1 workspace\./ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + }) + + it('sees sibling-host metadata kept only in the host-qualified maps', () => { + // Same repo id and path on local and SSH: the raw-id map holds only the local row. + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['repo-1::/work/app'], + canonicalMetaIdsForHost: ['repo-1::/work/app'], + targetRepo: { ...repo, connectionId: 'box' } + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /saved details for 1 workspace\./ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + }) + + it('deletes an unused project without --force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['other-repo::/work/other'] + }) + + controller.deleteSetup({ setupId, force: false }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) + + it('keeps the unchecked delete for older clients that omit force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 3, + metaIds: ['repo-1::/work/app'] + }) + + controller.deleteSetup({ setupId }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) +}) + +describe('project removal impact', () => { + it('counts only this repo and host, and treats unverifiable terminals as live', () => { + const ptys = [ + { ptyId: 'local-1', worktreeId: 'repo-1::/work/app', connectionId: null }, + { ptyId: 'local-exited', worktreeId: 'repo-1::/work/app', connectionId: null }, + { ptyId: 'ssh-1', worktreeId: 'repo-1::/work/app', connectionId: 'box' }, + { ptyId: 'other-repo', worktreeId: 'repo-10::/work/app', connectionId: null } + ] + const isKnownExited = (ptyId: string) => ptyId === 'local-exited' + + expect(countRepoTerminalsNotKnownExited(repo, ptys, isKnownExited)).toBe(1) + expect( + countRepoTerminalsNotKnownExited({ ...repo, connectionId: 'box' }, ptys, isKnownExited) + ).toBe(1) + }) + + it('counts metadata on the repo host only, reading unstamped rows as local', () => { + const meta = { + 'repo-1::/work/app': {}, + 'repo-1::/work/feature': { hostId: 'local' as const }, + 'repo-1::/srv/app': { hostId: 'ssh:box' as const }, + 'repo-10::/work/app': {} + } + + expect(countRepoWorkspacesWithMetadata(repo, meta)).toBe(2) + expect(countRepoWorkspacesWithMetadata({ ...repo, connectionId: 'box' }, meta)).toBe(1) + }) +}) diff --git a/src/main/runtime/project-removal-impact.ts b/src/main/runtime/project-removal-impact.ts new file mode 100644 index 00000000000..04708e0c490 --- /dev/null +++ b/src/main/runtime/project-removal-impact.ts @@ -0,0 +1,73 @@ +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId +} from '../../shared/execution-host' +import { splitWorktreeId } from '../../shared/worktree/id' +import { worktreePtyBelongsToHost, type WorktreePtyHostFence } from './worktree-pty-host-fence' + +export type ProjectRemovalImpact = { + liveTerminals: number + workspacesWithMetadata: number +} + +type RepoIdentity = Pick + +function repoPtyHostFence(repo: RepoIdentity): WorktreePtyHostFence { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + return host?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: host.environmentId } + : { resolvedConnectionId: host?.kind === 'ssh' ? host.targetId : null } +} + +/** Terminals not known to have exited count: a lost-contact PTY may still be running. */ +export function countRepoTerminalsNotKnownExited( + repo: RepoIdentity, + ptys: Iterable<{ ptyId: string; worktreeId: string; connectionId: string | null }>, + isKnownExited: (ptyId: string) => boolean +): number { + const fence = repoPtyHostFence(repo) + let count = 0 + for (const pty of ptys) { + if ( + splitWorktreeId(pty.worktreeId)?.repoId === repo.id && + worktreePtyBelongsToHost(pty.ptyId, pty.connectionId, fence) && + !isKnownExited(pty.ptyId) + ) { + count += 1 + } + } + return count +} + +/** Same host rule as the repo prune: unstamped metadata predates host stamping and is local. */ +export function countRepoWorkspacesWithMetadata( + repo: RepoIdentity, + metaById: Record> +): number { + const hostId = getRepoExecutionHostId(repo) + const prefix = `${repo.id}::` + return Object.entries(metaById).filter( + ([id, meta]) => id.startsWith(prefix) && (meta.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId + ).length +} + +export function describeProjectRemovalRefusal(impact: ProjectRemovalImpact): string | null { + const parts: string[] = [] + if (impact.liveTerminals > 0) { + parts.push( + `${impact.liveTerminals} terminal${impact.liveTerminals === 1 ? '' : 's'} still open` + ) + } + if (impact.workspacesWithMetadata > 0) { + parts.push( + `saved details for ${impact.workspacesWithMetadata} workspace${impact.workspacesWithMetadata === 1 ? '' : 's'}` + ) + } + if (parts.length === 0) { + return null + } + return `This project has ${parts.join(' and ')}. Removing it detaches those terminals from Orca and deletes the saved workspace details. Re-run with --force to remove it anyway.` +} diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index bb3d928d406..6defe84800b 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -43,7 +43,8 @@ function makeController(): { cloneRepo, invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), - notifyReposChanged: vi.fn() + notifyReposChanged: vi.fn(), + countLiveTerminalsForRepo: () => 0 }) return { controller, diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index bfcbb4e5fe1..c3661f06acd 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -24,6 +24,11 @@ import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-look import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' +import { readAllWorktreeMetaForRepo } from '../persistence/host-qualified-worktree-meta' +import { + countRepoWorkspacesWithMetadata, + describeProjectRemovalRefusal +} from './project-removal-impact' type RuntimeProjectHostSetupDependencies = { getStore: () => RuntimeStore | null @@ -40,6 +45,7 @@ type RuntimeProjectHostSetupDependencies = { invalidateResolvedWorktrees: () => void invalidateWorktreeScan: (repoId: string) => void notifyReposChanged: () => void + countLiveTerminalsForRepo: (repo: Repo) => number } // Why clone alone still refuses: nothing in this process clones onto an SSH host. `cloneRepo` runs @@ -147,6 +153,9 @@ export class RuntimeProjectHostSetupController { if (!store?.deleteProjectHostSetup) { throw new Error('runtime_unavailable') } + if (args.force === false) { + this.assertSetupRemovalIsUnused(store, args.setupId) + } const result = store.deleteProjectHostSetup(args) if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) @@ -154,6 +163,33 @@ export class RuntimeProjectHostSetupController { return result } + // Why only for an explicit `force: false`: older clients omit it and must keep their delete. + private assertSetupRemovalIsUnused(store: RuntimeStore, setupId: string): void { + const setup = this.listSetups().find((entry) => entry.id === setupId) + // Same exact-host match the store's delete uses, so a sibling host's row is never measured. + const repo = setup?.repoId + ? this.deps + .listRepos() + .find( + (entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId + ) + : undefined + if (!repo) { + return + } + const refusal = describeProjectRemovalRefusal({ + liveTerminals: this.deps.countLiveTerminalsForRepo(repo), + workspacesWithMetadata: countRepoWorkspacesWithMetadata( + repo, + // Host-qualified read: a same-id, same-path sibling host keeps its rows only in canonical maps. + readAllWorktreeMetaForRepo(store, repo) + ) + }) + if (refusal) { + throw new Error(refusal) + } + } + private completeSetup( args: ProjectHostSetupExistingFolderArgs, initialRepo: Repo, diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx index 7a5ac841d53..45c04b08a56 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx @@ -3,21 +3,30 @@ import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/repo-types' -const mocks = vi.hoisted(() => ({ - state: { - activeModal: 'confirm-remove-folder' as string | null, - modalData: { - repoId: 'repo-1', - displayName: 'Example', - hostId: 'ssh:target-1' - } as Record, - repos: [] as Repo[], - sshTargetLabels: new Map(), - removedSshTargetLabels: new Map(), - closeModal: vi.fn(), - removeProject: vi.fn() +const mocks = vi.hoisted(() => { + const worktreesByRepo: Record = {} + const tabsByWorktree: Record = {} + const ptyIdsByTabId: Record = {} + return { + state: { + activeModal: 'confirm-remove-folder' as string | null, + modalData: { + repoId: 'repo-1', + displayName: 'Example', + hostId: 'ssh:target-1' + } as Record, + repos: [] as Repo[], + sshTargetLabels: new Map(), + removedSshTargetLabels: new Map(), + worktreesByRepo, + detectedWorktreesByRepo: {}, + tabsByWorktree, + ptyIdsByTabId, + closeModal: vi.fn(), + removeProject: vi.fn() + } } -})) +}) vi.mock('@/store', () => ({ useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) @@ -70,6 +79,9 @@ describe('RemoveFolderDialog', () => { } mocks.state.sshTargetLabels = new Map([['target-1', 'Persistent host']]) mocks.state.removedSshTargetLabels = new Map() + mocks.state.worktreesByRepo = {} + mocks.state.tabsByWorktree = {} + mocks.state.ptyIdsByTabId = {} }) it('warns that VM recipe cleanup controls file deletion', () => { @@ -91,4 +103,17 @@ describe('RemoveFolderDialog', () => { expect(html).toContain('Its files stay on Persistent host') expect(html).not.toContain('VM recipe') }) + + it('says how many open terminals removal will close', () => { + mocks.state.repos = [repo('target-1', 'ssh:target-1')] + mocks.state.worktreesByRepo = { + 'repo-1': [{ id: 'repo-1::/workspace/example', hostId: 'ssh:target-1' }] + } + mocks.state.tabsByWorktree = { 'repo-1::/workspace/example': [{ id: 'a' }, { id: 'b' }] } + mocks.state.ptyIdsByTabId = { a: ['pty-a'], b: ['pty-b'] } + + expect(renderToStaticMarkup()).toContain( + '2 open terminals in this project will be closed.' + ) + }) }) diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index f1b8856e14f..f7b9776a6e8 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -15,6 +15,7 @@ import { isRuntimeOwnedSshTargetId, normalizeExecutionHostId } from '../../../../shared/execution-host' +import { countOpenRepoTerminalTabs } from '@/store/repos/repo-removal' // Why: interpolated into the sentence so locales control where the name sits; // U+0000 cannot appear in a real project name, so the split is unambiguous. @@ -53,6 +54,23 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { ) }) + const openTerminalCount = useAppStore((s) => + repoId && hostId ? countOpenRepoTerminalTabs(s, repoId, hostId) : 0 + ) + const openTerminalWarning = + openTerminalCount === 0 + ? null + : openTerminalCount === 1 + ? translate( + 'auto.components.sidebar.RemoveFolderDialog.openTerminalWarningOne', + '1 open terminal in this project will be closed.' + ) + : translate( + 'auto.components.sidebar.RemoveFolderDialog.openTerminalWarningMany', + '{{count}} open terminals in this project will be closed.', + { count: openTerminalCount } + ) + // Why: fragment concatenation around the styled name cannot be reordered by // SOV locales (#9294). Translate one full sentence with the name as a // sentinel token, then split on it to re-apply the inline emphasis. @@ -104,6 +122,9 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { {displayName} {descriptionAfterName} + {openTerminalWarning ? ( +

{openTerminalWarning}

+ ) : null}