Expose project host setup lifecycle in renderer store

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 c622bd746f
commit 88fb23fa15
7 changed files with 330 additions and 13 deletions
@@ -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:
+6 -2
View File
@@ -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
})
})
})
+16 -10
View File
@@ -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()
+139 -1
View File
@@ -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)