From ab1b83d20987223bb333349e9d42fe5b471efafe Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Sat, 6 Jun 2026 14:20:44 +0000 Subject: [PATCH 1/3] assistant-api + ui: manage a workspace's repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace could only ever clone one repository: the workspace_repositories junction, the attach/detach commands, and the REPO_URLS orchestrator injection all existed (#590) but nothing populated the junction or exposed it, so multi-repo workspaces were unreachable and invisible. Backend: - CreateWorkspaceCommandHandler seeds workspace_repositories when a repo-backed workspace is created: the workspace's own repository as is_primary=true, and — when a projectId is present — the project's linked repositories as additional members, so the orchestrator emits REPO_URLS and the runner clones them all. - POST /api/v1/workspaces/{id}/repositories and DELETE /api/v1/workspaces/{id}/repositories/{repositoryId} dispatch the existing attach/detach commands (204). - GET /api/v1/workspaces/{id} now returns the workspace's repositories (id, name, repoUrl, isPrimary) via a detail envelope; list/create keep the existing summary response shape. Frontend: - The workspace view shows its repositories (primary marked), adds/removes repositories through the new endpoints, and a split-guidance panel assembles the `council split` command for extracting a path into a new repo and explains registering + linking it afterward. Built via the council with codex (planners, consolidator, workers, verifier). The OpenAPI contract changes (new endpoints + repositories field), so services/assistant-api/openapi.json and services/assistant-ui/src/api/generated.ts must be regenerated by the operator with `./gradlew :services:assistant-api:exportOpenApiSpec` and `pnpm --filter @personal-stack/assistant-ui contract:generate` — not hand-edited. --- .../WorkspaceRepositoryFlowIntegrationTest.kt | 197 ++++++++++++++++++ .../command/CreateWorkspaceCommandHandler.kt | 24 +++ .../query/GetWorkspaceQueryService.kt | 19 +- .../infrastructure/web/WorkspaceController.kt | 42 +++- .../infrastructure/web/dto/WorkspaceDtos.kt | 92 ++++++++ .../CreateWorkspaceCommandHandlerTest.kt | 137 +++++++++++- .../query/GetWorkspaceQueryServiceTest.kt | 118 +++++++++++ .../web/WorkspaceControllerTest.kt | 83 +++++++- .../__tests__/WorkspaceView.test.ts | 149 ++++++++++++- .../__tests__/workspaces.store.test.ts | 85 +++++++- .../components/WorkspaceRepositoriesPanel.vue | 99 +++++++++ .../components/WorkspaceRepositoryPicker.vue | 105 ++++++++++ .../components/WorkspaceSplitGuidance.vue | 111 ++++++++++ .../workspaces/services/workspaceService.ts | 10 +- .../features/workspaces/stores/workspaces.ts | 60 ++++-- .../src/features/workspaces/types/index.ts | 29 ++- .../workspaces/views/WorkspaceView.vue | 92 ++++++-- 17 files changed, 1398 insertions(+), 54 deletions(-) create mode 100644 services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt create mode 100644 services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt create mode 100644 services/assistant-ui/src/features/workspaces/components/WorkspaceRepositoriesPanel.vue create mode 100644 services/assistant-ui/src/features/workspaces/components/WorkspaceRepositoryPicker.vue create mode 100644 services/assistant-ui/src/features/workspaces/components/WorkspaceSplitGuidance.vue diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt new file mode 100644 index 00000000..e5569006 --- /dev/null +++ b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt @@ -0,0 +1,197 @@ +package com.jorisjonkers.personalstack.assistant.flow + +import com.fasterxml.jackson.databind.ObjectMapper +import com.jorisjonkers.personalstack.assistant.IntegrationTestBase +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.WebApplicationContext +import java.util.UUID + +@ActiveProfiles("system-test") +class WorkspaceRepositoryFlowIntegrationTest : IntegrationTestBase() { + @Autowired + private lateinit var webApplicationContext: WebApplicationContext + + private lateinit var mockMvc: MockMvc + private val objectMapper = ObjectMapper() + + @BeforeEach + fun setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build() + } + + @Test + fun `project workspace creation surfaces primary and project repository extras in detail`() { + val unique = unique() + val projectId = createProject(unique) + val primaryId = createRepository("primary-$unique") + val extraOneId = createRepository("extra-one-$unique") + val extraTwoId = createRepository("extra-two-$unique") + linkRepository(projectId, extraOneId) + linkRepository(projectId, extraTwoId) + + val workspaceId = + createWorkspace( + name = "workspace-$unique", + projectId = projectId, + repositoryId = primaryId, + ) + + val detail = getWorkspaceDetail(workspaceId) + val repositories = detail["workspace"]["repositories"] + assertThat(repositories.map { it["id"].asText() }) + .containsExactlyInAnyOrder(primaryId, extraOneId, extraTwoId) + assertThat(repositories.map { it["name"].asText() }) + .containsExactlyInAnyOrder("primary-$unique", "extra-one-$unique", "extra-two-$unique") + } + + @Test + fun `attach and detach endpoints mutate workspace detail repositories`() { + val unique = unique() + val primaryId = createRepository("primary-$unique") + val extraId = createRepository("extra-$unique") + val workspaceId = createWorkspace(name = "workspace-$unique", repositoryId = primaryId) + + assertWorkspaceRepositories(workspaceId, primaryId) + + mockMvc + .perform( + post("/api/v1/workspaces/$workspaceId/repositories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("repositoryId" to extraId))), + ).andExpect(status().isNoContent) + + assertWorkspaceRepositories(workspaceId, primaryId, extraId) + + mockMvc + .perform(delete("/api/v1/workspaces/$workspaceId/repositories/$extraId")) + .andExpect(status().isNoContent) + + assertWorkspaceRepositories(workspaceId, primaryId) + } + + @Test + fun `detaching the primary repository returns the validation error`() { + val unique = unique() + val primaryId = createRepository("primary-$unique") + val workspaceId = createWorkspace(name = "workspace-$unique", repositoryId = primaryId) + + mockMvc + .perform(delete("/api/v1/workspaces/$workspaceId/repositories/$primaryId")) + .andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.title").value("Bad Request")) + .andExpect(jsonPath("$.detail").value("cannot detach the primary repository from a workspace")) + + assertWorkspaceRepositories(workspaceId, primaryId) + } + + private fun createProject(unique: String): String { + val result = + mockMvc + .perform( + post("/api/v1/projects") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "name" to "Project $unique", + "slug" to "project-$unique", + ), + ), + ), + ).andExpect(status().isCreated) + .andReturn() + return objectMapper.readTree(result.response.contentAsString)["id"].asText() + } + + private fun createRepository(name: String): String { + val result = + mockMvc + .perform( + post("/api/v1/repositories") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "name" to name, + "repoUrl" to "git@github.com:o/$name.git", + "defaultBranch" to "main", + ), + ), + ), + ).andExpect(status().isCreated) + .andReturn() + return objectMapper.readTree(result.response.contentAsString)["id"].asText() + } + + private fun linkRepository( + projectId: String, + repositoryId: String, + ) { + mockMvc + .perform( + post("/api/v1/projects/$projectId/repositories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("repositoryId" to repositoryId))), + ).andExpect(status().isCreated) + } + + private fun createWorkspace( + name: String, + repositoryId: String, + projectId: String? = null, + ): String { + val body = + mutableMapOf( + "name" to name, + "kind" to "REPO_BACKED", + "repositoryId" to repositoryId, + ) + if (projectId != null) { + body["projectId"] = projectId + } + val result = + mockMvc + .perform( + post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.repositoryId").value(repositoryId)) + .andReturn() + return objectMapper.readTree(result.response.contentAsString)["id"].asText() + } + + private fun getWorkspaceDetail(workspaceId: String) = + mockMvc + .perform(get("/api/v1/workspaces/$workspaceId")) + .andExpect(status().isOk) + .andReturn() + .let { objectMapper.readTree(it.response.contentAsString) } + + private fun assertWorkspaceRepositories( + workspaceId: String, + vararg expectedRepositoryIds: String, + ) { + val repositoryIds = + getWorkspaceDetail(workspaceId)["workspace"]["repositories"] + .map { it["id"].asText() } + assertThat(repositoryIds).containsExactlyInAnyOrder(*expectedRepositoryIds) + } + + private fun unique(): String = UUID.randomUUID().toString().take(8) +} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt index b0bb1844..ddc9919e 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt @@ -9,8 +9,10 @@ import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository +import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository import com.jorisjonkers.personalstack.common.command.CommandHandler import org.slf4j.LoggerFactory import org.springframework.beans.factory.ObjectProvider @@ -38,6 +40,8 @@ import java.time.Instant class CreateWorkspaceCommandHandler( private val workspaces: WorkspaceRepository, private val orchestrator: AgentRunnerOrchestrator, + private val projectRepositories: ProjectRepositoryRepository, + private val workspaceRepositories: WorkspaceRepositoryRepository, /** * Optional — present only when the Projects feature is wired * (Vault enabled). When absent, every CreateWorkspace must @@ -77,6 +81,7 @@ class CreateWorkspaceCommandHandler( verifyOrFail(resolved.repoUrl, resolved.branch, resolved.repositoryId) } val workspace = persistInitial(command, resolved) + seedRepositoryMembership(workspace) val withPod = provisionAndUpdate(workspace) log.info("workspace {} provisioned as pod {}", workspace.id, withPod.podName) } @@ -132,6 +137,25 @@ class CreateWorkspaceCommandHandler( return workspace } + private fun seedRepositoryMembership(workspace: Workspace) { + val repoId = workspace.repositoryId ?: return + val realPrimaryRepoId = + repositories + .ifAvailable + ?.findById(repoId) + ?.id + ?: return + + workspaceRepositories.attach(workspace.id, realPrimaryRepoId, isPrimary = true) + workspace.projectId + ?.let { projectRepositories.findAllByProjectId(it) } + .orEmpty() + .map { it.repositoryId } + .filterNot { it == realPrimaryRepoId } + .distinct() + .forEach { workspaceRepositories.attach(workspace.id, it, isPrimary = false) } + } + private fun provisionAndUpdate(workspace: Workspace): Workspace { val handle = orchestrator.provision(workspace) val withPod = diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt index 855db20c..adb92b39 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt @@ -1,24 +1,41 @@ package com.jorisjonkers.personalstack.assistant.application.query +import com.jorisjonkers.personalstack.assistant.domain.model.Repository import com.jorisjonkers.personalstack.assistant.domain.model.Workspace import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository import org.springframework.stereotype.Service @Service class GetWorkspaceQueryService( private val workspaces: WorkspaceRepository, private val sessions: WorkspaceAgentSessionRepository, + private val workspaceRepositories: WorkspaceRepositoryRepository, + private val repositories: RepositoryRepository, ) { data class WorkspaceView( val workspace: Workspace, val sessions: List, + val repositories: List, ) + fun getSummary(id: WorkspaceId): Workspace? = workspaces.findById(id) + fun get(id: WorkspaceId): WorkspaceView? { val workspace = workspaces.findById(id) ?: return null - return WorkspaceView(workspace, sessions.findAllByWorkspaceId(id)) + val resolvedRepositories = + workspaceRepositories + .findAllByWorkspaceId(id) + .map { link -> + repositories.findById(link.repositoryId) + ?: throw IllegalStateException( + "Workspace ${id.value} references missing repository ${link.repositoryId.value}", + ) + } + return WorkspaceView(workspace, sessions.findAllByWorkspaceId(id), resolvedRepositories) } } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt index f536cb1b..0f542acb 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt @@ -1,15 +1,18 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.web +import com.jorisjonkers.personalstack.assistant.application.command.AttachWorkspaceRepositoryCommand import com.jorisjonkers.personalstack.assistant.application.command.CreateWorkspaceCommand import com.jorisjonkers.personalstack.assistant.application.command.DestroyWorkspaceCommand +import com.jorisjonkers.personalstack.assistant.application.command.DetachWorkspaceRepositoryCommand import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService import com.jorisjonkers.personalstack.assistant.application.query.ListWorkspacesQueryService import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.AttachWorkspaceRepositoryRequest import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.CreateWorkspaceRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.WorkspaceAgentSessionResponse +import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.WorkspaceDetailResponse import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.WorkspaceResponse import com.jorisjonkers.personalstack.common.command.CommandBus import jakarta.validation.Valid @@ -50,11 +53,11 @@ class WorkspaceController( ), ) val view = - getQuery.get(id) + getQuery.getSummary(id) ?: throw IllegalStateException( "Workspace $id was created but not yet visible to the read model; retry the GET in a moment", ) - return ResponseEntity.status(HttpStatus.CREATED).body(WorkspaceResponse.of(view.workspace)) + return ResponseEntity.status(HttpStatus.CREATED).body(WorkspaceResponse.of(view)) } @GetMapping @@ -63,14 +66,37 @@ class WorkspaceController( @GetMapping("/{id}") fun get( @PathVariable id: UUID, - ): ResponseEntity> { + ): ResponseEntity { val view = getQuery.get(WorkspaceId(id)) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok( - mapOf( - "workspace" to WorkspaceResponse.of(view.workspace), - "sessions" to view.sessions.map(WorkspaceAgentSessionResponse::of), + return ResponseEntity.ok(WorkspaceDetailResponse.of(view.workspace, view.repositories, view.sessions)) + } + + @PostMapping("/{id}/repositories") + fun attachRepository( + @PathVariable id: UUID, + @Valid @RequestBody req: AttachWorkspaceRepositoryRequest, + ): ResponseEntity { + commandBus.dispatch( + AttachWorkspaceRepositoryCommand( + workspaceId = WorkspaceId(id), + repositoryId = RepositoryId(requireNotNull(req.repositoryId)), + ), + ) + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/{id}/repositories/{repoId}") + fun detachRepository( + @PathVariable id: UUID, + @PathVariable repoId: UUID, + ): ResponseEntity { + commandBus.dispatch( + DetachWorkspaceRepositoryCommand( + workspaceId = WorkspaceId(id), + repositoryId = RepositoryId(repoId), ), ) + return ResponseEntity.noContent().build() } @DeleteMapping("/{id}") diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt index 2ab43823..67daca1f 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt @@ -1,11 +1,13 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto +import com.jorisjonkers.personalstack.assistant.domain.model.Repository import com.jorisjonkers.personalstack.assistant.domain.model.Turn import com.jorisjonkers.personalstack.assistant.domain.model.Workspace import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size import java.time.Instant import java.util.UUID @@ -41,6 +43,11 @@ data class CreateWorkspaceRequest( val githubLinkId: UUID? = null, ) +data class AttachWorkspaceRepositoryRequest( + @field:NotNull + val repositoryId: UUID? = null, +) + @Suppress("DEPRECATION") data class WorkspaceResponse( val id: UUID, @@ -77,6 +84,91 @@ data class WorkspaceResponse( } } +data class WorkspaceRepositoryResponse( + val id: UUID, + val name: String, + val repoUrl: String, + val defaultBranch: String, + val vaultKeyPath: String, + val deployKeyFingerprint: String?, + val deployKeyAddedAt: Instant?, + val createdAt: Instant, + val updatedAt: Instant, + val verification: AccessVerificationResponse?, +) { + companion object { + fun of(r: Repository) = + WorkspaceRepositoryResponse( + id = r.id.value, + name = r.name, + repoUrl = r.repoUrl, + defaultBranch = r.defaultBranch, + vaultKeyPath = r.vaultKeyPath, + deployKeyFingerprint = r.deployKeyFingerprint, + deployKeyAddedAt = r.deployKeyAddedAt, + createdAt = r.createdAt, + updatedAt = r.updatedAt, + verification = r.verification?.let(AccessVerificationResponse::of), + ) + } +} + +@Suppress("DEPRECATION") +data class WorkspaceWithRepositoriesResponse( + val id: UUID, + val name: String, + val repoUrl: String?, + val branch: String?, + val podName: String?, + val gatewayEndpoint: String?, + val status: String, + val kind: String, + val projectId: UUID?, + val repositoryId: UUID?, + val githubLinkId: UUID?, + val repositories: List, + val createdAt: Instant, + val updatedAt: Instant, +) { + companion object { + fun of( + w: Workspace, + repositories: List, + ) = WorkspaceWithRepositoriesResponse( + id = w.id.value, + name = w.name, + repoUrl = w.repoUrl, + branch = w.branch, + podName = w.podName, + gatewayEndpoint = w.gatewayEndpoint, + status = w.status.name, + kind = w.kind.name, + projectId = w.projectId?.value, + repositoryId = w.repositoryId?.value, + githubLinkId = w.githubLinkId?.value, + repositories = repositories.map(WorkspaceRepositoryResponse::of), + createdAt = w.createdAt, + updatedAt = w.updatedAt, + ) + } +} + +data class WorkspaceDetailResponse( + val workspace: WorkspaceWithRepositoriesResponse, + val sessions: List, +) { + companion object { + fun of( + workspace: Workspace, + repositories: List, + sessions: List, + ) = WorkspaceDetailResponse( + workspace = WorkspaceWithRepositoriesResponse.of(workspace, repositories), + sessions = sessions.map(WorkspaceAgentSessionResponse::of), + ) + } +} + data class StartAgentSessionRequest( val kind: WorkspaceAgentKind, ) diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt index 3093ea7d..c601db88 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt @@ -10,14 +10,18 @@ import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId import com.jorisjonkers.personalstack.assistant.domain.model.Workspace import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository +import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import io.mockk.verifyOrder import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -28,12 +32,14 @@ import java.time.Instant class CreateWorkspaceCommandHandlerTest { private val workspaces = mockk() private val orchestrator = mockk() + private val projectRepositoryLinks = mockk(relaxed = true) + private val workspaceRepositoryLinks = mockk(relaxed = true) private val githubLinks = mockk() private val linkProvider = mockk> { every { ifAvailable } returns githubLinks } - private val repositories = mockk() + private val repositories = mockk(relaxed = true) private val repositoryProvider = mockk> { every { ifAvailable } returns repositories @@ -55,6 +61,8 @@ class CreateWorkspaceCommandHandlerTest { CreateWorkspaceCommandHandler( workspaces, orchestrator, + projectRepositoryLinks, + workspaceRepositoryLinks, linkProvider, repositoryProvider, verifyAccess, @@ -83,6 +91,7 @@ class CreateWorkspaceCommandHandlerTest { verify { orchestrator.provision(any()) } verify(exactly = 2) { workspaces.save(any()) } + verify(exactly = 0) { workspaceRepositoryLinks.attach(any(), any(), any()) } } @Test @@ -197,6 +206,96 @@ class CreateWorkspaceCommandHandlerTest { assertThat(saved.first().githubLinkId?.value).isEqualTo(repoId.value) } + @Test + fun `repositoryId create persists then attaches primary before provisioning`() { + val repoId = RepositoryId.random() + val repository = + Repository( + id = repoId, + name = "personal-stack", + repoUrl = "git@github.com:ExtraToast/personal-stack.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/repositories/$repoId", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + every { repositories.findById(repoId) } returns repository + every { githubLinks.findById(GithubLinkId(repoId.value)) } returns null + every { workspaces.save(any()) } answers { firstArg() } + every { orchestrator.provision(any()) } returns + AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") + val workspaceId = WorkspaceId.random() + + handler.handle( + CreateWorkspaceCommand( + workspaceId = workspaceId, + name = "demo", + repoUrl = null, + branch = null, + repositoryId = repoId, + ), + ) + + verifyOrder { + workspaces.save(match { it.id == workspaceId && it.status == WorkspaceStatus.PENDING }) + workspaceRepositoryLinks.attach(workspaceId, repoId, isPrimary = true) + orchestrator.provision(match { it.id == workspaceId }) + } + } + + @Test + fun `project repositoryId create attaches primary and project extras before provisioning`() { + val projectId = ProjectId.random() + val primaryRepoId = RepositoryId.random() + val extraRepoId = RepositoryId.random() + val secondExtraRepoId = RepositoryId.random() + val repository = + Repository( + id = primaryRepoId, + name = "personal-stack", + repoUrl = "git@github.com:ExtraToast/personal-stack.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/repositories/$primaryRepoId", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + every { repositories.findById(primaryRepoId) } returns repository + every { githubLinks.findById(GithubLinkId(primaryRepoId.value)) } returns null + every { projectRepositoryLinks.findAllByProjectId(projectId) } returns + listOf( + ProjectRepositoryRepository.Link(projectId, primaryRepoId, Instant.now()), + ProjectRepositoryRepository.Link(projectId, extraRepoId, Instant.now()), + ProjectRepositoryRepository.Link(projectId, secondExtraRepoId, Instant.now()), + ) + every { workspaces.save(any()) } answers { firstArg() } + every { orchestrator.provision(any()) } returns + AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") + val workspaceId = WorkspaceId.random() + + handler.handle( + CreateWorkspaceCommand( + workspaceId = workspaceId, + name = "demo", + repoUrl = null, + branch = null, + projectId = projectId, + repositoryId = primaryRepoId, + ), + ) + + verifyOrder { + workspaces.save(match { it.id == workspaceId && it.status == WorkspaceStatus.PENDING }) + workspaceRepositoryLinks.attach(workspaceId, primaryRepoId, isPrimary = true) + workspaceRepositoryLinks.attach(workspaceId, extraRepoId, isPrimary = false) + workspaceRepositoryLinks.attach(workspaceId, secondExtraRepoId, isPrimary = false) + orchestrator.provision(match { it.id == workspaceId }) + } + } + @Test fun `handle leaves legacy linkId null when no github_links mirror exists`() { // Regression for the production workspace 500: post-V9 @@ -242,6 +341,41 @@ class CreateWorkspaceCommandHandlerTest { assertThat(saved.first().githubLinkId).isNull() } + @Test + fun `legacy githubLinkId create does not attach a synthetic repository id`() { + val linkId = GithubLinkId.random() + val link = + GithubLink( + id = linkId, + projectId = ProjectId.random(), + name = "personal-stack", + repoUrl = "git@github.com:ExtraToast/personal-stack.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/projects/x/repos/y", + deployKeyFingerprint = "SHA256:abcd", + deployKeyAddedAt = Instant.now(), + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + every { githubLinks.findById(linkId) } returns link + every { repositories.findById(RepositoryId(linkId.value)) } returns null + every { workspaces.save(any()) } answers { firstArg() } + every { orchestrator.provision(any()) } returns + AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") + + handler.handle( + CreateWorkspaceCommand( + workspaceId = WorkspaceId.random(), + name = "demo", + repoUrl = null, + branch = null, + githubLinkId = linkId, + ), + ) + + verify(exactly = 0) { workspaceRepositoryLinks.attach(any(), any(), any()) } + } + @Test fun `handle SCRATCH kind ignores repoUrl`() { every { workspaces.save(any()) } answers { firstArg() } @@ -263,6 +397,7 @@ class CreateWorkspaceCommandHandlerTest { verify { orchestrator.provision(any()) } assertThat(saved.first().repoUrl).isNull() assertThat(saved.first().kind).isEqualTo(WorkspaceKind.SCRATCH) + verify(exactly = 0) { workspaceRepositoryLinks.attach(any(), any(), any()) } } @Test diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt new file mode 100644 index 00000000..74b56b2f --- /dev/null +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt @@ -0,0 +1,118 @@ +package com.jorisjonkers.personalstack.assistant.application.query + +import com.jorisjonkers.personalstack.assistant.domain.model.Repository +import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId +import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus +import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import java.time.Instant + +class GetWorkspaceQueryServiceTest { + private val workspaces = mockk() + private val sessions = mockk() + private val workspaceRepositories = mockk() + private val repositories = mockk() + private val service = GetWorkspaceQueryService(workspaces, sessions, workspaceRepositories, repositories) + + @Test + fun `getSummary returns workspace without enriching detail`() { + val w = workspace() + every { workspaces.findById(w.id) } returns w + + val result = service.getSummary(w.id) + + assertThat(result).isEqualTo(w) + verify(exactly = 0) { sessions.findAllByWorkspaceId(any()) } + verify(exactly = 0) { workspaceRepositories.findAllByWorkspaceId(any()) } + verify(exactly = 0) { repositories.findById(any()) } + } + + @Test + fun `get returns workspace enriched with sessions and repositories`() { + val w = workspace() + val r1 = repository() + val r2 = repository(name = "docs") + val now = Instant.now() + every { workspaces.findById(w.id) } returns w + every { sessions.findAllByWorkspaceId(w.id) } returns emptyList() + every { workspaceRepositories.findAllByWorkspaceId(w.id) } returns + listOf( + WorkspaceRepositoryRepository.Link(w.id, r1.id, isPrimary = true, now), + WorkspaceRepositoryRepository.Link(w.id, r2.id, isPrimary = false, now), + ) + every { repositories.findById(r1.id) } returns r1 + every { repositories.findById(r2.id) } returns r2 + + val result = service.get(w.id) ?: error("expected workspace detail") + + assertThat(result.workspace).isEqualTo(w) + assertThat(result.sessions).isEmpty() + assertThat(result.repositories).containsExactly(r1, r2) + } + + @Test + fun `get returns null when workspace is absent`() { + val workspaceId = WorkspaceId.random() + every { workspaces.findById(workspaceId) } returns null + + val result = service.get(workspaceId) + + assertThat(result).isNull() + verify(exactly = 0) { sessions.findAllByWorkspaceId(any()) } + verify(exactly = 0) { workspaceRepositories.findAllByWorkspaceId(any()) } + verify(exactly = 0) { repositories.findById(any()) } + } + + @Test + fun `get fails fast when a workspace repository link dangles`() { + val w = workspace() + val missingRepositoryId = RepositoryId.random() + every { workspaces.findById(w.id) } returns w + every { workspaceRepositories.findAllByWorkspaceId(w.id) } returns + listOf(WorkspaceRepositoryRepository.Link(w.id, missingRepositoryId, isPrimary = false, Instant.now())) + every { repositories.findById(missingRepositoryId) } returns null + + assertThatThrownBy { service.get(w.id) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("references missing repository ${missingRepositoryId.value}") + } + + private fun workspace(id: WorkspaceId = WorkspaceId.random()) = + Workspace( + id = id, + name = "demo", + repoUrl = "git@github.com:owner/repo.git", + branch = "main", + podName = "pod", + pvcName = "pvc", + gatewayEndpoint = "http://endpoint:8090", + status = WorkspaceStatus.READY, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + + private fun repository( + id: RepositoryId = RepositoryId.random(), + name: String = "personal-stack", + ) = Repository( + id = id, + name = name, + repoUrl = "git@github.com:owner/$name.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/repositories/${id.value}", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) +} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt index 7e3db2d7..3eeddb13 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt @@ -1,8 +1,11 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.web import com.fasterxml.jackson.databind.ObjectMapper +import com.jorisjonkers.personalstack.assistant.application.command.AttachWorkspaceRepositoryCommand +import com.jorisjonkers.personalstack.assistant.application.command.DetachWorkspaceRepositoryCommand import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService import com.jorisjonkers.personalstack.assistant.application.query.ListWorkspacesQueryService +import com.jorisjonkers.personalstack.assistant.domain.model.Repository import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId import com.jorisjonkers.personalstack.assistant.domain.model.Workspace import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId @@ -66,12 +69,25 @@ class WorkspaceControllerTest { ) } + private fun repository(id: RepositoryId = RepositoryId.random()) = + Repository( + id = id, + name = "personal-stack", + repoUrl = "git@github.com:owner/personal-stack.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/repositories/${id.value}", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + @Test fun `POST creates a workspace and returns 201 with the new shape`() { val w = workspace(kind = WorkspaceKind.REPO_BACKED, repositoryId = RepositoryId.random()) every { - getQuery.get(any()) - } returns GetWorkspaceQueryService.WorkspaceView(w, emptyList()) + getQuery.getSummary(any()) + } returns w mockMvc .perform( @@ -89,6 +105,7 @@ class WorkspaceControllerTest { ).andExpect(status().isCreated) .andExpect(jsonPath("$.kind").value("REPO_BACKED")) .andExpect(jsonPath("$.status").value("READY")) + .andExpect(jsonPath("$.repositories").doesNotExist()) verify { commandBus.dispatch(any()) } } @@ -120,13 +137,16 @@ class WorkspaceControllerTest { } @Test - fun `GET by id returns workspace + sessions envelope`() { + fun `GET by id returns workspace detail with repositories under workspace`() { val w = workspace() - every { getQuery.get(w.id) } returns GetWorkspaceQueryService.WorkspaceView(w, emptyList()) + val r = repository() + every { getQuery.get(w.id) } returns GetWorkspaceQueryService.WorkspaceView(w, emptyList(), listOf(r)) mockMvc .perform(get("/api/v1/workspaces/${w.id.value}")) .andExpect(status().isOk) .andExpect(jsonPath("$.workspace.id").value(w.id.value.toString())) + .andExpect(jsonPath("$.workspace.repositories[0].id").value(r.id.value.toString())) + .andExpect(jsonPath("$.workspace.repositories[0].name").value("personal-stack")) .andExpect(jsonPath("$.sessions").isArray) } @@ -145,4 +165,59 @@ class WorkspaceControllerTest { .andExpect(status().isNoContent) verify { commandBus.dispatch(any()) } } + + @Test + fun `POST repositories attaches repository and returns 204`() { + val workspaceId = WorkspaceId.random() + val repositoryId = RepositoryId.random() + + mockMvc + .perform( + post("/api/v1/workspaces/${workspaceId.value}/repositories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("repositoryId" to repositoryId.value.toString()))), + ).andExpect(status().isNoContent) + + verify { + commandBus.dispatch( + match { + it.workspaceId == workspaceId && it.repositoryId == repositoryId + }, + ) + } + } + + @Test + fun `POST repositories rejects missing repositoryId`() { + mockMvc + .perform( + post("/api/v1/workspaces/${UUID.randomUUID()}/repositories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(emptyMap())), + ).andExpect { result -> + require(result.response.status in 400..499) { + "expected client error, got ${result.response.status}" + } + } + + verify(exactly = 0) { commandBus.dispatch(any()) } + } + + @Test + fun `DELETE repositories detaches repository and returns 204`() { + val workspaceId = WorkspaceId.random() + val repositoryId = RepositoryId.random() + + mockMvc + .perform(delete("/api/v1/workspaces/${workspaceId.value}/repositories/${repositoryId.value}")) + .andExpect(status().isNoContent) + + verify { + commandBus.dispatch( + match { + it.workspaceId == workspaceId && it.repositoryId == repositoryId + }, + ) + } + } } diff --git a/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts b/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts index 1937c37b..32d6a251 100644 --- a/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts +++ b/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts @@ -1,4 +1,5 @@ -import type { AgentSession, WorkspaceDetail } from '../types' +import type { AgentSession, WorkspaceDetail, WorkspaceRepository } from '../types' +import type { Repository } from '@/features/repositories' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -52,6 +53,8 @@ vi.mock('../services/sessionSocket', () => ({ })) const getWorkspace = vi.fn<(id: string) => Promise>() +const attachRepository = vi.fn() +const detachRepository = vi.fn() const sendInput = vi.fn() const stageInput = vi.fn() vi.mock('../services/workspaceService', () => ({ @@ -61,11 +64,23 @@ vi.mock('../services/workspaceService', () => ({ destroyWorkspace: vi.fn(), startSession: vi.fn(), stopSession: vi.fn(), + attachRepository: (...args: unknown[]) => attachRepository(...args), + detachRepository: (...args: unknown[]) => detachRepository(...args), getTurns: vi.fn(async () => []), sendInput: (...args: unknown[]) => sendInput(...args), stageInput: (...args: unknown[]) => stageInput(...args), })) +const listRepositories = vi.fn<() => Promise>() +vi.mock('@/features/repositories/services/repositoriesService', () => ({ + listRepositories: () => listRepositories(), + getRepository: vi.fn(), + createRepository: vi.fn(), + attachDeployKey: vi.fn(), + deleteRepository: vi.fn(), + verifyRepositoryAccess: vi.fn(), +})) + function fakeSession(over: Partial = {}): AgentSession { return { id: 'sess-a', @@ -79,7 +94,33 @@ function fakeSession(over: Partial = {}): AgentSession { } } -function detail(sessions: AgentSession[]): WorkspaceDetail { +function fakeRepository(over: Partial = {}): Repository { + return { + id: 'repo-primary', + name: 'primary', + repoUrl: 'git@github.com:owner/primary.git', + defaultBranch: 'main', + vaultKeyPath: 'secret/data/agents/repositories/repo-primary', + deployKeyFingerprint: 'SHA256:primary', + deployKeyAddedAt: '2026-05-19T10:00:00Z', + createdAt: '2026-05-19T10:00:00Z', + updatedAt: '2026-05-19T10:00:00Z', + ...over, + } +} + +function fakeWorkspaceRepository(over: Partial = {}): WorkspaceRepository { + const repo = fakeRepository(over) + return { + ...repo, + verification: null, + isPrimary: false, + attachedAt: '2026-05-20T10:00:00Z', + ...over, + } +} + +function detail(sessions: AgentSession[], workspace: Partial = {}): WorkspaceDetail { return { workspace: { id: 'ws-1', @@ -95,6 +136,8 @@ function detail(sessions: AgentSession[]): WorkspaceDetail { githubLinkId: null, createdAt: '2026-05-19T10:00:00Z', updatedAt: '2026-05-19T10:00:00Z', + repositories: [], + ...workspace, }, sessions, } @@ -104,6 +147,8 @@ const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/sessions', component: { template: '
' } }, + { path: '/repositories', component: { template: '
' } }, + { path: '/repositories/:id', component: { template: '
' } }, { path: '/workspaces/:id', component: WorkspaceView }, ], }) @@ -134,6 +179,10 @@ describe('workspaceView terminal persistence', () => { Object.values(socket).forEach((m) => m.mockClear()) attachSessionSocket.mockClear() getWorkspace.mockReset() + attachRepository.mockReset() + detachRepository.mockReset() + listRepositories.mockReset() + listRepositories.mockResolvedValue([]) sendInput.mockReset() stageInput.mockReset() vi.stubGlobal( @@ -213,6 +262,102 @@ describe('workspaceView terminal persistence', () => { ) expect(sendInput.mock.calls[0]?.[2]).not.toContain('large document') }) + + it('renders attached repositories, marks the primary, and shows split guidance', async () => { + const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true }) + const destination = fakeWorkspaceRepository({ + id: 'repo-dest', + name: 'split-dest', + repoUrl: 'git@github.com:owner/split-dest.git', + }) + getWorkspace.mockResolvedValue( + detail([fakeSession({ id: 'sess-a' })], { projectId: 'project-1', repositories: [primary, destination] }), + ) + + const wrapper = await mountView() + + expect(wrapper.find('[data-testid="workspace-repositories-panel"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="workspace-repository-primary-repo-primary"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="workspace-detach-repository-repo-primary"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="workspace-detach-repository-repo-dest"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="workspace-split-command"]').text()).toBe( + 'council split --path path/to/subtree --dest owner/split-dest', + ) + expect(wrapper.find('[data-testid="split-follow-up"]').text()).toContain( + 'Keep owner/split-dest linked in the project repository pool', + ) + expect(wrapper.find('[data-testid="split-follow-up"]').text()).toContain( + 'Start the next runner from owner/split-dest after the split lands.', + ) + }) + + it('shows non-project split follow-up wording', async () => { + const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true }) + const destination = fakeWorkspaceRepository({ + id: 'repo-dest', + name: 'split-dest', + repoUrl: 'git@github.com:owner/split-dest.git', + }) + getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a' })], { repositories: [primary, destination] })) + + const wrapper = await mountView() + + expect(wrapper.find('[data-testid="split-follow-up"]').text()).toContain( + 'Keep owner/split-dest attached here, or open a new workspace from that repository.', + ) + }) + + it('loads candidate repositories, filters attached repositories, and attaches the selected one', async () => { + const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true }) + const extra = fakeWorkspaceRepository({ + id: 'repo-extra', + name: 'extra', + repoUrl: 'git@github.com:owner/extra.git', + }) + getWorkspace + .mockResolvedValueOnce(detail([fakeSession({ id: 'sess-a' })], { repositories: [primary] })) + .mockResolvedValueOnce(detail([fakeSession({ id: 'sess-a' })], { repositories: [primary, extra] })) + listRepositories.mockResolvedValue([ + fakeRepository({ id: 'repo-primary', name: 'primary' }), + fakeRepository({ id: 'repo-extra', name: 'extra', repoUrl: 'git@github.com:owner/extra.git' }), + ]) + attachRepository.mockResolvedValue([extra]) + const wrapper = await mountView() + + await wrapper.find('[data-testid="workspace-add-repository"]').trigger('click') + await flush() + + expect(listRepositories).toHaveBeenCalledOnce() + expect(document.querySelector('[data-testid="repository-picker-radio-repo-primary"]')).toBeNull() + const extraRadio = requireElement('[data-testid="repository-picker-radio-repo-extra"]') + extraRadio.click() + await wrapper.vm.$nextTick() + requireElement('[data-testid="repository-picker-submit"]').click() + await flush() + + expect(attachRepository).toHaveBeenCalledWith('ws-1', 'repo-extra') + expect(wrapper.find('[data-testid="workspace-repository-repo-extra"]').exists()).toBe(true) + }) + + it('removes non-primary repositories and reports detach failures', async () => { + const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true }) + const destination = fakeWorkspaceRepository({ + id: 'repo-dest', + name: 'split-dest', + repoUrl: 'git@github.com:owner/split-dest.git', + }) + getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a' })], { repositories: [primary, destination] })) + detachRepository.mockRejectedValue(new Error('detach failed')) + const wrapper = await mountView() + + await wrapper.find('[data-testid="workspace-detach-repository-repo-dest"]').trigger('click') + await flush() + + expect(detachRepository).toHaveBeenCalledWith('ws-1', 'repo-dest') + expect(wrapper.find('[data-testid="workspace-repositories-panel"]').text()).toContain( + 'Could not remove the repository', + ) + }) }) async function flush(): Promise { diff --git a/services/assistant-ui/src/features/workspaces/__tests__/workspaces.store.test.ts b/services/assistant-ui/src/features/workspaces/__tests__/workspaces.store.test.ts index 52ef858f..9b347f37 100644 --- a/services/assistant-ui/src/features/workspaces/__tests__/workspaces.store.test.ts +++ b/services/assistant-ui/src/features/workspaces/__tests__/workspaces.store.test.ts @@ -1,9 +1,11 @@ -import type { AgentSession, Workspace, WorkspaceDetail } from '../types' +import type { AgentSession, Workspace, WorkspaceDetail, WorkspaceRepository } from '../types' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { + attachRepository, createWorkspace, destroyWorkspace, + detachRepository, getTurns, getWorkspace, listWorkspaces, @@ -17,6 +19,8 @@ vi.mock('../services/workspaceService', () => ({ getWorkspace: vi.fn(), createWorkspace: vi.fn(), destroyWorkspace: vi.fn(), + attachRepository: vi.fn(), + detachRepository: vi.fn(), startSession: vi.fn(), stopSession: vi.fn(), getTurns: vi.fn(), @@ -28,6 +32,8 @@ const mocked = { getWorkspace: vi.mocked(getWorkspace), createWorkspace: vi.mocked(createWorkspace), destroyWorkspace: vi.mocked(destroyWorkspace), + attachRepository: vi.mocked(attachRepository), + detachRepository: vi.mocked(detachRepository), startSession: vi.mocked(startSession), getTurns: vi.mocked(getTurns), } @@ -64,6 +70,23 @@ function fakeSession(over: Partial = {}): AgentSession { } } +function fakeRepository(over: Partial = {}): WorkspaceRepository { + return { + id: 'repo-1', + name: 'demo-repo', + repoUrl: 'git@github.com:owner/demo.git', + defaultBranch: 'main', + vaultKeyPath: 'secret/data/agents/repositories/repo-1', + deployKeyFingerprint: null, + deployKeyAddedAt: null, + createdAt: '2026-05-20T10:00:00Z', + updatedAt: '2026-05-20T10:00:00Z', + isPrimary: false, + attachedAt: '2026-05-20T10:00:00Z', + ...over, + } +} + describe('useWorkspacesStore', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -81,7 +104,7 @@ describe('useWorkspacesStore', () => { it('open loads workspace + sessions and auto-selects first session turns', async () => { const detail: WorkspaceDetail = { - workspace: fakeWorkspace(), + workspace: { ...fakeWorkspace(), repositories: [] }, sessions: [fakeSession()], } mocked.getWorkspace.mockResolvedValue(detail) @@ -90,6 +113,17 @@ describe('useWorkspacesStore', () => { await store.open('11111111-1111-1111-1111-111111111111') expect(store.sessions).toHaveLength(1) expect(store.activeSessionId).toBe('sess-1') + expect(store.activeWorkspace?.repositories).toEqual([]) + }) + + it('open defaults missing detail repositories to an empty list', async () => { + mocked.getWorkspace.mockResolvedValue({ + workspace: fakeWorkspace(), + sessions: [], + }) + const store = useWorkspacesStore() + await store.open('11111111-1111-1111-1111-111111111111') + expect(store.activeWorkspace?.repositories).toEqual([]) }) it('open prefers a live session over a stopped first session after refresh', async () => { @@ -144,6 +178,53 @@ describe('useWorkspacesStore', () => { expect(store.activeWorkspace).toBeNull() }) + it('attachRepository attaches to the active workspace and refreshes detail', async () => { + const repo = fakeRepository({ id: 'repo-a' }) + mocked.attachRepository.mockResolvedValue([repo]) + mocked.getWorkspace.mockResolvedValue({ + workspace: { ...fakeWorkspace(), repositories: [repo] }, + sessions: [], + }) + + const store = useWorkspacesStore() + store.activeWorkspace = { ...fakeWorkspace(), repositories: [] } + await store.attachRepository('repo-a') + + expect(mocked.attachRepository).toHaveBeenCalledWith(fakeWorkspace().id, 'repo-a') + expect(mocked.getWorkspace).toHaveBeenCalledWith(fakeWorkspace().id) + expect(store.activeWorkspace?.repositories?.map((r) => r.id)).toEqual(['repo-a']) + }) + + it('attachRepository no-ops when no workspace is active', async () => { + const store = useWorkspacesStore() + await store.attachRepository('repo-a') + expect(mocked.attachRepository).not.toHaveBeenCalled() + }) + + it('detachRepository removes from the active workspace and refreshes detail', async () => { + const a = fakeRepository({ id: 'repo-a' }) + const b = fakeRepository({ id: 'repo-b' }) + mocked.detachRepository.mockResolvedValue() + mocked.getWorkspace.mockResolvedValue({ + workspace: { ...fakeWorkspace(), repositories: [b] }, + sessions: [], + }) + + const store = useWorkspacesStore() + store.activeWorkspace = { ...fakeWorkspace(), repositories: [a, b] } + await store.detachRepository('repo-a') + + expect(mocked.detachRepository).toHaveBeenCalledWith(fakeWorkspace().id, 'repo-a') + expect(mocked.getWorkspace).toHaveBeenCalledWith(fakeWorkspace().id) + expect(store.activeWorkspace?.repositories?.map((r) => r.id)).toEqual(['repo-b']) + }) + + it('detachRepository no-ops when no workspace is active', async () => { + const store = useWorkspacesStore() + await store.detachRepository('repo-a') + expect(mocked.detachRepository).not.toHaveBeenCalled() + }) + it('appendStreamedOutput appends to the trailing streamed turn rather than creating new ones', () => { const store = useWorkspacesStore() store.activeSessionId = 'sess-1' diff --git a/services/assistant-ui/src/features/workspaces/components/WorkspaceRepositoriesPanel.vue b/services/assistant-ui/src/features/workspaces/components/WorkspaceRepositoriesPanel.vue new file mode 100644 index 00000000..65ced754 --- /dev/null +++ b/services/assistant-ui/src/features/workspaces/components/WorkspaceRepositoriesPanel.vue @@ -0,0 +1,99 @@ + + + diff --git a/services/assistant-ui/src/features/workspaces/components/WorkspaceRepositoryPicker.vue b/services/assistant-ui/src/features/workspaces/components/WorkspaceRepositoryPicker.vue new file mode 100644 index 00000000..be5baadd --- /dev/null +++ b/services/assistant-ui/src/features/workspaces/components/WorkspaceRepositoryPicker.vue @@ -0,0 +1,105 @@ + + + diff --git a/services/assistant-ui/src/features/workspaces/components/WorkspaceSplitGuidance.vue b/services/assistant-ui/src/features/workspaces/components/WorkspaceSplitGuidance.vue new file mode 100644 index 00000000..6a3e6736 --- /dev/null +++ b/services/assistant-ui/src/features/workspaces/components/WorkspaceSplitGuidance.vue @@ -0,0 +1,111 @@ + + + diff --git a/services/assistant-ui/src/features/workspaces/services/workspaceService.ts b/services/assistant-ui/src/features/workspaces/services/workspaceService.ts index 4f0853c2..39baa7df 100644 --- a/services/assistant-ui/src/features/workspaces/services/workspaceService.ts +++ b/services/assistant-ui/src/features/workspaces/services/workspaceService.ts @@ -1,4 +1,4 @@ -import type { AgentKind, StagedInput, Turn, Workspace, WorkspaceDetail } from '../types' +import type { AgentKind, StagedInput, Turn, Workspace, WorkspaceDetail, WorkspaceRepository } from '../types' import { ApiError, useApiWithAuth } from '@personal-stack/vue-common' function getApi(): ReturnType { @@ -67,6 +67,14 @@ export async function destroyWorkspace(id: string): Promise { return getApi().del(`/workspaces/${id}`) } +export async function attachRepository(workspaceId: string, repositoryId: string): Promise { + return getApi().post(`/workspaces/${workspaceId}/repositories`, { repositoryId }) +} + +export async function detachRepository(workspaceId: string, repositoryId: string): Promise { + await getApi().del(`/workspaces/${workspaceId}/repositories/${repositoryId}`) +} + export async function startSession( workspaceId: string, kind: AgentKind, diff --git a/services/assistant-ui/src/features/workspaces/stores/workspaces.ts b/services/assistant-ui/src/features/workspaces/stores/workspaces.ts index dfa87710..d31576d4 100644 --- a/services/assistant-ui/src/features/workspaces/stores/workspaces.ts +++ b/services/assistant-ui/src/features/workspaces/stores/workspaces.ts @@ -1,16 +1,8 @@ import type { CreateWorkspaceInput } from '../services/workspaceService' -import type { AgentKind, AgentSession, Turn, Workspace } from '../types' +import type { AgentKind, AgentSession, Turn, Workspace, WorkspaceDetailWorkspace } from '../types' import { defineStore } from 'pinia' import { ref } from 'vue' -import { - createWorkspace, - destroyWorkspace, - getTurns, - getWorkspace, - listWorkspaces, - startSession, - stopSession, -} from '../services/workspaceService' +import * as workspaceService from '../services/workspaceService' const ACTIVE_SESSION_STORAGE_KEY = 'assistant-ui:workspace-active-session' @@ -62,9 +54,16 @@ function chooseActiveSession(all: AgentSession[], preferredId: string | null): s return live[0]?.id ?? all[0]?.id ?? null } +function withRepositoryList(workspace: WorkspaceDetailWorkspace): WorkspaceDetailWorkspace { + return { + ...workspace, + repositories: workspace.repositories ?? [], + } +} + export const useWorkspacesStore = defineStore('workspaces', () => { const workspaces = ref([]) - const activeWorkspace = ref(null) + const activeWorkspace = ref(null) const sessions = ref([]) const activeSessionId = ref(null) const turns = ref([]) @@ -78,7 +77,7 @@ export const useWorkspacesStore = defineStore('workspaces', () => { isLoading.value = true error.value = null try { - workspaces.value = await listWorkspaces() + workspaces.value = await workspaceService.listWorkspaces() } catch { error.value = 'Failed to load workspaces' } finally { @@ -90,9 +89,9 @@ export const useWorkspacesStore = defineStore('workspaces', () => { isLoading.value = true error.value = null try { - const detail = await getWorkspace(id) + const detail = await workspaceService.getWorkspace(id) const preferredId = activeWorkspace.value?.id === id ? activeSessionId.value : readPreferredSession(id) - activeWorkspace.value = detail.workspace + activeWorkspace.value = withRepositoryList(detail.workspace) sessions.value = detail.sessions activeSessionId.value = chooseActiveSession(sessions.value, preferredId) if (activeSessionId.value) { @@ -109,13 +108,13 @@ export const useWorkspacesStore = defineStore('workspaces', () => { } async function create(input: CreateWorkspaceInput): Promise { - const ws = await createWorkspace(input) + const ws = await workspaceService.createWorkspace(input) workspaces.value.unshift(ws) return ws } async function destroy(id: string): Promise { - await destroyWorkspace(id) + await workspaceService.destroyWorkspace(id) workspaces.value = workspaces.value.filter((w) => w.id !== id) if (activeWorkspace.value?.id === id) { activeWorkspace.value = null @@ -130,7 +129,7 @@ export const useWorkspacesStore = defineStore('workspaces', () => { if (!ws) return null startingSession.value = true try { - const { sessionId } = await startSession(ws.id, kind, () => { + const { sessionId } = await workspaceService.startSession(ws.id, kind, () => { startingSession.value = true }) activeSessionId.value = sessionId @@ -145,7 +144,7 @@ export const useWorkspacesStore = defineStore('workspaces', () => { async function endSession(sessionId: string): Promise { const ws = activeWorkspace.value if (!ws) return - await stopSession(ws.id, sessionId) + await workspaceService.stopSession(ws.id, sessionId) if (activeSessionId.value === sessionId) activeSessionId.value = null await open(ws.id) } @@ -153,7 +152,28 @@ export const useWorkspacesStore = defineStore('workspaces', () => { async function loadTurns(sessionId: string): Promise { const ws = activeWorkspace.value if (!ws) return - turns.value = await getTurns(ws.id, sessionId) + turns.value = await workspaceService.getTurns(ws.id, sessionId) + } + + async function attachRepository(repositoryId: string): Promise { + const ws = activeWorkspace.value + if (!ws) return + activeWorkspace.value = { + ...ws, + repositories: await workspaceService.attachRepository(ws.id, repositoryId), + } + await open(ws.id) + } + + async function detachRepository(repositoryId: string): Promise { + const ws = activeWorkspace.value + if (!ws) return + await workspaceService.detachRepository(ws.id, repositoryId) + activeWorkspace.value = { + ...ws, + repositories: (ws.repositories ?? []).filter((r) => r.id !== repositoryId), + } + await open(ws.id) } function selectSession(sessionId: string): void { @@ -204,6 +224,8 @@ export const useWorkspacesStore = defineStore('workspaces', () => { newSession, endSession, loadTurns, + attachRepository, + detachRepository, selectSession, appendStreamedOutput, appendUserTurn, diff --git a/services/assistant-ui/src/features/workspaces/types/index.ts b/services/assistant-ui/src/features/workspaces/types/index.ts index 00673665..0e519852 100644 --- a/services/assistant-ui/src/features/workspaces/types/index.ts +++ b/services/assistant-ui/src/features/workspaces/types/index.ts @@ -36,6 +36,33 @@ export interface Workspace { updatedAt: string } +export interface WorkspaceRepositoryVerification { + read?: boolean | null + write?: boolean | null + defaultBranchProtected?: boolean | null + checkedAt?: string | null + messages: string[] +} + +export interface WorkspaceRepository { + id: string + name: string + repoUrl: string + defaultBranch: string + vaultKeyPath: string + deployKeyFingerprint?: string | null + deployKeyAddedAt?: string | null + createdAt: string + updatedAt: string + verification?: WorkspaceRepositoryVerification | null + isPrimary: boolean + attachedAt: string +} + +export interface WorkspaceDetailWorkspace extends Workspace { + repositories?: WorkspaceRepository[] +} + export type AgentKind = 'CLAUDE' | 'CODEX' | 'SHELL' export type AgentSessionStatus = 'STARTING' | 'RUNNING' | 'STOPPED' | 'FAILED' @@ -67,6 +94,6 @@ export interface StagedInput { } export interface WorkspaceDetail { - workspace: Workspace + workspace: WorkspaceDetailWorkspace sessions: AgentSession[] } diff --git a/services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue b/services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue index 9c1f3bfe..9a4d8eaf 100644 --- a/services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue +++ b/services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue @@ -6,6 +6,9 @@ import { useRoute, useRouter } from 'vue-router' import AgentKindPicker from '../components/AgentKindPicker.vue' import SessionTabs from '../components/SessionTabs.vue' import SessionTerminal from '../components/SessionTerminal.vue' +import WorkspaceRepositoriesPanel from '../components/WorkspaceRepositoriesPanel.vue' +import WorkspaceRepositoryPicker from '../components/WorkspaceRepositoryPicker.vue' +import WorkspaceSplitGuidance from '../components/WorkspaceSplitGuidance.vue' import { sendInput, stageInput } from '../services/workspaceService' import { useWorkspacesStore } from '../stores/workspaces' @@ -17,9 +20,13 @@ const toast = useToast() const workspaceId = computed(() => String(route.params.id)) const pickerKind = ref('CLAUDE') const showStageInput = ref(false) +const showRepositoryPicker = ref(false) const stageName = ref('source.txt') const stageContent = ref('') const isStaging = ref(false) +const isAttachingRepository = ref(false) +const detachingRepositoryId = ref(null) +const repositoryActionError = ref(null) // Only sessions with a live PTY get a mounted terminal. A session // dropping out of this set (STOPPED/FAILED) unmounts its @@ -67,6 +74,35 @@ async function onStageInput(): Promise { isStaging.value = false } } + +async function onAttachRepository(repositoryId: string): Promise { + repositoryActionError.value = null + isAttachingRepository.value = true + try { + await store.attachRepository(repositoryId) + showRepositoryPicker.value = false + toast.success('Repository attached') + } catch (e) { + repositoryActionError.value = 'Could not attach the repository' + toast.errorFromCatch('Could not attach the repository', e) + } finally { + isAttachingRepository.value = false + } +} + +async function onDetachRepository(repositoryId: string, repositoryName: string): Promise { + repositoryActionError.value = null + detachingRepositoryId.value = repositoryId + try { + await store.detachRepository(repositoryId) + toast.success(`Removed ${repositoryName}`) + } catch (e) { + repositoryActionError.value = 'Could not remove the repository' + toast.errorFromCatch('Could not remove the repository', e) + } finally { + detachingRepositoryId.value = null + } +}