diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index 0d1c34b9bfc..c8d072619ef 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -496,12 +496,14 @@ Already partially landed: - persistence merge that preserves independently persisted project/setup rows across load, repo updates, and repo reorders while refreshing repo-backed compatibility rows from repos +- first-class `projectHostSetup.update` mutation through local IPC, preload, + runtime RPC, and CLI for setup-owned metadata Not complete yet: - SSH clone streamed-progress parity and provisioning flows -- first-class independent project-host setup creation/update/delete APIs beyond - the current repo-backed import/clone compatibility paths +- first-class independent project-host setup creation/delete 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 3ff4b18b725..3c31df59078 100644 --- a/notes/project-first-host-model-conversation.md +++ b/notes/project-first-host-model-conversation.md @@ -500,11 +500,13 @@ Already partially landed: - persistence merge that preserves independently persisted project/setup rows across load, repo updates, and repo reorders while refreshing repo-backed compatibility rows from repos +- first-class `projectHostSetup.update` mutation through local IPC, preload, + runtime RPC, and CLI for setup-owned metadata Not complete yet: -- first-class independent project-host setup creation/update/delete APIs beyond - the current repo-backed import/clone compatibility paths +- first-class independent project-host setup creation/delete 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 @@ -534,7 +536,7 @@ tests, and version-skew handling. The largest remaining areas are: -1. independent `ProjectHostSetup` creation/update/delete APIs +1. independent `ProjectHostSetup` creation/delete 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-discussion.md b/notes/project-first-host-model-discussion.md index 7314e1861be..626004e3e01 100644 --- a/notes/project-first-host-model-discussion.md +++ b/notes/project-first-host-model-discussion.md @@ -535,14 +535,16 @@ Already partially landed: - persistence merge that preserves independently persisted project/setup rows across load, repo updates, and repo reorders while refreshing repo-backed compatibility rows from repos +- first-class `projectHostSetup.update` mutation through local IPC, preload, + runtime RPC, and CLI for setup-owned metadata Not complete yet: - SSH clone streamed-progress parity - provisioning flows and inline provisioning actions from the composer - bulk setup flows and setup for hosts that are not already known to the client -- first-class independent project-host setup creation/update/delete APIs beyond - the current repo-backed import/clone compatibility paths +- first-class independent project-host setup creation/delete APIs beyond the + current repo-backed import/clone compatibility paths - full project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup - complete cache/request ownership audit diff --git a/notes/project-first-host-model-handoff.md b/notes/project-first-host-model-handoff.md index b0698e75706..60c0a1cb5ed 100644 --- a/notes/project-first-host-model-handoff.md +++ b/notes/project-first-host-model-handoff.md @@ -418,12 +418,14 @@ Already partially landed: - persistence merge that preserves independently persisted project/setup rows across load, repo updates, and repo reorders while refreshing repo-backed compatibility rows from repos +- first-class `projectHostSetup.update` mutation through local IPC, preload, + runtime RPC, and CLI for setup-owned metadata Still needed: - finish SSH clone streamed-progress parity and provisioning flows -- first-class independent project-host setup creation/update/delete APIs beyond - the current repo-backed import/clone compatibility paths +- first-class independent project-host setup creation/delete 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 d47cf394a67..e3950e47683 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -770,16 +770,20 @@ Landed so far: compatibility rows are still refreshed from repos, but independently persisted projects/setups now survive load, repo update, and repo reorder paths instead of being overwritten by the repo projection. +- Added a first-class `projectHostSetup.update` mutation through local IPC, + 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 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 routing, project-host setup capability gating, independent setup preservation, - setup method persistence, local/runtime/SSH clone setup composition, remote - clone IPC, and GitHub clone URL inference. Sidebar row-builder tests now cover - project-first multi-host grouping and same-name repo separation without - project identity. Workspace target tests cover local-only fallback, - focused-host setup selection, explicit project-plus-host resolution, same-name - non-merging, and unavailable setup reasons. + setup update mutation, setup method persistence, local/runtime/SSH clone setup + composition, remote clone IPC, and GitHub clone URL inference. Sidebar + row-builder tests now cover project-first multi-host grouping and same-name + repo separation without project identity. Workspace target tests cover + local-only fallback, focused-host setup selection, explicit project-plus-host + resolution, same-name non-merging, and unavailable setup reasons. Important limitation: @@ -798,16 +802,16 @@ Important limitation: - Repo-backed project-host setup records are still regenerated from repo 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. A fully independent setup creation and - mutation table is still future work. + 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. 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/update/delete APIs - instead of routing most setup changes through repo import/update compatibility +- add first-class independent project-host setup creation/delete 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 cc476a1af92..6cde305a68d 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -25,6 +25,7 @@ export type { ComputerActionFollowUpTarget } from './computer-format' export { formatProjectHostSetupList, formatProjectHostSetupResult, + formatProjectHostSetupUpdateResult, formatProjectList } from './project-format' export { diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index 504d7a08daf..1ea0514c286 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -3,12 +3,15 @@ import type { ProjectHostSetup, ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, RepoKind } from '../../shared/types' import type { CommandHandler } from '../dispatch' import { formatProjectHostSetupList, formatProjectHostSetupResult, + formatProjectHostSetupUpdateResult, formatProjectList, printResult } from '../format' @@ -57,5 +60,66 @@ export const PROJECT_HANDLERS: Record = { args ) printResult(result, json, formatProjectHostSetupResult) + }, + 'project setup-update': async ({ flags, client, cwd, json }) => { + const path = getOptionalStringFlag(flags, 'path') + const args: ProjectHostSetupUpdateArgs = { + setupId: getRequiredStringFlag(flags, 'setup'), + updates: { + displayName: getOptionalStringFlag(flags, 'display-name'), + path: + path === undefined + ? undefined + : resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup update'), + worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'), + gitUsername: getOptionalStringFlag(flags, 'git-username'), + kind: getOptionalRepoKind(flags), + setupState: getOptionalSetupState(flags), + setupMethod: getOptionalSetupMethod(flags) + } + } + const result = await client.call<{ result: ProjectHostSetupUpdateResult }>( + 'projectHostSetup.update', + args + ) + printResult(result, json, formatProjectHostSetupUpdateResult) } } + +function getOptionalSetupState( + flags: Map +): ProjectHostSetupUpdateArgs['updates']['setupState'] { + const state = getOptionalStringFlag(flags, 'state') + if (state === undefined) { + return undefined + } + if ( + state === 'ready' || + state === 'not-set-up' || + state === 'setting-up' || + state === 'error' || + state === 'unsupported' + ) { + return state + } + throw new RuntimeClientError( + 'invalid_argument', + '--state must be ready, not-set-up, setting-up, error, or unsupported' + ) +} + +function getOptionalSetupMethod( + flags: Map +): ProjectHostSetupUpdateArgs['updates']['setupMethod'] { + const method = getOptionalStringFlag(flags, 'method') + if (method === undefined) { + return undefined + } + if (method === 'legacy-repo' || method === 'imported-existing-folder' || method === 'cloned') { + return method + } + throw new RuntimeClientError( + 'invalid_argument', + '--method must be legacy-repo, imported-existing-folder, or cloned' + ) +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 6a79b39a57a..e52e8e38824 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -33,6 +33,7 @@ Projects: project list List durable projects known to Orca 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 Repos: repo list List repos registered in Orca @@ -209,6 +210,7 @@ Common Commands: orca project list [--json] 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 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 2597fc507a7..879d7912ea4 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -144,6 +144,9 @@ describe('orca root help', () => { expect(logSpy.mock.calls[0][0]).toContain( 'project setup-existing-folder Make a project available on a host by importing an existing folder' ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-update Update project host setup metadata' + ) expect(callMock).not.toHaveBeenCalled() }) }) @@ -3068,4 +3071,69 @@ describe('orca cli worktree awareness', () => { process.exitCode = priorExitCode }) + + it('updates project host setup metadata through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup_update', { + 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-update', + '--setup', + 'setup-gpu', + '--display-name', + 'GPU VM', + '--path', + '/srv/orca', + '--worktree-base-path', + '../worktrees', + '--state', + 'ready', + '--method', + 'imported-existing-folder', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.update', { + setupId: 'setup-gpu', + updates: { + displayName: 'GPU VM', + path: '/srv/orca', + worktreeBasePath: '../worktrees', + gitUsername: undefined, + kind: undefined, + setupState: 'ready', + setupMethod: 'imported-existing-folder' + } + }) + }) }) diff --git a/src/cli/project-format.ts b/src/cli/project-format.ts index 926b57fceaf..8a3fa7c5324 100644 --- a/src/cli/project-format.ts +++ b/src/cli/project-format.ts @@ -1,4 +1,9 @@ -import type { Project, ProjectHostSetup, ProjectHostSetupResult } from '../shared/types' +import type { + Project, + ProjectHostSetup, + ProjectHostSetupResult, + ProjectHostSetupUpdateResult +} from '../shared/types' export function formatProjectList(result: { projects: Project[] }): string { if (result.projects.length === 0) { @@ -28,6 +33,21 @@ export function formatProjectHostSetupList(result: { setups: ProjectHostSetup[] export function formatProjectHostSetupResult(result: { result: ProjectHostSetupResult }): string { const { project, setup, repo } = result.result + return formatProjectHostSetupResultFields(project, setup, repo.id) +} + +export function formatProjectHostSetupUpdateResult(result: { + result: ProjectHostSetupUpdateResult +}): string { + const { project, setup, repo } = result.result + return formatProjectHostSetupResultFields(project, setup, repo?.id) +} + +function formatProjectHostSetupResultFields( + project: Project, + setup: ProjectHostSetup, + repoId: string | undefined +): string { return [ `projectId: ${project.id}`, `project: ${project.displayName}`, @@ -36,6 +56,6 @@ export function formatProjectHostSetupResult(result: { result: ProjectHostSetupR `path: ${setup.path}`, `state: ${setup.setupState}`, `method: ${setup.setupMethod}`, - `repoId: ${repo.id}` + `repoId: ${repoId ?? 'none'}` ].join('\n') } diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index 1f6ef6755d3..6d5e05db09e 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -32,5 +32,30 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ 'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca', 'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json' ] + }, + { + path: ['project', 'setup-update'], + summary: 'Update project host setup metadata', + usage: + '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]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'setup', + 'display-name', + 'path', + 'worktree-base-path', + 'git-username', + 'kind', + 'state', + 'method' + ], + notes: [ + 'Repo-backed setups mirror safe fields onto the repo record.', + 'Path and availability state changes are only supported for independent setup records.' + ], + examples: [ + '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' + ] } ] diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 40b6c24008b..abd058dbc2b 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -13,6 +13,8 @@ import type { ProjectGroupImportResult, ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, NestedRepoScanResult, BaseRefDefaultResult, SparsePreset @@ -453,6 +455,19 @@ const ProjectHostSetupExistingFolderIpcArgs = z.object({ setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() }) +const ProjectHostSetupUpdateIpcArgs = z.object({ + setupId: z.string().min(1), + updates: z.object({ + displayName: z.string().optional(), + path: z.string().optional(), + worktreeBasePath: z.string().optional(), + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z.enum(['legacy-repo', 'imported-existing-folder', 'cloned']).optional(), + gitUsername: z.string().optional(), + kind: z.enum(['git', 'folder']).optional() + }) +}) + const ProjectGroupScanNestedArgs = z.object({ path: z.string().min(1), connectionId: z.string().min(1).optional(), @@ -742,6 +757,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('projects:list') ipcMain.removeHandler('projectHostSetups:list') ipcMain.removeHandler('projectHostSetups:setupExistingFolder') + ipcMain.removeHandler('projectHostSetups:update') ipcMain.removeHandler('projectGroups:list') ipcMain.removeHandler('projectGroups:create') ipcMain.removeHandler('projectGroups:update') @@ -773,6 +789,23 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups()) + ipcMain.handle( + 'projectHostSetups:update', + (_event, rawArgs: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult => { + const args = parseProjectGroupIpcArgs( + ProjectHostSetupUpdateIpcArgs, + rawArgs, + 'project_host_setup_update_invalid_args' + ) + const result = store.updateProjectHostSetup(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 33671b2ffff..05624424d50 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2419,6 +2419,99 @@ describe('Store', () => { ]) }) + it('updates independent project host setup records directly', 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.updateProjectHostSetup({ + setupId: independentSetup.id, + updates: { + displayName: 'GPU VM renamed', + path: '/srv/renamed', + worktreeBasePath: '../worktrees', + setupState: 'ready', + setupMethod: 'cloned', + gitUsername: 'alice' + } + }) + + expect(result).toEqual({ + project: independentProject, + setup: expect.objectContaining({ + id: independentSetup.id, + displayName: 'GPU VM renamed', + path: '/srv/renamed', + worktreeBasePath: '../worktrees', + setupState: 'ready', + setupMethod: 'cloned', + gitUsername: 'alice' + }) + }) + expect(store.getProjectHostSetups()[0]).toMatchObject({ + displayName: 'GPU VM renamed', + path: '/srv/renamed' + }) + }) + + it('updates repo-backed project host setup metadata through the repo record', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', displayName: 'Repo', worktreeBasePath: '../old' })) + + const result = store.updateProjectHostSetup({ + setupId: 'r1', + updates: { + displayName: 'Repo renamed', + worktreeBasePath: '../new', + setupMethod: 'cloned' + } + }) + + expect(result?.repo).toMatchObject({ + id: 'r1', + displayName: 'Repo renamed', + worktreeBasePath: '../new', + projectHostSetupMethod: 'cloned' + }) + expect(result?.project).toMatchObject({ + id: 'repo:r1', + displayName: 'Repo renamed' + }) + expect(result?.setup).toMatchObject({ + id: 'r1', + displayName: 'Repo renamed', + worktreeBasePath: '../new', + setupMethod: 'cloned' + }) + }) + + it('rejects repo-backed project host setup path changes', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + + expect(() => + store.updateProjectHostSetup({ + setupId: 'r1', + updates: { path: '/other' } + }) + ).toThrow('Repo-backed project host setup paths must be changed by re-importing the project.') + }) + 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 fdb1a9dc565..bc4d7dc3cc2 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -36,6 +36,8 @@ import type { PersistedState, Project, ProjectHostSetup, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, ProjectHostSetupMethod, Repo, ProjectGroup, @@ -2561,6 +2563,31 @@ export class Store { return [...this.state.projectHostSetups] } + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | 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) { + const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates) + const updatedProject = updated + ? this.state.projects.find((entry) => entry.id === updated.setup.projectId) + : undefined + return updated && updatedProject + ? { project: updatedProject, setup: updated.setup, repo: updated.repo } + : null + } + const updatedSetup = this.updateIndependentProjectHostSetup(setup, args.updates) + return { project, setup: updatedSetup } + } + /** * 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 @@ -2825,6 +2852,83 @@ export class Store { this.state.projectHostSetups = compatibilityState.projectHostSetups } + private updateRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] + ): { setup: ProjectHostSetup; repo: Repo } | null { + if (updates.path !== undefined && updates.path !== repo.path) { + throw new Error( + 'Repo-backed project host setup paths must be changed by re-importing the project.' + ) + } + if (updates.setupState !== undefined && updates.setupState !== 'ready') { + throw new Error('Repo-backed project host setups cannot be marked unavailable.') + } + const repoUpdates: Parameters[1] = {} + if (updates.displayName !== undefined) { + repoUpdates.displayName = updates.displayName + } + if (updates.worktreeBasePath !== undefined) { + repoUpdates.worktreeBasePath = updates.worktreeBasePath + } + if (updates.kind !== undefined) { + repoUpdates.kind = updates.kind + } + if (updates.setupMethod !== undefined && updates.setupMethod !== 'legacy-repo') { + repoUpdates.projectHostSetupMethod = updates.setupMethod + } + const updatedRepo = + Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo + if (!updatedRepo) { + return null + } + return { + setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup, + repo: updatedRepo + } + } + + private updateIndependentProjectHostSetup( + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] + ): ProjectHostSetup { + if (updates.displayName !== undefined) { + setup.displayName = updates.displayName.trim() || setup.displayName + } + if (updates.path !== undefined) { + setup.path = updates.path.trim() || setup.path + } + if (updates.worktreeBasePath !== undefined) { + const worktreeBasePath = updates.worktreeBasePath.trim() + if (worktreeBasePath) { + setup.worktreeBasePath = worktreeBasePath + } else { + delete setup.worktreeBasePath + } + } + if (updates.kind !== undefined) { + setup.kind = updates.kind + } + if (updates.gitUsername !== undefined) { + const gitUsername = updates.gitUsername.trim() + if (gitUsername) { + setup.gitUsername = gitUsername + } else { + delete setup.gitUsername + } + } + if (updates.setupState !== undefined) { + setup.setupState = updates.setupState + } + if (updates.setupMethod !== undefined) { + setup.setupMethod = updates.setupMethod + } + setup.updatedAt = Date.now() + this.scheduleSave() + return setup + } + private hydrateRepo(repo: Repo): Repo { const { repoIcon: rawRepoIcon, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2961549073d..6bf7bc7d4bf 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -54,6 +54,8 @@ import type { ProjectHostSetup, ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, Repo, RemoveWorktreeResult, StatsSummary, @@ -529,6 +531,7 @@ type RuntimeStore = { updateRepo: Store['updateRepo'] getProjects?: Store['getProjects'] getProjectHostSetups?: Store['getProjectHostSetups'] + updateProjectHostSetup?: Store['updateProjectHostSetup'] getProjectGroups?: Store['getProjectGroups'] createProjectGroup?: Store['createProjectGroup'] updateProjectGroup?: Store['updateProjectGroup'] @@ -6500,6 +6503,17 @@ export class OrcaRuntimeService { return { project, setup, repo } } + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult { + if (!this.store?.updateProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.updateProjectHostSetup(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 c8ee8bcfd0f..a06b8facc07 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -19,6 +19,19 @@ const ProjectHostSetupExistingFolder = z.object({ setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() }) +const ProjectHostSetupUpdate = z.object({ + setupId: requiredString('Missing setup ID'), + updates: z.object({ + displayName: OptionalString, + path: OptionalString, + worktreeBasePath: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z.enum(['legacy-repo', 'imported-existing-folder', 'cloned']).optional(), + gitUsername: OptionalString, + kind: z.enum(['git', 'folder']).optional() + }) +}) + export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ defineMethod({ name: 'project.list', @@ -36,5 +49,12 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => ({ result: await runtime.setupProjectExistingFolder(params) }) + }), + defineMethod({ + name: 'projectHostSetup.update', + params: ProjectHostSetupUpdate, + handler: (params, { runtime }) => ({ + result: runtime.updateProjectHostSetup(params) + }) }) ] diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 9a11e934a0f..9f4bc59e704 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -403,6 +403,57 @@ describe('repo RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { result } }) }) + it('routes project-host setup updates 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', + updateProjectHostSetup: vi.fn().mockReturnValue(result) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('projectHostSetup.update', { + setupId: 'setup-1', + updates: { + displayName: 'GPU VM', + path: '/srv/project', + setupState: 'ready' + } + }) + ) + + expect(runtime.updateProjectHostSetup).toHaveBeenCalledWith({ + setupId: 'setup-1', + updates: { + displayName: 'GPU VM', + path: '/srv/project', + setupState: 'ready' + } + }) + 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 b3cdfc2fc37..69c3542ecf3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -126,6 +126,8 @@ import type { ProjectHostSetup, ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, ProjectGroupImportResult, ProjectGroupImportMode, ShellHydrationFailureReason, @@ -783,6 +785,7 @@ export type PreloadApi = { setupExistingFolder: ( args: ProjectHostSetupExistingFolderArgs ) => Promise + updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise } projectGroups: { list: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index c50055712f2..e70ba685093 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -508,7 +508,9 @@ const api = { projects: { list: () => ipcRenderer.invoke('projects:list'), listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), - setupExistingFolder: (args) => ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args) + setupExistingFolder: (args) => + ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), + updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args) } satisfies PreloadApi['projects'], projectGroups: { diff --git a/src/shared/types.ts b/src/shared/types.ts index 4ecbb573482..0d96d52d468 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -142,12 +142,34 @@ export type ProjectHostSetupCloneArgs = { displayName?: string } +export type ProjectHostSetupUpdateArgs = { + setupId: string + updates: Partial< + Pick< + ProjectHostSetup, + | 'displayName' + | 'path' + | 'worktreeBasePath' + | 'setupState' + | 'setupMethod' + | 'gitUsername' + | 'kind' + > + > +} + export type ProjectHostSetupResult = { project: Project setup: ProjectHostSetup repo: Repo } +export type ProjectHostSetupUpdateResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} + export type Repo = { id: string path: string