mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Expose project host setup lifecycle in renderer store
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -502,6 +502,8 @@ Already partially landed:
|
||||
runtime RPC, and CLI
|
||||
- first-class `projectHostSetup.create` mutation through local IPC, preload,
|
||||
runtime RPC, and CLI for independent setup/provisioning metadata
|
||||
- renderer-store lifecycle actions for project-host setup create, update, and
|
||||
delete, routed by setup-owning host
|
||||
|
||||
Not complete yet:
|
||||
|
||||
|
||||
@@ -506,6 +506,8 @@ Already partially landed:
|
||||
runtime RPC, and CLI
|
||||
- first-class `projectHostSetup.create` mutation through local IPC, preload,
|
||||
runtime RPC, and CLI for independent setup/provisioning metadata
|
||||
- renderer-store lifecycle actions for project-host setup create, update, and
|
||||
delete, routed by setup-owning host
|
||||
|
||||
Not complete yet:
|
||||
|
||||
|
||||
@@ -424,6 +424,8 @@ Already partially landed:
|
||||
runtime RPC, and CLI
|
||||
- first-class `projectHostSetup.create` mutation through local IPC, preload,
|
||||
runtime RPC, and CLI for independent setup/provisioning metadata
|
||||
- renderer-store lifecycle actions for project-host setup create, update, and
|
||||
delete, routed by setup-owning host
|
||||
|
||||
Still needed:
|
||||
|
||||
|
||||
@@ -782,6 +782,9 @@ Landed so far:
|
||||
preload, runtime RPC, and CLI. This creates independent, non-repo-backed
|
||||
setup metadata for setup/provisioning states without registering a repo
|
||||
compatibility record.
|
||||
- Added renderer-store lifecycle actions for first-class setup create, update,
|
||||
and delete. Runtime-owned setups route through their owning runtime instead
|
||||
of whichever host is currently focused.
|
||||
- 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
|
||||
@@ -811,8 +814,9 @@ Important limitation:
|
||||
compatibility records. Independently persisted setup records are now preserved
|
||||
across load and repo mutations, but the UI/API still mostly creates setups
|
||||
through repo-backed import/clone paths. First-class setup update now exists,
|
||||
first-class setup create/update/delete now exist for independent metadata, but
|
||||
actual provisioning execution and full UI flows are still future work.
|
||||
first-class setup create/update/delete now exist for independent metadata and
|
||||
are exposed through the renderer store, but actual provisioning execution and
|
||||
full UI flows are still future work.
|
||||
|
||||
Remaining end-to-end work:
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Project, ProjectHostSetup } from '../../../../shared/types'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
const projectsCreateHostSetup = vi.fn()
|
||||
const projectsUpdateHostSetup = vi.fn()
|
||||
const projectsDeleteHostSetup = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
const project: Project = {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#000',
|
||||
sourceRepoIds: ['local-repo'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const runtimeSetup: ProjectHostSetup = {
|
||||
id: 'setup-gpu',
|
||||
projectId: project.id,
|
||||
hostId: 'runtime:env-1',
|
||||
repoId: '',
|
||||
path: '/srv/project',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'provisioned',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
projectsCreateHostSetup.mockReset()
|
||||
projectsUpdateHostSetup.mockReset()
|
||||
projectsDeleteHostSetup.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
projects: {
|
||||
createHostSetup: projectsCreateHostSetup,
|
||||
updateHostSetup: projectsUpdateHostSetup,
|
||||
deleteHostSetup: projectsDeleteHostSetup
|
||||
},
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('repo slice project host setup lifecycle', () => {
|
||||
it('creates independent project host setup metadata through local IPC', async () => {
|
||||
const setup: ProjectHostSetup = {
|
||||
...runtimeSetup,
|
||||
hostId: 'local',
|
||||
path: '',
|
||||
setupState: 'setting-up'
|
||||
}
|
||||
projectsCreateHostSetup.mockResolvedValue({ project, setup })
|
||||
const store = createTestStore()
|
||||
|
||||
await expect(
|
||||
store.getState().createProjectHostSetup({
|
||||
projectId: project.id,
|
||||
hostId: 'local',
|
||||
setupId: setup.id,
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
).resolves.toEqual({ project, setup })
|
||||
|
||||
expect(store.getState().projects).toEqual([project])
|
||||
expect(store.getState().projectHostSetups).toEqual([setup])
|
||||
expect(projectsCreateHostSetup).toHaveBeenCalledWith({
|
||||
projectId: project.id,
|
||||
hostId: 'local',
|
||||
setupId: setup.id,
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
})
|
||||
|
||||
it('updates runtime-owned project host setups through their owning runtime', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-update-setup',
|
||||
ok: true,
|
||||
result: {
|
||||
result: {
|
||||
project,
|
||||
setup: { ...runtimeSetup, displayName: 'GPU VM renamed' }
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
projectHostSetups: [runtimeSetup],
|
||||
settings: { activeRuntimeEnvironmentId: null } as never
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().updateProjectHostSetup({
|
||||
setupId: runtimeSetup.id,
|
||||
updates: { displayName: 'GPU VM renamed' }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
project,
|
||||
setup: { ...runtimeSetup, displayName: 'GPU VM renamed' },
|
||||
repo: undefined
|
||||
})
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'projectHostSetup.update',
|
||||
params: {
|
||||
setupId: runtimeSetup.id,
|
||||
updates: { displayName: 'GPU VM renamed' }
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes runtime-owned project host setups through their owning runtime', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-delete-setup',
|
||||
ok: true,
|
||||
result: { result: { project, setup: runtimeSetup } },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
projects: [project],
|
||||
projectHostSetups: [runtimeSetup],
|
||||
settings: { activeRuntimeEnvironmentId: null } as never
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().deleteProjectHostSetup({ setupId: runtimeSetup.id })
|
||||
).resolves.toEqual({
|
||||
project,
|
||||
setup: runtimeSetup,
|
||||
repo: undefined
|
||||
})
|
||||
|
||||
expect(store.getState().projects).toEqual([project])
|
||||
expect(store.getState().projectHostSetups).toEqual([])
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'projectHostSetup.delete',
|
||||
params: { setupId: runtimeSetup.id },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,10 @@ const reposCloneRemote = vi.fn()
|
||||
const reposRemove = vi.fn()
|
||||
const reposUpdate = vi.fn()
|
||||
const reposReorder = vi.fn()
|
||||
const projectsCreateHostSetup = vi.fn()
|
||||
const projectsSetupExistingFolder = vi.fn()
|
||||
const projectsUpdateHostSetup = vi.fn()
|
||||
const projectsDeleteHostSetup = vi.fn()
|
||||
const projectGroupsMoveProject = vi.fn()
|
||||
const ptyKill = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
@@ -57,7 +60,10 @@ beforeEach(() => {
|
||||
reposRemove.mockReset()
|
||||
reposUpdate.mockReset()
|
||||
reposReorder.mockReset()
|
||||
projectsCreateHostSetup.mockReset()
|
||||
projectsSetupExistingFolder.mockReset()
|
||||
projectsUpdateHostSetup.mockReset()
|
||||
projectsDeleteHostSetup.mockReset()
|
||||
projectGroupsMoveProject.mockReset()
|
||||
ptyKill.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
@@ -78,7 +84,10 @@ beforeEach(() => {
|
||||
reorder: reposReorder
|
||||
},
|
||||
projects: {
|
||||
setupExistingFolder: projectsSetupExistingFolder
|
||||
createHostSetup: projectsCreateHostSetup,
|
||||
setupExistingFolder: projectsSetupExistingFolder,
|
||||
updateHostSetup: projectsUpdateHostSetup,
|
||||
deleteHostSetup: projectsDeleteHostSetup
|
||||
},
|
||||
projectGroups: {
|
||||
moveProject: projectGroupsMoveProject
|
||||
@@ -130,27 +139,24 @@ describe('repo slice runtime routing', () => {
|
||||
}
|
||||
const projectsList = vi.fn().mockResolvedValue([project])
|
||||
const listHostSetups = vi.fn().mockResolvedValue([setup])
|
||||
const createHostSetup = vi.fn()
|
||||
const updateHostSetup = vi.fn()
|
||||
const deleteHostSetup = vi.fn()
|
||||
;(
|
||||
window.api as typeof window.api & {
|
||||
projects?: {
|
||||
list: typeof projectsList
|
||||
listHostSetups: typeof listHostSetups
|
||||
createHostSetup: typeof createHostSetup
|
||||
createHostSetup: typeof projectsCreateHostSetup
|
||||
setupExistingFolder: typeof projectsSetupExistingFolder
|
||||
updateHostSetup: typeof updateHostSetup
|
||||
deleteHostSetup: typeof deleteHostSetup
|
||||
updateHostSetup: typeof projectsUpdateHostSetup
|
||||
deleteHostSetup: typeof projectsDeleteHostSetup
|
||||
}
|
||||
}
|
||||
).projects = {
|
||||
list: projectsList,
|
||||
listHostSetups,
|
||||
createHostSetup,
|
||||
createHostSetup: projectsCreateHostSetup,
|
||||
setupExistingFolder: projectsSetupExistingFolder,
|
||||
updateHostSetup,
|
||||
deleteHostSetup
|
||||
updateHostSetup: projectsUpdateHostSetup,
|
||||
deleteHostSetup: projectsDeleteHostSetup
|
||||
}
|
||||
reposList.mockResolvedValue([localRepo])
|
||||
const store = createTestStore()
|
||||
|
||||
@@ -13,8 +13,14 @@ import type {
|
||||
ProjectGroupImportResult,
|
||||
NestedRepoScanResult,
|
||||
ProjectHostSetupCloneArgs,
|
||||
ProjectHostSetupCreateArgs,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteArgs,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult
|
||||
ProjectHostSetupResult,
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
projectHostSetupProjectionFromRepos,
|
||||
@@ -299,6 +305,15 @@ export type RepoSlice = {
|
||||
setupProjectExistingFolder: (
|
||||
args: ProjectHostSetupExistingFolderArgs
|
||||
) => Promise<ProjectHostSetupResult | null>
|
||||
createProjectHostSetup: (
|
||||
args: ProjectHostSetupCreateArgs
|
||||
) => Promise<ProjectHostSetupCreateResult | null>
|
||||
updateProjectHostSetup: (
|
||||
args: ProjectHostSetupUpdateArgs
|
||||
) => Promise<ProjectHostSetupUpdateResult | null>
|
||||
deleteProjectHostSetup: (
|
||||
args: ProjectHostSetupDeleteArgs
|
||||
) => Promise<ProjectHostSetupDeleteResult | null>
|
||||
setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise<ProjectHostSetupResult | null>
|
||||
addNonGitFolder: (path: string) => Promise<Repo | null>
|
||||
scanNestedRepos: (
|
||||
@@ -737,6 +752,129 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
createProjectHostSetup: async (args) => {
|
||||
try {
|
||||
const target = getProjectSetupRuntimeTarget(args.hostId)
|
||||
await assertProjectHostSetupRuntimeCapability(target)
|
||||
const result =
|
||||
target.kind === 'local'
|
||||
? await window.api.projects.createHostSetup(args)
|
||||
: (
|
||||
await callRuntimeRpc<{ result: ProjectHostSetupCreateResult }>(
|
||||
target,
|
||||
'projectHostSetup.create',
|
||||
args,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).result
|
||||
const setup = setupWithFetchedOwner(result.setup, target)
|
||||
set((s) => ({
|
||||
projects: s.projects.some((entry) => entry.id === result.project.id)
|
||||
? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry))
|
||||
: [...s.projects, result.project],
|
||||
projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id)
|
||||
? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry))
|
||||
: [...s.projectHostSetups, setup]
|
||||
}))
|
||||
return { project: result.project, setup }
|
||||
} catch (err) {
|
||||
console.error('Failed to create project host setup:', err)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), {
|
||||
description: message,
|
||||
duration: ERROR_TOAST_DURATION
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectHostSetup: async (args) => {
|
||||
try {
|
||||
const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId)
|
||||
const target = currentSetup
|
||||
? getProjectSetupRuntimeTarget(currentSetup.hostId)
|
||||
: { kind: 'local' as const }
|
||||
await assertProjectHostSetupRuntimeCapability(target)
|
||||
const result =
|
||||
target.kind === 'local'
|
||||
? await window.api.projects.updateHostSetup(args)
|
||||
: (
|
||||
await callRuntimeRpc<{ result: ProjectHostSetupUpdateResult }>(
|
||||
target,
|
||||
'projectHostSetup.update',
|
||||
args,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).result
|
||||
const setup = setupWithFetchedOwner(result.setup, target)
|
||||
const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined
|
||||
set((s) => ({
|
||||
repos: repo
|
||||
? s.repos.some((entry) => entry.id === repo.id)
|
||||
? s.repos.map((entry) => (entry.id === repo.id ? repo : entry))
|
||||
: [...s.repos, repo]
|
||||
: s.repos,
|
||||
projects: s.projects.some((entry) => entry.id === result.project.id)
|
||||
? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry))
|
||||
: [...s.projects, result.project],
|
||||
projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id)
|
||||
? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry))
|
||||
: [...s.projectHostSetups, setup]
|
||||
}))
|
||||
return { ...result, repo, setup }
|
||||
} catch (err) {
|
||||
console.error('Failed to update project host setup:', err)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), {
|
||||
description: message,
|
||||
duration: ERROR_TOAST_DURATION
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
deleteProjectHostSetup: async (args) => {
|
||||
try {
|
||||
const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId)
|
||||
const target = currentSetup
|
||||
? getProjectSetupRuntimeTarget(currentSetup.hostId)
|
||||
: { kind: 'local' as const }
|
||||
await assertProjectHostSetupRuntimeCapability(target)
|
||||
const result =
|
||||
target.kind === 'local'
|
||||
? await window.api.projects.deleteHostSetup(args)
|
||||
: (
|
||||
await callRuntimeRpc<{ result: ProjectHostSetupDeleteResult }>(
|
||||
target,
|
||||
'projectHostSetup.delete',
|
||||
args,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).result
|
||||
const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined
|
||||
set((s) => {
|
||||
const projectHostSetups = s.projectHostSetups.filter(
|
||||
(setup) => setup.id !== result.setup.id
|
||||
)
|
||||
const repos = repo ? s.repos.filter((entry) => entry.id !== repo.id) : s.repos
|
||||
const projects =
|
||||
repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id)
|
||||
? s.projects.filter((project) => project.id !== result.project.id)
|
||||
: s.projects
|
||||
return { repos, projects, projectHostSetups }
|
||||
})
|
||||
return { ...result, repo }
|
||||
} catch (err) {
|
||||
console.error('Failed to delete project host setup:', err)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), {
|
||||
description: message,
|
||||
duration: ERROR_TOAST_DURATION
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
setupProjectClone: async (args) => {
|
||||
try {
|
||||
const parsedHost = parseExecutionHostId(args.hostId)
|
||||
|
||||
Reference in New Issue
Block a user