Add project host setup delete API

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:59 -07:00
co-authored by Orca
parent 4c146ce394
commit beb7cbaade
19 changed files with 268 additions and 11 deletions
@@ -498,12 +498,14 @@ Already partially landed:
compatibility rows from repos
- first-class `projectHostSetup.update` mutation through local IPC, preload,
runtime RPC, and CLI for setup-owned metadata
- first-class `projectHostSetup.delete` mutation through local IPC, preload,
runtime RPC, and CLI
Not complete yet:
- SSH clone streamed-progress parity and provisioning flows
- first-class independent project-host setup creation/delete APIs beyond the
current repo-backed import/clone compatibility paths
- first-class independent project-host setup creation/provisioning APIs beyond
the current repo-backed import/clone compatibility paths
- bulk setup flows and setup for hosts that are not already known to the client
- project settings split into global and host-specific ownership
- host settings/capability UI aligned with project setup
@@ -502,11 +502,13 @@ Already partially landed:
compatibility rows from repos
- first-class `projectHostSetup.update` mutation through local IPC, preload,
runtime RPC, and CLI for setup-owned metadata
- first-class `projectHostSetup.delete` mutation through local IPC, preload,
runtime RPC, and CLI
Not complete yet:
- first-class independent project-host setup creation/delete APIs beyond the
current repo-backed import/clone compatibility paths
- first-class independent project-host setup creation/provisioning APIs beyond
the current repo-backed import/clone compatibility paths
- bulk setup flows and setup for hosts that are not already known to the client
- SSH streamed clone progress parity
- full project settings split into global and host-specific ownership
@@ -536,7 +538,7 @@ tests, and version-skew handling.
The largest remaining areas are:
1. independent `ProjectHostSetup` creation/delete APIs
1. independent `ProjectHostSetup` creation/provisioning APIs
2. project-first creation/setup UI completion
3. project and host settings ownership split
4. cache/request ownership audit
+4 -2
View File
@@ -420,12 +420,14 @@ Already partially landed:
compatibility rows from repos
- first-class `projectHostSetup.update` mutation through local IPC, preload,
runtime RPC, and CLI for setup-owned metadata
- first-class `projectHostSetup.delete` mutation through local IPC, preload,
runtime RPC, and CLI
Still needed:
- finish SSH clone streamed-progress parity and provisioning flows
- first-class independent project-host setup creation/delete APIs beyond the
current repo-backed import/clone compatibility paths
- first-class independent project-host setup creation/provisioning APIs beyond
the current repo-backed import/clone compatibility paths
- bulk setup flows and setup for newly added hosts
- project settings split into global and host-specific ownership
- host settings/capability UI aligned with project setup
+8 -3
View File
@@ -774,6 +774,10 @@ Landed so far:
preload, runtime RPC, and CLI. Independent setup rows can update setup-owned
metadata directly, while repo-backed setup rows mirror safe fields onto the
existing repo record and reject path/state changes that require re-import.
- Added a first-class `projectHostSetup.delete` mutation through local IPC,
preload, runtime RPC, and CLI. Independent setup rows are removed directly,
while repo-backed setup rows remove the existing repo compatibility record so
legacy cleanup semantics stay centralized.
- Added tests for local repos, SSH repos, same-provider multi-host grouping,
no-identity same-name non-grouping, selector cache behavior, persistence
backfill, repo mutation synchronization, renderer hydration, runtime RPC
@@ -803,15 +807,16 @@ Important limitation:
compatibility records. Independently persisted setup records are now preserved
across load and repo mutations, but the UI/API still mostly creates setups
through repo-backed import/clone paths. First-class setup update now exists,
but independent setup create/delete and full UI flows are still future work.
and first-class setup delete now exists, but independent setup creation,
provisioning, and full UI flows are still future work.
Remaining end-to-end work:
- broaden setup-on-host flows beyond known local, SSH, and active runtime hosts
- finish SSH clone streamed-progress parity, provisioning, and bulk setup-on-host
flows
- add first-class independent project-host setup creation/delete APIs instead of
routing most setup changes through repo import/update compatibility
- add first-class independent project-host setup creation and provisioning APIs
instead of routing most setup changes through repo import/update compatibility
- split settings into explicit client, host, project, and project-host setup
scopes
- validate the default project-first sidebar view in Electron and continue
+1
View File
@@ -23,6 +23,7 @@ export {
} from './computer-format'
export type { ComputerActionFollowUpTarget } from './computer-format'
export {
formatProjectHostSetupDeleteResult,
formatProjectHostSetupList,
formatProjectHostSetupResult,
formatProjectHostSetupUpdateResult,
+11
View File
@@ -1,6 +1,7 @@
import type {
Project,
ProjectHostSetup,
ProjectHostSetupDeleteResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectHostSetupUpdateArgs,
@@ -9,6 +10,7 @@ import type {
} from '../../shared/types'
import type { CommandHandler } from '../dispatch'
import {
formatProjectHostSetupDeleteResult,
formatProjectHostSetupList,
formatProjectHostSetupResult,
formatProjectHostSetupUpdateResult,
@@ -83,6 +85,15 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
args
)
printResult(result, json, formatProjectHostSetupUpdateResult)
},
'project setup-delete': async ({ flags, client, json }) => {
const result = await client.call<{ result: ProjectHostSetupDeleteResult }>(
'projectHostSetup.delete',
{
setupId: getRequiredStringFlag(flags, 'setup')
}
)
printResult(result, json, formatProjectHostSetupDeleteResult)
}
}
+2
View File
@@ -34,6 +34,7 @@ Projects:
project setups List project host setups
project setup-existing-folder Make a project available on a host by importing an existing folder
project setup-update Update project host setup metadata
project setup-delete Remove a project host setup
Repos:
repo list List repos registered in Orca
@@ -211,6 +212,7 @@ Common Commands:
orca project setups [--project <id>] [--host <host-id>] [--json]
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned] [--json]
orca project setup-delete --setup <setup-id> [--json]
orca repo list [--json]
orca repo add --path <path> [--json]
orca repo show --repo <selector> [--json]
+40
View File
@@ -147,6 +147,9 @@ describe('orca root help', () => {
expect(logSpy.mock.calls[0][0]).toContain(
'project setup-update Update project host setup metadata'
)
expect(logSpy.mock.calls[0][0]).toContain(
'project setup-delete Remove a project host setup'
)
expect(callMock).not.toHaveBeenCalled()
})
})
@@ -3136,4 +3139,41 @@ describe('orca cli worktree awareness', () => {
}
})
})
it('deletes project host setup metadata through the project-first runtime API', async () => {
queueFixtures(
callMock,
okFixture('req_project_setup_delete', {
result: {
project: {
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#7c3aed',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: '',
path: '/srv/orca',
displayName: 'GPU VM',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 2
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', {
setupId: 'setup-gpu'
})
})
})
+11
View File
@@ -1,6 +1,7 @@
import type {
Project,
ProjectHostSetup,
ProjectHostSetupDeleteResult,
ProjectHostSetupResult,
ProjectHostSetupUpdateResult
} from '../shared/types'
@@ -43,6 +44,16 @@ export function formatProjectHostSetupUpdateResult(result: {
return formatProjectHostSetupResultFields(project, setup, repo?.id)
}
export function formatProjectHostSetupDeleteResult(result: {
result: ProjectHostSetupDeleteResult
}): string {
const { project, setup, repo } = result.result
return [
`deleted: ${setup.id}`,
formatProjectHostSetupResultFields(project, setup, repo?.id)
].join('\n')
}
function formatProjectHostSetupResultFields(
project: Project,
setup: ProjectHostSetup,
+11
View File
@@ -57,5 +57,16 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
'orca project setup-update --setup github:stablyai/orca::gpu --display-name "GPU VM"',
'orca project setup-update --setup github:stablyai/orca::gpu --path /srv/orca --state ready --json'
]
},
{
path: ['project', 'setup-delete'],
summary: 'Remove a project host setup',
usage: 'orca project setup-delete --setup <setup-id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'setup'],
notes: [
'Independent setups are removed directly.',
'Repo-backed setups remove the registered repo compatibility record.'
],
examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json']
}
]
+24
View File
@@ -11,6 +11,8 @@ import type {
Repo,
ProjectGroup,
ProjectGroupImportResult,
ProjectHostSetupDeleteArgs,
ProjectHostSetupDeleteResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectHostSetupUpdateArgs,
@@ -468,6 +470,10 @@ const ProjectHostSetupUpdateIpcArgs = z.object({
})
})
const ProjectHostSetupDeleteIpcArgs = z.object({
setupId: z.string().min(1)
})
const ProjectGroupScanNestedArgs = z.object({
path: z.string().min(1),
connectionId: z.string().min(1).optional(),
@@ -758,6 +764,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('projectHostSetups:list')
ipcMain.removeHandler('projectHostSetups:setupExistingFolder')
ipcMain.removeHandler('projectHostSetups:update')
ipcMain.removeHandler('projectHostSetups:delete')
ipcMain.removeHandler('projectGroups:list')
ipcMain.removeHandler('projectGroups:create')
ipcMain.removeHandler('projectGroups:update')
@@ -806,6 +813,23 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
)
ipcMain.handle(
'projectHostSetups:delete',
(_event, rawArgs: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupDeleteIpcArgs,
rawArgs,
'project_host_setup_delete_invalid_args'
)
const result = store.deleteProjectHostSetup(args)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
notifyReposChanged(mainWindow)
return result
}
)
ipcMain.handle(
'projectHostSetups:setupExistingFolder',
async (
+43
View File
@@ -2512,6 +2512,49 @@ describe('Store', () => {
).toThrow('Repo-backed project host setup paths must be changed by re-importing the project.')
})
it('deletes independent project host setup records without deleting the project', async () => {
const independentProject = makeProject({
id: 'cloud-project',
displayName: 'Cloud Project'
})
const independentSetup = makeProjectHostSetup({
id: 'cloud-project::gpu-vm',
projectId: independentProject.id,
hostId: 'runtime:gpu-vm',
repoId: '',
path: '/srv/cloud-project',
displayName: 'GPU VM'
})
writeDataFile({
...getDefaultPersistedState(testState.dir),
projects: [independentProject],
projectHostSetups: [independentSetup]
})
const store = await createStore()
const result = store.deleteProjectHostSetup({ setupId: independentSetup.id })
expect(result).toEqual({ project: independentProject, setup: independentSetup })
expect(store.getProjects()).toEqual([independentProject])
expect(store.getProjectHostSetups()).toEqual([])
})
it('deletes repo-backed project host setups by removing the compatibility repo', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1', path: '/repo' }))
store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' })
const result = store.deleteProjectHostSetup({ setupId: 'r1' })
expect(result?.project).toMatchObject({ id: 'repo:r1' })
expect(result?.setup).toMatchObject({ id: 'r1', repoId: 'r1' })
expect(result?.repo).toMatchObject({ id: 'r1' })
expect(store.getRepo('r1')).toBeUndefined()
expect(store.getProjects()).toEqual([])
expect(store.getProjectHostSetups()).toEqual([])
expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined()
})
it('updateRepo preserves repo-backed project host setup method', async () => {
const store = await createStore()
store.addRepo(makeRepo())
+25
View File
@@ -36,6 +36,8 @@ import type {
PersistedState,
Project,
ProjectHostSetup,
ProjectHostSetupDeleteArgs,
ProjectHostSetupDeleteResult,
ProjectHostSetupUpdateArgs,
ProjectHostSetupUpdateResult,
ProjectHostSetupMethod,
@@ -2588,6 +2590,29 @@ export class Store {
return { project, setup: updatedSetup }
}
deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null {
const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId)
if (!setup) {
return null
}
const project = this.state.projects.find((entry) => entry.id === setup.projectId)
if (!project) {
return null
}
const repo = setup.repoId
? this.state.repos.find((entry) => entry.id === setup.repoId)
: undefined
if (repo) {
this.removeProject(repo.id)
return { project, setup, repo: this.hydrateRepo(repo) }
}
this.state.projectHostSetups = this.state.projectHostSetups.filter(
(entry) => entry.id !== setup.id
)
this.scheduleSave()
return { project, setup }
}
/**
* O(1) read of the persisted repo count. Use this when you only need the
* count (e.g. cohort-classifier) — `getRepos()` hydrates each repo and
+14
View File
@@ -52,6 +52,8 @@ import type {
PersistedUIState,
Project,
ProjectHostSetup,
ProjectHostSetupDeleteArgs,
ProjectHostSetupDeleteResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectHostSetupUpdateArgs,
@@ -532,6 +534,7 @@ type RuntimeStore = {
getProjects?: Store['getProjects']
getProjectHostSetups?: Store['getProjectHostSetups']
updateProjectHostSetup?: Store['updateProjectHostSetup']
deleteProjectHostSetup?: Store['deleteProjectHostSetup']
getProjectGroups?: Store['getProjectGroups']
createProjectGroup?: Store['createProjectGroup']
updateProjectGroup?: Store['updateProjectGroup']
@@ -6514,6 +6517,17 @@ export class OrcaRuntimeService {
return result
}
deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult {
if (!this.store?.deleteProjectHostSetup) {
throw new Error('runtime_unavailable')
}
const result = this.store.deleteProjectHostSetup(args)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
return result
}
listProjectGroups(): ProjectGroup[] {
return this.store?.getProjectGroups?.() ?? []
}
@@ -32,6 +32,10 @@ const ProjectHostSetupUpdate = z.object({
})
})
const ProjectHostSetupDelete = z.object({
setupId: requiredString('Missing setup ID')
})
export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
name: 'project.list',
@@ -56,5 +60,12 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
handler: (params, { runtime }) => ({
result: runtime.updateProjectHostSetup(params)
})
}),
defineMethod({
name: 'projectHostSetup.delete',
params: ProjectHostSetupDelete,
handler: (params, { runtime }) => ({
result: runtime.deleteProjectHostSetup(params)
})
})
]
+39
View File
@@ -454,6 +454,45 @@ describe('repo RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { result } })
})
it('routes project-host setup deletes to the runtime server', async () => {
const result = {
project: {
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-1',
projectId: 'project-1',
hostId: 'runtime:env-1',
repoId: '',
path: '/srv/project',
displayName: 'GPU VM',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 2
}
}
const runtime = {
getRuntimeId: () => 'test-runtime',
deleteProjectHostSetup: vi.fn().mockReturnValue(result)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('projectHostSetup.delete', {
setupId: 'setup-1'
})
)
expect(runtime.deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
expect(response).toMatchObject({ ok: true, result: { result } })
})
it('allows separate nested-repo imports without a group name', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+3
View File
@@ -124,6 +124,8 @@ import type {
Repo,
ProjectGroup,
ProjectHostSetup,
ProjectHostSetupDeleteArgs,
ProjectHostSetupDeleteResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectHostSetupUpdateArgs,
@@ -786,6 +788,7 @@ export type PreloadApi = {
args: ProjectHostSetupExistingFolderArgs
) => Promise<ProjectHostSetupResult>
updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise<ProjectHostSetupUpdateResult>
deleteHostSetup: (args: ProjectHostSetupDeleteArgs) => Promise<ProjectHostSetupDeleteResult>
}
projectGroups: {
list: () => Promise<ProjectGroup[]>
+2 -1
View File
@@ -510,7 +510,8 @@ const api = {
listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'),
setupExistingFolder: (args) =>
ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args),
updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args)
updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args),
deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args)
} satisfies PreloadApi['projects'],
projectGroups: {
+10
View File
@@ -158,6 +158,10 @@ export type ProjectHostSetupUpdateArgs = {
>
}
export type ProjectHostSetupDeleteArgs = {
setupId: string
}
export type ProjectHostSetupResult = {
project: Project
setup: ProjectHostSetup
@@ -170,6 +174,12 @@ export type ProjectHostSetupUpdateResult = {
repo?: Repo
}
export type ProjectHostSetupDeleteResult = {
project: Project
setup: ProjectHostSetup
repo?: Repo
}
export type Repo = {
id: string
path: string