From dae70ec5720db991a03eeeb2f8c1cc75314896bf Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Sat, 6 Jun 2026 21:09:13 +0000 Subject: [PATCH 1/6] assistant-api: support multi-repo workspace creation --- services/assistant-api/openapi.json | 24 +++++- .../WorkspaceRepositoryFlowIntegrationTest.kt | 34 +++++++++ ...paceRepositoryRepositoryIntegrationTest.kt | 16 ++++ .../AttachWorkspaceRepositoryCommand.kt | 6 +- ...AttachWorkspaceRepositoryCommandHandler.kt | 15 +++- .../command/CreateWorkspaceCommand.kt | 7 ++ .../command/CreateWorkspaceCommandHandler.kt | 65 ++++++++++++---- .../query/GetWorkspaceQueryService.kt | 32 ++++++-- .../JooqWorkspaceRepositoryRepository.kt | 52 ++++++++++++- .../infrastructure/web/WorkspaceController.kt | 26 ++++++- .../infrastructure/web/dto/WorkspaceDtos.kt | 43 ++++++++++- ...chWorkspaceRepositoryCommandHandlerTest.kt | 70 ++++++++++++++++-- .../CreateWorkspaceCommandHandlerTest.kt | 52 +++++++++++++ .../query/GetWorkspaceQueryServiceTest.kt | 49 ++++++++---- .../web/WorkspaceControllerTest.kt | 74 ++++++++++++++++++- 15 files changed, 508 insertions(+), 57 deletions(-) diff --git a/services/assistant-api/openapi.json b/services/assistant-api/openapi.json index 716d9dff..781e2b2c 100644 --- a/services/assistant-api/openapi.json +++ b/services/assistant-api/openapi.json @@ -241,8 +241,8 @@ "required" : true }, "responses" : { - "200" : { - "description" : "OK" + "204" : { + "description" : "No Content" } } } @@ -1213,6 +1213,17 @@ "type" : [ "string", "null" ], "format" : "uuid" }, + "primaryRepositoryId" : { + "type" : [ "string", "null" ], + "format" : "uuid" + }, + "repositoryIds" : { + "type" : [ "array", "null" ], + "items" : { + "type" : "string", + "format" : "uuid" + } + }, "githubLinkId" : { "type" : [ "string", "null" ], "format" : "uuid", @@ -1825,6 +1836,13 @@ "name" : { "type" : "string" }, + "isPrimary" : { + "type" : "boolean" + }, + "attachedAt" : { + "type" : "string", + "format" : "date-time" + }, "repoUrl" : { "type" : "string" }, @@ -1857,7 +1875,7 @@ } ] } }, - "required" : [ "createdAt", "defaultBranch", "id", "name", "repoUrl", "updatedAt", "vaultKeyPath" ] + "required" : [ "attachedAt", "createdAt", "defaultBranch", "id", "isPrimary", "name", "repoUrl", "updatedAt", "vaultKeyPath" ] }, "WorkspaceWithRepositoriesResponse" : { "type" : "object", 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 index e5569006..596f5214 100644 --- 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 @@ -57,6 +57,40 @@ class WorkspaceRepositoryFlowIntegrationTest : IntegrationTestBase() { .containsExactlyInAnyOrder("primary-$unique", "extra-one-$unique", "extra-two-$unique") } + @Test + fun `workspace creation accepts multi-repository selection with explicit primary`() { + val unique = unique() + val primaryId = createRepository("primary-$unique") + val extraOneId = createRepository("extra-one-$unique") + val extraTwoId = createRepository("extra-two-$unique") + + val result = + mockMvc + .perform( + post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "name" to "workspace-$unique", + "kind" to "REPO_BACKED", + "primaryRepositoryId" to primaryId, + "repositoryIds" to listOf(extraOneId, primaryId, extraTwoId), + ), + ), + ), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.repositoryId").value(primaryId)) + .andReturn() + val workspaceId = objectMapper.readTree(result.response.contentAsString)["id"].asText() + + val repositories = getWorkspaceDetail(workspaceId)["workspace"]["repositories"] + assertThat(repositories.map { it["id"].asText() }) + .containsExactlyInAnyOrder(primaryId, extraOneId, extraTwoId) + assertThat(repositories.single { it["isPrimary"].asBoolean() }["id"].asText()) + .isEqualTo(primaryId) + } + @Test fun `attach and detach endpoints mutate workspace detail repositories`() { val unique = unique() diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt index cba63690..f0aa90c9 100644 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt +++ b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt @@ -73,6 +73,22 @@ class JooqWorkspaceRepositoryRepositoryIntegrationTest : IntegrationTestBase() { assertThat(junction.findAllByWorkspaceId(w.id)).hasSize(1) } + @Test + fun `attaching a repository as primary promotes it and clears previous primary`() { + val w = workspace() + val r1 = repository() + val r2 = repository() + junction.attach(w.id, r1.id, isPrimary = true) + junction.attach(w.id, r2.id) + + val promoted = junction.attach(w.id, r2.id, isPrimary = true) + + assertThat(promoted.isPrimary).isTrue + val links = junction.findAllByWorkspaceId(w.id) + assertThat(links.filter { it.isPrimary }.map { it.repositoryId }).containsExactly(r2.id) + assertThat(links.first().repositoryId).isEqualTo(r2.id) + } + @Test fun `detach removes the row`() { val w = workspace() diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt index 7e9c9480..0702e1a0 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt @@ -5,9 +5,9 @@ import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId import com.jorisjonkers.personalstack.common.command.Command /** - * Attach an additional repository to a workspace. The repo is cloned - * alongside the primary (it becomes part of REPO_URLS) the next time the - * runner Pod boots; running pods are not re-provisioned. + * Attach an additional repository to a workspace. The link becomes part + * of REPO_URLS for future runner boots, and a currently running gateway + * is asked to clone it immediately into /workspace/. */ data class AttachWorkspaceRepositoryCommand( val workspaceId: WorkspaceId, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt index 834ebd4e..661e5d4a 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt @@ -1,5 +1,6 @@ package com.jorisjonkers.personalstack.assistant.application.command +import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository @@ -12,13 +13,19 @@ class AttachWorkspaceRepositoryCommandHandler( private val workspaces: WorkspaceRepository, private val repositories: RepositoryRepository, private val links: WorkspaceRepositoryRepository, + private val gateway: AgentGatewayClient, ) : CommandHandler { @Transactional override fun handle(command: AttachWorkspaceRepositoryCommand) { - workspaces.findById(command.workspaceId) - ?: throw NoSuchElementException("workspace not found: ${command.workspaceId}") - repositories.findById(command.repositoryId) - ?: throw NoSuchElementException("repository not found: ${command.repositoryId}") + val workspace = + workspaces.findById(command.workspaceId) + ?: throw NoSuchElementException("workspace not found: ${command.workspaceId}") + val repository = + repositories.findById(command.repositoryId) + ?: throw NoSuchElementException("repository not found: ${command.repositoryId}") links.attach(command.workspaceId, command.repositoryId, isPrimary = false) + if (workspace.gatewayEndpoint != null && gateway.isReady(workspace)) { + gateway.clone(workspace, repository.repoUrl, repository.defaultBranch) + } } } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt index f1eb9eba..d6231760 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt @@ -36,6 +36,13 @@ data class CreateWorkspaceCommand( val kind: WorkspaceKind = WorkspaceKind.REPO_BACKED, val projectId: ProjectId? = null, val repositoryId: RepositoryId? = null, + /** + * Ordered repository selection for multi-repo workspaces. The + * first entry becomes the primary repository when [repositoryId] + * is omitted; all other distinct entries are attached as + * additional repositories before the runner is provisioned. + */ + val repositoryIds: List = emptyList(), @Deprecated( "Use repositoryId — kept for the V9 migration window so existing UI " + "callers continue to work until PR F migrates them.", 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 ddc9919e..5b23f238 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 @@ -24,7 +24,7 @@ import java.time.Instant * Creates the workspace record and provisions the runner Pod. For a * repo-backed workspace the clone happens inside the runner at boot * (the orchestrator passes REPO_URL/REPO_BRANCH and the entrypoint - * clones into /workspace with the mounted deploy key) — not from here. + * clones into /workspace/ with the mounted deploy key) — not from here. * An earlier create-time `gateway.clone` raced the runner's startup: * it fired before the gateway was up, failed, and was swallowed, * leaving the workspace empty. @@ -81,7 +81,7 @@ class CreateWorkspaceCommandHandler( verifyOrFail(resolved.repoUrl, resolved.branch, resolved.repositoryId) } val workspace = persistInitial(command, resolved) - seedRepositoryMembership(workspace) + seedRepositoryMembership(workspace, command) val withPod = provisionAndUpdate(workspace) log.info("workspace {} provisioned as pod {}", workspace.id, withPod.podName) } @@ -137,25 +137,53 @@ class CreateWorkspaceCommandHandler( return workspace } - private fun seedRepositoryMembership(workspace: Workspace) { + private fun seedRepositoryMembership( + workspace: Workspace, + command: CreateWorkspaceCommand, + ) { val repoId = workspace.repositoryId ?: return + val repoPort = repositories.ifAvailable ?: return val realPrimaryRepoId = - repositories - .ifAvailable - ?.findById(repoId) + repoPort + .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) } + selectedExtraRepositoryIds(command, realPrimaryRepoId) + .forEach { repositoryId -> + val repository = requireRepository(repoPort, repositoryId) + workspaceRepositories.attach( + workspace.id, + repository.id, + isPrimary = false, + ) + } + } + + private fun selectedExtraRepositoryIds( + command: CreateWorkspaceCommand, + primaryRepoId: RepositoryId, + ): List { + val extras = + if (command.repositoryIds.isNotEmpty()) { + command.repositoryIds.filterNot { it == primaryRepoId } + } else { + command.projectId + ?.let { projectRepositories.findAllByProjectId(it) } + .orEmpty() + .map { it.repositoryId } + .filterNot { it == primaryRepoId } + } + return extras.distinct() } + private fun requireRepository( + repos: RepositoryRepository, + repositoryId: RepositoryId, + ) = repos.findById(repositoryId) + ?: throw NoSuchElementException("repository not found: repositoryId=${repositoryId.value}") + private fun provisionAndUpdate(workspace: Workspace): Workspace { val handle = orchestrator.provision(workspace) val withPod = @@ -175,13 +203,18 @@ class CreateWorkspaceCommandHandler( val legacyLinkId: GithubLinkId?, ) - private fun resolveRepo(command: CreateWorkspaceCommand): ResolvedRepo = - when { + private fun resolveRepo(command: CreateWorkspaceCommand): ResolvedRepo { + val primaryRepositoryId = primaryRepositoryId(command) + return when { command.kind == WorkspaceKind.SCRATCH -> ResolvedRepo(null, null, null, null) - command.repositoryId != null -> resolveFromRepository(command, command.repositoryId) + primaryRepositoryId != null -> resolveFromRepository(command, primaryRepositoryId) command.githubLinkId != null -> resolveFromLegacyLink(command, command.githubLinkId) else -> ResolvedRepo(command.repoUrl, command.branch, null, null) } + } + + private fun primaryRepositoryId(command: CreateWorkspaceCommand): RepositoryId? = + command.repositoryId ?: command.repositoryIds.firstOrNull() private fun resolveFromRepository( command: CreateWorkspaceCommand, 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 adb92b39..ac01f64a 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 @@ -9,6 +9,7 @@ import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessio import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository import org.springframework.stereotype.Service +import java.time.Instant @Service class GetWorkspaceQueryService( @@ -20,22 +21,43 @@ class GetWorkspaceQueryService( data class WorkspaceView( val workspace: Workspace, val sessions: List, - val repositories: List, + val repositories: List, + ) + + data class WorkspaceRepositoryView( + val repository: Repository, + val isPrimary: Boolean, + val attachedAt: Instant, ) fun getSummary(id: WorkspaceId): Workspace? = workspaces.findById(id) fun get(id: WorkspaceId): WorkspaceView? { val workspace = workspaces.findById(id) ?: return null + val links = repositoryLinks(workspace) val resolvedRepositories = - workspaceRepositories - .findAllByWorkspaceId(id) - .map { link -> + links.map { link -> + val repository = repositories.findById(link.repositoryId) ?: throw IllegalStateException( "Workspace ${id.value} references missing repository ${link.repositoryId.value}", ) - } + WorkspaceRepositoryView(repository, link.isPrimary, link.attachedAt) + } return WorkspaceView(workspace, sessions.findAllByWorkspaceId(id), resolvedRepositories) } + + private fun repositoryLinks(workspace: Workspace): List { + val links = workspaceRepositories.findAllByWorkspaceId(workspace.id) + val primaryRepositoryId = workspace.repositoryId ?: return links + if (links.any { it.repositoryId == primaryRepositoryId }) return links + val primaryLink = + WorkspaceRepositoryRepository.Link( + workspaceId = workspace.id, + repositoryId = primaryRepositoryId, + isPrimary = true, + attachedAt = workspace.createdAt, + ) + return listOf(primaryLink) + links + } } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt index c026cea1..840d40a3 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt @@ -22,16 +22,51 @@ class JooqWorkspaceRepositoryRepository( isPrimary: Boolean, ): WorkspaceRepositoryRepository.Link { val now = Instant.now() + if (isPrimary) { + attachPrimary(workspaceId, repositoryId, now) + } else { + attachSecondary(workspaceId, repositoryId, now) + } + return findLink(workspaceId, repositoryId) + ?: WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, now) + } + + private fun attachPrimary( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + now: Instant, + ) { + dsl + .update(JUNCTION) + .set(IS_PRIMARY, false) + .where(WORKSPACE_ID.eq(workspaceId.value)) + .execute() dsl .insertInto(JUNCTION) .set(WORKSPACE_ID, workspaceId.value) .set(REPOSITORY_ID, repositoryId.value) - .set(IS_PRIMARY, isPrimary) + .set(IS_PRIMARY, true) + .set(ATTACHED_AT, now.atOffset(ZoneOffset.UTC)) + .onConflict(WORKSPACE_ID, REPOSITORY_ID) + .doUpdate() + .set(IS_PRIMARY, true) + .execute() + } + + private fun attachSecondary( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + now: Instant, + ) { + dsl + .insertInto(JUNCTION) + .set(WORKSPACE_ID, workspaceId.value) + .set(REPOSITORY_ID, repositoryId.value) + .set(IS_PRIMARY, false) .set(ATTACHED_AT, now.atOffset(ZoneOffset.UTC)) .onConflict(WORKSPACE_ID, REPOSITORY_ID) .doNothing() .execute() - return WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, now) } override fun detach( @@ -49,10 +84,21 @@ class JooqWorkspaceRepositoryRepository( dsl .selectFrom(JUNCTION) .where(WORKSPACE_ID.eq(workspaceId.value)) - .orderBy(ATTACHED_AT.asc()) + .orderBy(IS_PRIMARY.desc(), ATTACHED_AT.asc()) .fetch() .map { it.toLink() } + private fun findLink( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + ): WorkspaceRepositoryRepository.Link? = + dsl + .selectFrom(JUNCTION) + .where(WORKSPACE_ID.eq(workspaceId.value)) + .and(REPOSITORY_ID.eq(repositoryId.value)) + .fetchOne() + ?.toLink() + private fun Record.toLink(): WorkspaceRepositoryRepository.Link = WorkspaceRepositoryRepository.Link( workspaceId = WorkspaceId(this[WORKSPACE_ID]), 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 0f542acb..1ef2e368 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 @@ -15,6 +15,7 @@ import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.CreateWor 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 io.swagger.v3.oas.annotations.responses.ApiResponse import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -40,6 +41,7 @@ class WorkspaceController( @Valid @RequestBody req: CreateWorkspaceRequest, ): ResponseEntity { val id = WorkspaceId.random() + val primaryRepositoryId = primaryRepositoryId(req) commandBus.dispatch( CreateWorkspaceCommand( workspaceId = id, @@ -48,7 +50,8 @@ class WorkspaceController( branch = req.branch, kind = req.kind, projectId = req.projectId?.let { ProjectId(it) }, - repositoryId = req.repositoryId?.let { RepositoryId(it) }, + repositoryId = primaryRepositoryId?.let { RepositoryId(it) }, + repositoryIds = selectedRepositoryIds(req, primaryRepositoryId).map { RepositoryId(it) }, githubLinkId = req.githubLinkId?.let { GithubLinkId(it) }, ), ) @@ -72,6 +75,7 @@ class WorkspaceController( } @PostMapping("/{id}/repositories") + @ApiResponse(responseCode = "204", description = "No Content") fun attachRepository( @PathVariable id: UUID, @Valid @RequestBody req: AttachWorkspaceRepositoryRequest, @@ -106,4 +110,24 @@ class WorkspaceController( commandBus.dispatch(DestroyWorkspaceCommand(WorkspaceId(id))) return ResponseEntity.noContent().build() } + + private fun primaryRepositoryId(req: CreateWorkspaceRequest): UUID? { + if (req.repositoryId != null && + req.primaryRepositoryId != null && + req.repositoryId != req.primaryRepositoryId + ) { + throw IllegalArgumentException("repositoryId and primaryRepositoryId must match when both are supplied") + } + return req.repositoryId ?: req.primaryRepositoryId ?: req.repositoryIds.orEmpty().firstOrNull() + } + + private fun selectedRepositoryIds( + req: CreateWorkspaceRequest, + primaryRepositoryId: UUID?, + ): List { + val repositoryIds = req.repositoryIds.orEmpty() + if (repositoryIds.isEmpty()) return emptyList() + return (listOfNotNull(primaryRepositoryId) + repositoryIds) + .distinct() + } } 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 67daca1f..650790fa 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,5 +1,7 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto +import com.fasterxml.jackson.annotation.JsonProperty +import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService.WorkspaceRepositoryView import com.jorisjonkers.personalstack.assistant.domain.model.Repository import com.jorisjonkers.personalstack.assistant.domain.model.Turn import com.jorisjonkers.personalstack.assistant.domain.model.Workspace @@ -34,6 +36,20 @@ data class CreateWorkspaceRequest( * Repository row. */ val repositoryId: UUID? = null, + /** + * Primary repo for the multi-repository request shape. Kept + * separate from [repositoryIds] so clients can submit an ordered + * or unordered selected set without relying on list position. + * [repositoryId] remains the backwards-compatible alias and wins + * when both fields match. + */ + val primaryRepositoryId: UUID? = null, + /** + * Selected repositories for a multi-repo workspace. When no + * primary field is set, the first entry becomes the primary and + * the remaining distinct ids are attached as extras. + */ + val repositoryIds: List? = null, /** * Deprecated alias for [repositoryId]. Kept until PR F migrates * the assistant-ui to the new field. The server prefers @@ -87,6 +103,9 @@ data class WorkspaceResponse( data class WorkspaceRepositoryResponse( val id: UUID, val name: String, + @get:JsonProperty("isPrimary") + val isPrimary: Boolean, + val attachedAt: Instant, val repoUrl: String, val defaultBranch: String, val vaultKeyPath: String, @@ -97,10 +116,30 @@ data class WorkspaceRepositoryResponse( val verification: AccessVerificationResponse?, ) { companion object { + fun of(view: WorkspaceRepositoryView): WorkspaceRepositoryResponse { + val r = view.repository + return WorkspaceRepositoryResponse( + id = r.id.value, + name = r.name, + isPrimary = view.isPrimary, + attachedAt = view.attachedAt, + 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), + ) + } + fun of(r: Repository) = WorkspaceRepositoryResponse( id = r.id.value, name = r.name, + isPrimary = false, + attachedAt = r.createdAt, repoUrl = r.repoUrl, defaultBranch = r.defaultBranch, vaultKeyPath = r.vaultKeyPath, @@ -133,7 +172,7 @@ data class WorkspaceWithRepositoriesResponse( companion object { fun of( w: Workspace, - repositories: List, + repositories: List, ) = WorkspaceWithRepositoriesResponse( id = w.id.value, name = w.name, @@ -160,7 +199,7 @@ data class WorkspaceDetailResponse( companion object { fun of( workspace: Workspace, - repositories: List, + repositories: List, sessions: List, ) = WorkspaceDetailResponse( workspace = WorkspaceWithRepositoriesResponse.of(workspace, repositories), diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt index e581563d..4e9fdfa0 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt @@ -4,6 +4,8 @@ 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.AgentGatewayClient import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository @@ -12,25 +14,56 @@ import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import java.time.Instant class AttachWorkspaceRepositoryCommandHandlerTest { private val workspaces = mockk() private val repositories = mockk() private val links = mockk(relaxed = true) - private val handler = AttachWorkspaceRepositoryCommandHandler(workspaces, repositories, links) + private val gateway = mockk(relaxed = true) + private val handler = AttachWorkspaceRepositoryCommandHandler(workspaces, repositories, links, gateway) private val workspaceId = WorkspaceId.random() private val repositoryId = RepositoryId.random() private val command = AttachWorkspaceRepositoryCommand(workspaceId, repositoryId) @Test - fun `attaches a non-primary link when both exist`() { - every { workspaces.findById(workspaceId) } returns mockk() - every { repositories.findById(repositoryId) } returns mockk() + fun `attaches a non-primary link and clones into a running workspace`() { + val workspace = workspace() + val repository = repository() + every { workspaces.findById(workspaceId) } returns workspace + every { repositories.findById(repositoryId) } returns repository + every { gateway.isReady(workspace) } returns true handler.handle(command) verify { links.attach(workspaceId, repositoryId, isPrimary = false) } + verify { gateway.clone(workspace, repository.repoUrl, repository.defaultBranch) } + } + + @Test + fun `attaches without live clone when workspace has no gateway yet`() { + val workspace = workspace(gatewayEndpoint = null) + every { workspaces.findById(workspaceId) } returns workspace + every { repositories.findById(repositoryId) } returns repository() + + handler.handle(command) + + verify { links.attach(workspaceId, repositoryId, isPrimary = false) } + verify(exactly = 0) { gateway.clone(any(), any(), any()) } + } + + @Test + fun `attaches without live clone when gateway is not ready`() { + val workspace = workspace() + every { workspaces.findById(workspaceId) } returns workspace + every { repositories.findById(repositoryId) } returns repository() + every { gateway.isReady(workspace) } returns false + + handler.handle(command) + + verify { links.attach(workspaceId, repositoryId, isPrimary = false) } + verify(exactly = 0) { gateway.clone(any(), any(), any()) } } @Test @@ -43,10 +76,37 @@ class AttachWorkspaceRepositoryCommandHandlerTest { @Test fun `rejects an unknown repository`() { - every { workspaces.findById(workspaceId) } returns mockk() + every { workspaces.findById(workspaceId) } returns workspace() every { repositories.findById(repositoryId) } returns null assertThrows { handler.handle(command) } verify(exactly = 0) { links.attach(any(), any(), any()) } } + + private fun workspace(gatewayEndpoint: String? = "http://runner:8090") = + Workspace( + id = workspaceId, + name = "workspace", + repoUrl = "git@github.com:o/primary.git", + branch = "main", + podName = "pod", + pvcName = "pvc", + gatewayEndpoint = gatewayEndpoint, + status = WorkspaceStatus.READY, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + + private fun repository() = + Repository( + id = repositoryId, + name = "website", + repoUrl = "git@github.com:o/website.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/repositories/${repositoryId.value}", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) } 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 c601db88..21c82cc9 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 @@ -264,6 +264,8 @@ class CreateWorkspaceCommandHandlerTest { updatedAt = Instant.now(), ) every { repositories.findById(primaryRepoId) } returns repository + every { repositories.findById(extraRepoId) } returns repository(extraRepoId, "extra-one") + every { repositories.findById(secondExtraRepoId) } returns repository(secondExtraRepoId, "extra-two") every { githubLinks.findById(GithubLinkId(primaryRepoId.value)) } returns null every { projectRepositoryLinks.findAllByProjectId(projectId) } returns listOf( @@ -296,6 +298,41 @@ class CreateWorkspaceCommandHandlerTest { } } + @Test + fun `repositoryIds-only create treats first selected repository as primary and attaches distinct extras`() { + val primaryRepoId = RepositoryId.random() + val extraRepoId = RepositoryId.random() + val secondExtraRepoId = RepositoryId.random() + every { repositories.findById(primaryRepoId) } returns repository(primaryRepoId, "primary") + every { repositories.findById(extraRepoId) } returns repository(extraRepoId, "extra") + every { repositories.findById(secondExtraRepoId) } returns repository(secondExtraRepoId, "docs") + every { githubLinks.findById(GithubLinkId(primaryRepoId.value)) } returns null + val saved = mutableListOf() + every { workspaces.save(capture(saved)) } 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, + repositoryIds = listOf(primaryRepoId, extraRepoId, primaryRepoId, secondExtraRepoId), + ), + ) + + assertThat(saved.first().repositoryId).isEqualTo(primaryRepoId) + verifyOrder { + workspaces.save(match { it.id == workspaceId && it.repositoryId == primaryRepoId }) + 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 @@ -667,4 +704,19 @@ class CreateWorkspaceCommandHandlerTest { verify(exactly = 0) { verifyAccess.verify(any(), any()) } } + + private fun repository( + id: RepositoryId, + name: String, + ) = Repository( + id = id, + name = name, + repoUrl = "git@github.com:ExtraToast/$name.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/repositories/$id", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) } 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 index 74b56b2f..2408b343 100644 --- 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 @@ -57,7 +57,25 @@ class GetWorkspaceQueryServiceTest { assertThat(result.workspace).isEqualTo(w) assertThat(result.sessions).isEmpty() - assertThat(result.repositories).containsExactly(r1, r2) + assertThat(result.repositories.map { it.repository }).containsExactly(r1, r2) + assertThat(result.repositories.map { it.isPrimary }).containsExactly(true, false) + } + + @Test + fun `get includes denormalized primary repository when junction row is missing`() { + val repoId = RepositoryId.random() + val w = workspace(repositoryId = repoId) + val r = repository(id = repoId) + every { workspaces.findById(w.id) } returns w + every { sessions.findAllByWorkspaceId(w.id) } returns emptyList() + every { workspaceRepositories.findAllByWorkspaceId(w.id) } returns emptyList() + every { repositories.findById(repoId) } returns r + + val result = service.get(w.id) ?: error("expected workspace detail") + + assertThat(result.repositories.map { it.repository }).containsExactly(r) + assertThat(result.repositories.single().isPrimary).isTrue + assertThat(result.repositories.single().attachedAt).isEqualTo(w.createdAt) } @Test @@ -87,19 +105,22 @@ class GetWorkspaceQueryServiceTest { .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 workspace( + id: WorkspaceId = WorkspaceId.random(), + repositoryId: RepositoryId? = null, + ) = 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(), + repositoryId = repositoryId, + ) private fun repository( id: RepositoryId = RepositoryId.random(), 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 3eeddb13..6d474bfa 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 @@ -2,6 +2,7 @@ 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.CreateWorkspaceCommand import com.jorisjonkers.personalstack.assistant.application.command.DetachWorkspaceRepositoryCommand import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService import com.jorisjonkers.personalstack.assistant.application.query.ListWorkspacesQueryService @@ -110,6 +111,65 @@ class WorkspaceControllerTest { verify { commandBus.dispatch(any()) } } + @Test + fun `POST creates a workspace with primary and extra repository ids`() { + val primaryRepositoryId = RepositoryId.random() + val extraRepositoryId = RepositoryId.random() + val w = workspace(kind = WorkspaceKind.REPO_BACKED, repositoryId = primaryRepositoryId) + every { getQuery.getSummary(any()) } returns w + + mockMvc + .perform( + post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "name" to "demo", + "kind" to "REPO_BACKED", + "primaryRepositoryId" to primaryRepositoryId.value.toString(), + "repositoryIds" to + listOf( + extraRepositoryId.value.toString(), + primaryRepositoryId.value.toString(), + extraRepositoryId.value.toString(), + ), + ), + ), + ), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.repositoryId").value(primaryRepositoryId.value.toString())) + + verify { + commandBus.dispatch( + match { + it.repositoryId == primaryRepositoryId && + it.repositoryIds == listOf(primaryRepositoryId, extraRepositoryId) + }, + ) + } + } + + @Test + fun `POST rejects conflicting primary repository fields`() { + mockMvc + .perform( + post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "name" to "demo", + "repositoryId" to UUID.randomUUID().toString(), + "primaryRepositoryId" to UUID.randomUUID().toString(), + ), + ), + ), + ).andExpect(status().isBadRequest) + + verify(exactly = 0) { commandBus.dispatch(any()) } + } + @Test fun `POST returns error on blank name`() { mockMvc @@ -140,13 +200,25 @@ class WorkspaceControllerTest { fun `GET by id returns workspace detail with repositories under workspace`() { val w = workspace() val r = repository() - every { getQuery.get(w.id) } returns GetWorkspaceQueryService.WorkspaceView(w, emptyList(), listOf(r)) + every { getQuery.get(w.id) } returns + GetWorkspaceQueryService.WorkspaceView( + w, + emptyList(), + listOf( + GetWorkspaceQueryService.WorkspaceRepositoryView( + repository = r, + isPrimary = true, + attachedAt = Instant.now(), + ), + ), + ) 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("$.workspace.repositories[0].isPrimary").value(true)) .andExpect(jsonPath("$.sessions").isArray) } From 1ea0a0e4eea8b270c0c5365a888df638d2664adc Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Sat, 6 Jun 2026 21:09:38 +0000 Subject: [PATCH 2/6] assistant-ui: improve multi-repo workspace flows --- services/assistant-ui/src/api/generated.ts | 10 +- .../__tests__/CreateWorkspaceWizard.test.ts | 215 ++++++++++++++++++ .../components/CreateWorkspaceWizard.vue | 129 ++++++++--- .../workspaces/__tests__/SessionTabs.test.ts | 14 ++ .../__tests__/WorkspaceView.test.ts | 46 +++- .../__tests__/workspaces.store.test.ts | 4 +- .../workspaces/components/AgentKindPicker.vue | 26 ++- .../workspaces/components/SessionTabs.vue | 150 ++++++++---- .../components/WorkspaceRepositoriesPanel.vue | 6 +- .../components/WorkspaceRepositoryPicker.vue | 8 +- .../components/WorkspaceSplitGuidance.vue | 47 +++- .../workspaces/services/workspaceService.ts | 10 +- .../features/workspaces/stores/workspaces.ts | 5 +- .../workspaces/views/WorkspaceView.vue | 148 +++++++++--- 14 files changed, 670 insertions(+), 148 deletions(-) create mode 100644 services/assistant-ui/src/features/sessions/__tests__/CreateWorkspaceWizard.test.ts diff --git a/services/assistant-ui/src/api/generated.ts b/services/assistant-ui/src/api/generated.ts index 5dba7530..b77d15b1 100644 --- a/services/assistant-ui/src/api/generated.ts +++ b/services/assistant-ui/src/api/generated.ts @@ -491,6 +491,9 @@ export interface components { projectId?: string | null; /** Format: uuid */ repositoryId?: string | null; + /** Format: uuid */ + primaryRepositoryId?: string | null; + repositoryIds?: string[] | null; /** * Format: uuid * @deprecated @@ -721,6 +724,9 @@ export interface components { /** Format: uuid */ id: string; name: string; + isPrimary: boolean; + /** Format: date-time */ + attachedAt: string; repoUrl: string; defaultBranch: string; vaultKeyPath: string; @@ -927,8 +933,8 @@ export interface operations { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; diff --git a/services/assistant-ui/src/features/sessions/__tests__/CreateWorkspaceWizard.test.ts b/services/assistant-ui/src/features/sessions/__tests__/CreateWorkspaceWizard.test.ts new file mode 100644 index 00000000..33ce0bee --- /dev/null +++ b/services/assistant-ui/src/features/sessions/__tests__/CreateWorkspaceWizard.test.ts @@ -0,0 +1,215 @@ +import type { Project, ProjectDetail } from '@/features/projects' +import type { Repository, RepositoryDetail } from '@/features/repositories' +import type { Workspace } from '@/features/workspaces' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' +import CreateWorkspaceWizard from '../components/CreateWorkspaceWizard.vue' + +vi.mock('@personal-stack/vue-common', () => ({ + FormErrors: { + props: ['error'], + template: '
{{ error }}
', + }, + FormField: { + props: ['label'], + template: '', + }, + SubmitButton: { + props: ['disabled', 'label', 'type'], + emits: ['click'], + template: + '', + }, + useFormErrors: () => ({ + general: { value: null }, + clear: vi.fn(), + captureFromCatch: vi.fn(), + fieldErrorFor: vi.fn(() => null), + }), + useMutationState: () => ({ + status: { value: 'idle' }, + run: async (fn: () => Promise) => fn(), + }), + useToast: () => ({ + success: vi.fn(), + errorFromCatch: vi.fn(), + }), +})) + +const listProjects = vi.fn<() => Promise>() +const getProject = vi.fn<(id: string) => Promise>() +vi.mock('@/features/projects/services/projectsService', () => ({ + listProjects: () => listProjects(), + getProject: (id: string) => getProject(id), + createProject: vi.fn(), + linkRepository: vi.fn(), + unlinkRepository: vi.fn(), + addLink: vi.fn(), + removeLink: vi.fn(), + attachKey: vi.fn(), +})) + +const getRepository = vi.fn<(id: string) => Promise>() +vi.mock('@/features/repositories/services/repositoriesService', () => ({ + listRepositories: vi.fn(), + getRepository: (id: string) => getRepository(id), + createRepository: vi.fn(), + attachDeployKey: vi.fn(), + deleteRepository: vi.fn(), + verifyRepositoryAccess: vi.fn(), +})) + +const createWorkspace = vi.fn() +const attachRepository = vi.fn() +vi.mock('@/features/workspaces/services/workspaceService', () => ({ + listWorkspaces: vi.fn(), + getWorkspace: vi.fn(), + createWorkspace: (...args: unknown[]) => createWorkspace(...args), + destroyWorkspace: vi.fn(), + attachRepository: (...args: unknown[]) => attachRepository(...args), + detachRepository: vi.fn(), + startSession: vi.fn(), + stopSession: vi.fn(), + getTurns: vi.fn(), + sendInput: vi.fn(), +})) + +function fakeProject(over: Partial = {}): Project { + return { + id: 'project-1', + name: 'Project One', + slug: 'project-one', + description: '', + createdAt: '2026-05-20T10:00:00Z', + updatedAt: '2026-05-20T10:00:00Z', + ...over, + } +} + +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-20T10:00:00Z', + createdAt: '2026-05-20T10:00:00Z', + updatedAt: '2026-05-20T10:00:00Z', + ...over, + } +} + +function fakeWorkspace(over: Partial = {}): Workspace { + return { + id: 'ws-new', + name: 'workspace', + repoUrl: 'git@github.com:owner/primary.git', + branch: 'trunk', + podName: null, + gatewayEndpoint: null, + status: 'PENDING', + kind: 'REPO_BACKED', + projectId: 'project-1', + repositoryId: 'repo-primary', + githubLinkId: null, + createdAt: '2026-05-20T10:00:00Z', + updatedAt: '2026-05-20T10:00:00Z', + ...over, + } +} + +async function mountWizard() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/sessions', component: { template: '
' } }, + { path: '/sessions/workspace/:id', component: { template: '
' } }, + { path: '/projects', component: { template: '
' } }, + { path: '/repositories', component: { template: '
' } }, + { path: '/repositories/:id', component: { template: '
' } }, + ], + }) + await router.push('/sessions') + await router.isReady() + + const wrapper = mount(CreateWorkspaceWizard, { + props: { open: true }, + global: { plugins: [router] }, + }) + await flush() + return { router, wrapper } +} + +describe('createWorkspaceWizard', () => { + beforeEach(() => { + setActivePinia(createPinia()) + listProjects.mockReset() + getProject.mockReset() + getRepository.mockReset() + createWorkspace.mockReset() + attachRepository.mockReset() + }) + + it('creates with a primary repository and selected repository ids', async () => { + const project = fakeProject() + const primary = fakeRepository({ defaultBranch: 'trunk' }) + const extra = fakeRepository({ + id: 'repo-extra', + name: 'extra', + repoUrl: 'git@github.com:owner/extra.git', + vaultKeyPath: 'secret/data/agents/repositories/repo-extra', + deployKeyFingerprint: 'SHA256:extra', + }) + const skipped = fakeRepository({ + id: 'repo-skipped', + name: 'skipped', + repoUrl: 'git@github.com:owner/skipped.git', + vaultKeyPath: 'secret/data/agents/repositories/repo-skipped', + deployKeyFingerprint: 'SHA256:skipped', + }) + listProjects.mockResolvedValue([project]) + getProject.mockResolvedValue({ project, links: [], repositories: [primary, extra, skipped] }) + getRepository.mockResolvedValue({ repository: primary, attachedProjects: [] }) + createWorkspace.mockResolvedValue(fakeWorkspace()) + + const { router, wrapper } = await mountWizard() + + await wrapper.get('[data-testid="wizard-project-project-1"]').setValue() + await flush() + await wrapper.get('[data-testid="wizard-step1-next"]').trigger('click') + await flush() + await wrapper.get('[data-testid="wizard-repo-primary-repo-primary"]').setValue() + await flush() + await wrapper.get('[data-testid="wizard-repo-checkbox-repo-extra"]').setValue(true) + await flush() + await wrapper.get('[data-testid="wizard-step2-next"]').trigger('click') + await flush() + await wrapper.get('[data-testid="wizard-name"]').setValue('workspace') + await wrapper.get('[data-testid="wizard-submit"]').trigger('click') + await flush() + + expect(createWorkspace).toHaveBeenCalledWith({ + name: 'workspace', + kind: 'REPO_BACKED', + projectId: 'project-1', + repositoryId: 'repo-primary', + primaryRepositoryId: 'repo-primary', + repositoryIds: ['repo-primary', 'repo-extra'], + branch: 'trunk', + }) + expect(attachRepository).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(router.currentRoute.value.fullPath).toBe('/sessions/workspace/ws-new') + }) + }) +}) + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} diff --git a/services/assistant-ui/src/features/sessions/components/CreateWorkspaceWizard.vue b/services/assistant-ui/src/features/sessions/components/CreateWorkspaceWizard.vue index 6c8515d9..b19bce64 100644 --- a/services/assistant-ui/src/features/sessions/components/CreateWorkspaceWizard.vue +++ b/services/assistant-ui/src/features/sessions/components/CreateWorkspaceWizard.vue @@ -32,7 +32,8 @@ const toast = useToast() const step = ref<'pick-project' | 'pick-repo' | 'pick-branch'>('pick-project') const selectedProjectId = ref(null) -const selectedRepositoryId = ref(null) +const selectedPrimaryRepositoryId = ref(null) +const selectedRepositoryIds = ref([]) const branch = ref('main') const name = ref('') @@ -53,7 +54,8 @@ onMounted(async () => { watch(selectedProjectId, async (id) => { if (!id) return - selectedRepositoryId.value = null + selectedPrimaryRepositoryId.value = null + selectedRepositoryIds.value = [] try { await projects.open(id) } catch (e) { @@ -61,7 +63,7 @@ watch(selectedProjectId, async (id) => { } }) -watch(selectedRepositoryId, async (id) => { +watch(selectedPrimaryRepositoryId, async (id) => { if (!id) return // Load detail so we can read the default branch + key state. try { @@ -78,12 +80,51 @@ watch(selectedRepositoryId, async (id) => { const projectRepos = computed(() => projects.repositories) const selectedRepo = computed(() => - selectedRepositoryId.value ? (repos.detailById[selectedRepositoryId.value]?.repository ?? null) : null, + selectedPrimaryRepositoryId.value + ? (repos.detailById[selectedPrimaryRepositoryId.value]?.repository + ?? projectRepos.value.find((r) => r.id === selectedPrimaryRepositoryId.value) + ?? null) + : null, +) +const selectedRepositories = computed(() => { + const ids = new Set(selectedRepositoryIds.value) + return projectRepos.value.filter((r) => ids.has(r.id)) +}) +const additionalRepositoriesMissingKeys = computed(() => + selectedRepositories.value.filter((r) => r.id !== selectedPrimaryRepositoryId.value && !r.deployKeyFingerprint), ) const keyAttached = computed(() => Boolean(selectedRepo.value?.deployKeyFingerprint)) +const selectedRepositoryCount = computed(() => selectedRepositoryIds.value.length) + +function ensureRepositorySelected(repositoryId: string): void { + if (selectedRepositoryIds.value.includes(repositoryId)) return + selectedRepositoryIds.value = [...selectedRepositoryIds.value, repositoryId] +} + +function checked(event: Event): boolean { + return event.target instanceof HTMLInputElement && event.target.checked +} + +function onRepositorySelectionChange(repositoryId: string, isSelected: boolean): void { + if (isSelected) { + ensureRepositorySelected(repositoryId) + if (!selectedPrimaryRepositoryId.value) selectedPrimaryRepositoryId.value = repositoryId + return + } + + selectedRepositoryIds.value = selectedRepositoryIds.value.filter((id) => id !== repositoryId) + if (selectedPrimaryRepositoryId.value === repositoryId) { + selectedPrimaryRepositoryId.value = selectedRepositoryIds.value[0] ?? null + } +} + +function onPrimaryRepositoryChange(repositoryId: string): void { + selectedPrimaryRepositoryId.value = repositoryId + ensureRepositorySelected(repositoryId) +} async function onSubmit(): Promise { - if (!selectedProjectId.value || !selectedRepositoryId.value || !name.value.trim()) return + if (!selectedProjectId.value || !selectedPrimaryRepositoryId.value || !name.value.trim()) return formErrors.clear() try { await create.run(async () => { @@ -91,7 +132,9 @@ async function onSubmit(): Promise { name: name.value.trim(), kind: 'REPO_BACKED', projectId: selectedProjectId.value, - repositoryId: selectedRepositoryId.value, + repositoryId: selectedPrimaryRepositoryId.value, + primaryRepositoryId: selectedPrimaryRepositoryId.value, + repositoryIds: selectedRepositoryIds.value, branch: branch.value.trim() || 'main', }) emit('created', ws.id) @@ -101,7 +144,8 @@ async function onSubmit(): Promise { // Reset for next time. step.value = 'pick-project' selectedProjectId.value = null - selectedRepositoryId.value = null + selectedPrimaryRepositoryId.value = null + selectedRepositoryIds.value = [] branch.value = 'main' name.value = '' } catch (e) { @@ -119,7 +163,7 @@ async function onSubmit(): Promise {
  1. 1. Project
  2. -
  3. 2. Repository
  4. +
  5. 2. Repositories
  6. 3. Branch + name
