From beb7cbaade6f64d1de0c8db8767c398c79684408 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 10 Jun 2026 19:22:09 -0700 Subject: [PATCH] Add project host setup delete API Co-authored-by: Orca --- ...oject-first-host-model-change-inventory.md | 6 ++- .../project-first-host-model-conversation.md | 8 ++-- notes/project-first-host-model-handoff.md | 6 ++- notes/project-first-host-model.md | 11 +++-- src/cli/format.ts | 1 + src/cli/handlers/project.ts | 11 +++++ src/cli/help.ts | 2 + src/cli/index.test.ts | 40 +++++++++++++++++ src/cli/project-format.ts | 11 +++++ src/cli/specs/project.ts | 11 +++++ src/main/ipc/repos.ts | 24 +++++++++++ src/main/persistence.test.ts | 43 +++++++++++++++++++ src/main/persistence.ts | 25 +++++++++++ src/main/runtime/orca-runtime.ts | 14 ++++++ .../methods/project-runtime-rpc-methods.ts | 11 +++++ src/main/runtime/rpc/methods/repo.test.ts | 39 +++++++++++++++++ src/preload/api-types.ts | 3 ++ src/preload/index.ts | 3 +- src/shared/types.ts | 10 +++++ 19 files changed, 268 insertions(+), 11 deletions(-) diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index c8d072619ef..00aa8240af2 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -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 diff --git a/notes/project-first-host-model-conversation.md b/notes/project-first-host-model-conversation.md index 3c31df59078..261fcf898fa 100644 --- a/notes/project-first-host-model-conversation.md +++ b/notes/project-first-host-model-conversation.md @@ -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 diff --git a/notes/project-first-host-model-handoff.md b/notes/project-first-host-model-handoff.md index 60c0a1cb5ed..a6746c387bc 100644 --- a/notes/project-first-host-model-handoff.md +++ b/notes/project-first-host-model-handoff.md @@ -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 diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index e3950e47683..ace67381de9 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -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 diff --git a/src/cli/format.ts b/src/cli/format.ts index 6cde305a68d..78ba0aaacc1 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -23,6 +23,7 @@ export { } from './computer-format' export type { ComputerActionFollowUpTarget } from './computer-format' export { + formatProjectHostSetupDeleteResult, formatProjectHostSetupList, formatProjectHostSetupResult, formatProjectHostSetupUpdateResult, diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index 1ea0514c286..b6bcccf46ee 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -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 = { 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) } } diff --git a/src/cli/help.ts b/src/cli/help.ts index e52e8e38824..21a6d3adfc1 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 ] [--host ] [--json] orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--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] [--json] + orca project setup-delete --setup [--json] orca repo list [--json] orca repo add --path [--json] orca repo show --repo [--json] diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 879d7912ea4..61d255cf2ea 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -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' + }) + }) }) diff --git a/src/cli/project-format.ts b/src/cli/project-format.ts index 8a3fa7c5324..340fe8abb93 100644 --- a/src/cli/project-format.ts +++ b/src/cli/project-format.ts @@ -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, diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index 6d5e05db09e..ffeec9388e2 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -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 [--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'] } ] diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index abd058dbc2b..38c5208dc6f 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 ( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 05624424d50..aff09ff4849 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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()) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index bc4d7dc3cc2..d0b7ea2a0b1 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6bf7bc7d4bf..28e805e4072 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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?.() ?? [] } diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index a06b8facc07..1b114d840dc 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -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) + }) }) ] diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 9f4bc59e704..af756e1b427 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -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', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 69c3542ecf3..c3f01474a06 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -124,6 +124,8 @@ import type { Repo, ProjectGroup, ProjectHostSetup, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult, ProjectHostSetupUpdateArgs, @@ -786,6 +788,7 @@ export type PreloadApi = { args: ProjectHostSetupExistingFolderArgs ) => Promise updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise + deleteHostSetup: (args: ProjectHostSetupDeleteArgs) => Promise } projectGroups: { list: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index e70ba685093..885f3001355 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: { diff --git a/src/shared/types.ts b/src/shared/types.ts index 0d96d52d468..66b9b9c4cdd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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