Add project host setup update 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 13baeba95d
commit 4c146ce394
20 changed files with 556 additions and 22 deletions
@@ -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
@@ -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
+4 -2
View File
@@ -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
+4 -2
View File
@@ -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
+14 -10
View File
@@ -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
+1
View File
@@ -25,6 +25,7 @@ export type { ComputerActionFollowUpTarget } from './computer-format'
export {
formatProjectHostSetupList,
formatProjectHostSetupResult,
formatProjectHostSetupUpdateResult,
formatProjectList
} from './project-format'
export {
+64
View File
@@ -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<string, CommandHandler> = {
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<string, string | boolean>
): 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<string, string | boolean>
): 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'
)
}
+2
View File
@@ -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 <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 repo list [--json]
orca repo add --path <path> [--json]
orca repo show --repo <selector> [--json]
+68
View File
@@ -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'
}
})
})
})
+22 -2
View File
@@ -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')
}
+25
View File
@@ -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 <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]',
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'
]
}
]
+33
View File
@@ -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 (
+93
View File
@@ -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())
+104
View File
@@ -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<Store['updateRepo']>[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,
+14
View File
@@ -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?.() ?? []
}
@@ -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)
})
})
]
+51
View File
@@ -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',
+3
View File
@@ -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<ProjectHostSetupResult>
updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise<ProjectHostSetupUpdateResult>
}
projectGroups: {
list: () => Promise<ProjectGroup[]>
+3 -1
View File
@@ -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: {
+22
View File
@@ -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