mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(repos): deleting a project with live terminals warns and needs --force orca project setup-delete now refuses a repo-backed project that still has terminals not known to have exited or saved workspace details, unless --force. The sidebar remove dialog says how many open terminals will close. Co-Authored-By: Claude <noreply@anthropic.com> * fix(repos): count a sibling host's host-qualified workspace metadata in the delete guard Co-Authored-By: Claude <noreply@anthropic.com> * test(sidebar): cover the remove dialog's open-terminal warning Co-Authored-By: Claude <noreply@anthropic.com> * test(sidebar): type the dialog mock state without assertions Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude <noreply@anthropic.com>
260 lines
9.6 KiB
TypeScript
260 lines
9.6 KiB
TypeScript
import type {
|
|
Project,
|
|
ProjectHostSetup,
|
|
ProjectHostSetupCloneArgs,
|
|
ProjectHostSetupCreateArgs,
|
|
ProjectHostSetupCreateResult,
|
|
ProjectHostSetupDeleteArgs,
|
|
ProjectHostSetupDeleteResult,
|
|
ProjectHostSetupExistingFolderArgs,
|
|
ProjectHostSetupResult,
|
|
ProjectHostSetupUpdateArgs,
|
|
ProjectHostSetupUpdateResult,
|
|
ProjectUpdateArgs
|
|
} from '../../shared/project-types'
|
|
import type { Repo } from '../../shared/repo-types'
|
|
import {
|
|
getRepoExecutionHostId,
|
|
getSshTargetIdForExecutionHost,
|
|
parseExecutionHostId,
|
|
type ExecutionHostId
|
|
} from '../../shared/execution-host'
|
|
import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection'
|
|
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup'
|
|
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
|
|
listRepos: () => Repo[]
|
|
addRepo: (path: string, kind: 'folder' | 'git', hostId: ExecutionHostId) => Promise<Repo>
|
|
/** Register an existing path that lives on an SSH host; `addRepo` only reaches local/runtime hosts. */
|
|
addRemoteRepo: (args: {
|
|
connectionId: string
|
|
remotePath: string
|
|
displayName?: string
|
|
kind: 'folder' | 'git'
|
|
}) => Promise<Repo>
|
|
cloneRepo: (url: string, destination: string, hostId: ExecutionHostId) => Promise<Repo>
|
|
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
|
|
// `git clone` on the client, so accepting `ssh:*` here would register the client's copy as the
|
|
// host's repo — a local answer to a remote question. Registering an existing remote path, by
|
|
// contrast, has a correct implementation this process already uses over IPC.
|
|
function assertCloneHostIsSupported(hostId: ExecutionHostId | null | undefined): void {
|
|
if (parseExecutionHostId(hostId)?.kind !== 'ssh') {
|
|
return
|
|
}
|
|
throw new Error(
|
|
'Cloning onto an SSH host is not supported. Clone the repository on the host, then set the project up from that existing folder.'
|
|
)
|
|
}
|
|
|
|
export class RuntimeProjectHostSetupController {
|
|
constructor(private readonly deps: RuntimeProjectHostSetupDependencies) {}
|
|
|
|
listProjects(): Project[] {
|
|
return this.deps.getStore()?.getProjects?.() ?? []
|
|
}
|
|
|
|
updateProject(projectId: string, updates: ProjectUpdateArgs['updates']): Project {
|
|
const store = this.deps.getStore()
|
|
if (!store?.updateProject) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const project = store.updateProject(projectId, updates)
|
|
if (!project) {
|
|
throw new Error(`Project not found: ${projectId}`)
|
|
}
|
|
this.deps.invalidateResolvedWorktrees()
|
|
this.deps.notifyReposChanged()
|
|
return project
|
|
}
|
|
|
|
listSetups(): ProjectHostSetup[] {
|
|
return this.deps.getStore()?.getProjectHostSetups?.() ?? []
|
|
}
|
|
|
|
createSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult {
|
|
const store = this.deps.getStore()
|
|
if (!store?.createProjectHostSetup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const result = store.createProjectHostSetup(args)
|
|
if (!result) {
|
|
throw new Error(`Project not found: ${args.projectId}`)
|
|
}
|
|
return result
|
|
}
|
|
|
|
async setupExistingFolder(
|
|
args: ProjectHostSetupExistingFolderArgs
|
|
): Promise<ProjectHostSetupResult> {
|
|
if (!this.deps.getStore()) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const kind = args.kind === 'folder' ? 'folder' : 'git'
|
|
const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id))
|
|
// Why route rather than refuse: this process owns the SSH connection, and its own IPC handler
|
|
// already registers `ssh:*` hosts correctly. Refusing here only made the CLI and runtime RPC
|
|
// disagree with the desktop app about what the same process can do.
|
|
const sshTargetId = getSshTargetIdForExecutionHost(args.hostId)
|
|
const repo = sshTargetId
|
|
? await this.deps.addRemoteRepo({
|
|
connectionId: sshTargetId,
|
|
remotePath: args.path,
|
|
...(args.displayName ? { displayName: args.displayName } : {}),
|
|
kind
|
|
})
|
|
: await this.deps.addRepo(args.path, kind, args.hostId)
|
|
return this.completeSetup(args, repo, !knownRepoIds.has(repo.id))
|
|
}
|
|
|
|
async setupClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
|
|
assertCloneHostIsSupported(args.hostId)
|
|
const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id))
|
|
const repo = await this.deps.cloneRepo(args.url, args.destination, args.hostId)
|
|
return this.completeSetup(
|
|
{ ...args, path: repo.path, kind: 'git', setupMethod: 'cloned' },
|
|
repo,
|
|
!knownRepoIds.has(repo.id)
|
|
)
|
|
}
|
|
|
|
updateSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult {
|
|
const store = this.deps.getStore()
|
|
if (!store?.updateProjectHostSetup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const result = store.updateProjectHostSetup(args)
|
|
if (!result) {
|
|
throw new Error(`Project host setup not found: ${args.setupId}`)
|
|
}
|
|
if ('worktreeBasePath' in args.updates && result.repo) {
|
|
void prepareLocalWorktreeRootForRepo(store, result.repo)
|
|
invalidateAuthorizedRootsCache()
|
|
}
|
|
return result
|
|
}
|
|
|
|
deleteSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult {
|
|
const store = this.deps.getStore()
|
|
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}`)
|
|
}
|
|
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,
|
|
repoWasCreated: boolean
|
|
): ProjectHostSetupResult {
|
|
try {
|
|
return this.linkRepo(args, initialRepo)
|
|
} catch (error) {
|
|
if (repoWasCreated) {
|
|
// Why: host-scoped so a rollback never takes a same-id row on another host.
|
|
this.deps
|
|
.getStore()
|
|
?.removeProjectForHost?.(initialRepo.id, getRepoExecutionHostId(initialRepo))
|
|
this.deps.invalidateResolvedWorktrees()
|
|
this.deps.invalidateWorktreeScan(initialRepo.id)
|
|
invalidateAuthorizedRootsCache()
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private linkRepo(
|
|
args: ProjectHostSetupExistingFolderArgs,
|
|
initialRepo: Repo
|
|
): ProjectHostSetupResult {
|
|
const store = this.deps.getStore()
|
|
if (!store) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
let repo = initialRepo
|
|
let setup = getProjectHostSetupForRepo(this.listSetups(), repo)
|
|
if (setup.projectId !== args.projectId) {
|
|
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
|
|
const identity = existingProject?.providerIdentity ?? args.projectProviderIdentity
|
|
if (!identity || getProjectIdForProviderIdentity(identity) !== args.projectId) {
|
|
throw new Error('Imported folder does not match the selected project identity.')
|
|
}
|
|
const updated = store.updateRepo(repo.id, {
|
|
upstream: {
|
|
owner: identity.owner,
|
|
repo: identity.repo,
|
|
...(identity.host ? { host: identity.host } : {})
|
|
}
|
|
})
|
|
if (!updated) {
|
|
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
|
|
}
|
|
repo = updated
|
|
setup = getProjectHostSetupForRepo(this.listSetups(), repo)
|
|
}
|
|
const setupMethod = args.setupMethod ?? 'imported-existing-folder'
|
|
const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
|
|
if (!updated) {
|
|
throw new Error(
|
|
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
|
|
)
|
|
}
|
|
repo = updated
|
|
setup = getProjectHostSetupForRepo(this.listSetups(), repo)
|
|
const project = this.listProjects().find((entry) => entry.id === setup.projectId)
|
|
if (!project) {
|
|
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
|
|
}
|
|
return { project, setup, repo }
|
|
}
|
|
}
|