@@ -166,7 +210,8 @@ async function onSubmit(): Promise {

- Pick a repository from the project's pool. Need a different one? + Select the repositories to clone into this workspace, then choose one primary repository for the branch and + split workflow defaults. Need a different one? Add a repository @@ -174,25 +219,37 @@ async function onSubmit(): Promise {

  • -

No repositories linked to this project yet.

@@ -202,17 +259,27 @@ async function onSubmit(): Promise { class="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 text-sm text-amber-300" data-testid="wizard-missing-key-warning" > - The selected repository has no deploy key yet — the runner Pod won't be able to clone it. - Attach a key + The primary repository has no deploy key yet — the runner Pod won't be able to clone it. + Attach a key first.
+
+ {{ additionalRepositoriesMissingKeys.length }} selected + {{ additionalRepositoriesMissingKeys.length === 1 ? 'repository has' : 'repositories have' }} no deploy key yet; + additional repositories clone with the GitHub App token when the runner starts. +
+
diff --git a/services/assistant-ui/src/features/workspaces/__tests__/SessionTabs.test.ts b/services/assistant-ui/src/features/workspaces/__tests__/SessionTabs.test.ts index 016baab2..5bc639ae 100644 --- a/services/assistant-ui/src/features/workspaces/__tests__/SessionTabs.test.ts +++ b/services/assistant-ui/src/features/workspaces/__tests__/SessionTabs.test.ts @@ -75,4 +75,18 @@ describe('sessionTabs', () => { await wrapper.find('[data-testid="session-tab-rename"]').trigger('click') expect(wrapper.emitted('select')).toBeUndefined() }) + + it('renders vertical session controls without nesting the stop action in the selector', async () => { + const session = fakeSession() + const wrapper = mount(SessionTabs, { + props: { sessions: [session], activeId: session.id, orientation: 'vertical' }, + }) + + await wrapper.get(`[data-testid="session-tab-${session.id}"]`).trigger('click') + await wrapper.get(`[data-testid="session-tab-stop-${session.id}"]`).trigger('click') + + expect(wrapper.get('[data-testid="session-tabs"]').attributes('aria-label')).toBe('Agent sessions') + expect(wrapper.emitted('select')).toEqual([[session.id]]) + expect(wrapper.emitted('stop')).toEqual([[session.id]]) + }) }) 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 32d6a251..de23195d 100644 --- a/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts +++ b/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts @@ -159,7 +159,6 @@ async function mountView() { const wrapper = mount(WorkspaceView, { global: { plugins: [router], - stubs: { AgentKindPicker: true, SessionTabs: true }, }, }) await flush() @@ -263,6 +262,24 @@ describe('workspaceView terminal persistence', () => { expect(sendInput.mock.calls[0]?.[2]).not.toContain('large document') }) + it('keeps workspace controls in the sidebar', async () => { + getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a', gatewayAgentId: 'abc12345' })])) + + const wrapper = await mountView() + const sidebar = wrapper.get('[data-testid="workspace-sidebar"]') + + expect(wrapper.get('[data-testid="workspace-view-header"]').find('[data-testid="stage-input-open"]').exists()).toBe( + false, + ) + expect(sidebar.find('[data-testid="workspace-agent-panel"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="workspace-new-agent"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="workspace-tools-panel"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="stage-input-open"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="workspace-sessions-panel"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="session-tabs"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="workspace-repositories-panel"]').exists()).toBe(true) + }) + 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({ @@ -281,7 +298,7 @@ describe('workspaceView terminal persistence', () => { 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', + 'cd /workspace/primary && 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', @@ -307,6 +324,29 @@ describe('workspaceView terminal persistence', () => { ) }) + it('sends the split command to the active running session', 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', gatewayAgentId: 'abc12345' })], { repositories: [primary, destination] }), + ) + + const wrapper = await mountView() + await wrapper.find('[data-testid="split-send-command"]').trigger('click') + await flush() + + expect(sendInput).toHaveBeenCalledWith( + 'ws-1', + 'sess-a', + 'cd /workspace/primary && council split --path path/to/subtree --dest owner/split-dest', + true, + ) + }) + 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({ @@ -321,7 +361,7 @@ describe('workspaceView terminal persistence', () => { fakeRepository({ id: 'repo-primary', name: 'primary' }), fakeRepository({ id: 'repo-extra', name: 'extra', repoUrl: 'git@github.com:owner/extra.git' }), ]) - attachRepository.mockResolvedValue([extra]) + attachRepository.mockResolvedValue(undefined) const wrapper = await mountView() await wrapper.find('[data-testid="workspace-add-repository"]').trigger('click') 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 9b347f37..778607c0 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 @@ -178,9 +178,9 @@ describe('useWorkspacesStore', () => { expect(store.activeWorkspace).toBeNull() }) - it('attachRepository attaches to the active workspace and refreshes detail', async () => { + it('attachRepository refreshes detail without reading an attach response body', async () => { const repo = fakeRepository({ id: 'repo-a' }) - mocked.attachRepository.mockResolvedValue([repo]) + mocked.attachRepository.mockResolvedValue() mocked.getWorkspace.mockResolvedValue({ workspace: { ...fakeWorkspace(), repositories: [repo] }, sessions: [], diff --git a/services/assistant-ui/src/features/workspaces/components/AgentKindPicker.vue b/services/assistant-ui/src/features/workspaces/components/AgentKindPicker.vue index b04291e7..cae90e57 100644 --- a/services/assistant-ui/src/features/workspaces/components/AgentKindPicker.vue +++ b/services/assistant-ui/src/features/workspaces/components/AgentKindPicker.vue @@ -1,7 +1,9 @@