mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Add project host setup update API
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ export type { ComputerActionFollowUpTarget } from './computer-format'
|
||||
export {
|
||||
formatProjectHostSetupList,
|
||||
formatProjectHostSetupResult,
|
||||
formatProjectHostSetupUpdateResult,
|
||||
formatProjectList
|
||||
} from './project-format'
|
||||
export {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user