From 88fb23fa152f8c7bd4a7b82d5192fe0a76be9a46 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 10 Jun 2026 19:40:16 -0700 Subject: [PATCH] Expose project host setup lifecycle in renderer store Co-authored-by: Orca --- ...oject-first-host-model-change-inventory.md | 2 + .../project-first-host-model-conversation.md | 2 + notes/project-first-host-model-handoff.md | 2 + notes/project-first-host-model.md | 8 +- .../repos-project-host-lifecycle.test.ts | 163 ++++++++++++++++++ src/renderer/src/store/slices/repos.test.ts | 26 +-- src/renderer/src/store/slices/repos.ts | 140 ++++++++++++++- 7 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index 270d1689efb..9447f1e7461 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -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: diff --git a/notes/project-first-host-model-conversation.md b/notes/project-first-host-model-conversation.md index d7241afdd62..bfffe00f8a8 100644 --- a/notes/project-first-host-model-conversation.md +++ b/notes/project-first-host-model-conversation.md @@ -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: diff --git a/notes/project-first-host-model-handoff.md b/notes/project-first-host-model-handoff.md index 7a615c05072..70dba30246b 100644 --- a/notes/project-first-host-model-handoff.md +++ b/notes/project-first-host-model-handoff.md @@ -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: diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index d6cd3519afe..283bcfd8ed9 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -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: diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts new file mode 100644 index 00000000000..43ae830eb44 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 97996e31a67..0572187df52 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index e642fd426d3..de7642a1b3a 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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 + createProjectHostSetup: ( + args: ProjectHostSetupCreateArgs + ) => Promise + updateProjectHostSetup: ( + args: ProjectHostSetupUpdateArgs + ) => Promise + deleteProjectHostSetup: ( + args: ProjectHostSetupDeleteArgs + ) => Promise setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise addNonGitFolder: (path: string) => Promise scanNestedRepos: ( @@ -737,6 +752,129 @@ export const createRepoSlice: StateCreator = (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)