From 085b057471ddb7357a04fc905aac7a3314fa3603 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Thu, 27 Aug 2026 10:55:16 +0200 Subject: [PATCH 1/2] fix(api): raise domain exceptions where the caller needs the message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `error(…)` throws IllegalStateException, whose message the shared advice now withholds (JorisJonkers-dev/kotlin-spring-commons#34). Most uses here are internal invariants and should stay withheld — "No handler registered for X", "not visible immediately after create", "empty response from gateway". These are not. Two conflicts the caller caused and needs to read become DomainExceptions with codes, so the message survives as a 400: - CreateProjectCommandHandler SLUG_ALREADY_IN_USE - CreateRepositoryCommandHandler REPOSITORY_ALREADY_REGISTERED Six lookups of an entity the caller named become NotFoundException, which is both the right status and a detail worth reading — they were 409s: - LinkRepositoryToProjectCommandHandler project, repository - AddGithubLinkCommandHandler project - OpenPullRequestCommandHandler workspace - AgentSessionController session (x2), workspace Deliberately unchanged: SendUserInputCommandHandler's generation conflict and AgentSessionController.restartConflict, which the UI reads as a 409 to drive its reattach flow. That contract is status-only, so the generic detail does not affect it. Co-Authored-By: Claude Opus 5 (1M context) --- .../command/AddGithubLinkCommandHandler.kt | 3 +- .../command/CreateProjectCommandHandler.kt | 6 +++- .../command/CreateRepositoryCommandHandler.kt | 9 +++++- .../LinkRepositoryToProjectCommandHandler.kt | 5 ++-- .../command/OpenPullRequestCommandHandler.kt | 5 +++- .../web/AgentSessionController.kt | 9 ++++-- .../AddGithubLinkCommandHandlerTest.kt | 15 ++++++---- .../CreateProjectCommandHandlerTest.kt | 13 +++++++-- .../CreateRepositoryCommandHandlerTest.kt | 17 +++++++---- ...nkRepositoryToProjectCommandHandlerTest.kt | 24 +++++++++++---- .../OpenPullRequestCommandHandlerTest.kt | 12 ++++++-- .../web/AgentSessionControllerTest.kt | 29 +++++++++++++++++++ 12 files changed, 116 insertions(+), 31 deletions(-) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandler.kt index d70824b..6faa30d 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandler.kt @@ -4,6 +4,7 @@ import com.jorisjonkers.personalstack.agents.domain.model.GithubLink import com.jorisjonkers.personalstack.agents.domain.port.GithubLinkRepository import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.NotFoundException import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -22,7 +23,7 @@ class AddGithubLinkCommandHandler( override fun handle(command: AddGithubLinkCommand) { val project = projects.findById(command.projectId) - ?: error("project not found: ${command.projectId}") + ?: throw NotFoundException("Project", command.projectId.value.toString()) require(command.name.isNotBlank()) { "link name must not be blank" } require(command.repoUrl.isNotBlank()) { "repo URL must not be blank" } val now = Instant.now() diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandler.kt index 81c9a12..3646d1c 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandler.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agents.application.command import com.jorisjonkers.personalstack.agents.domain.model.Project import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.DomainException import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -19,7 +20,10 @@ class CreateProjectCommandHandler( } val existing = projects.findBySlug(command.slug) if (existing != null && existing.id != command.projectId) { - error("slug already in use: ${command.slug}") + // DomainException, not error(…): the caller picked this slug and + // needs to read which one clashed. `error(…)` throws + // IllegalStateException, whose message the advice withholds. + throw DomainException("Slug already in use: ${command.slug}", "SLUG_ALREADY_IN_USE") } val now = Instant.now() projects.save( diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandler.kt index d1bfee7..d5f6eb2 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandler.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agents.application.command import com.jorisjonkers.personalstack.agents.domain.model.Repository import com.jorisjonkers.personalstack.agents.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.DomainException import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -25,7 +26,13 @@ class CreateRepositoryCommandHandler( // a migration). Dedup on the URL instead. val existing = repositories.findByRepoUrl(command.repoUrl.trim()) if (existing != null && existing.id != command.repositoryId) { - error("repository url already registered: ${command.repoUrl.trim()}") + // DomainException, not error(…): the caller supplied this URL and + // needs to read it back. `error(…)` throws IllegalStateException, + // whose message the advice withholds. + throw DomainException( + "Repository URL already registered: ${command.repoUrl.trim()}", + "REPOSITORY_ALREADY_REGISTERED", + ) } val now = Instant.now() repositories.save( diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandler.kt index dc83437..4fd7abb 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandler.kt @@ -4,6 +4,7 @@ import com.jorisjonkers.personalstack.agents.domain.port.ProjectRepositoryReposi import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository import com.jorisjonkers.personalstack.agents.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.NotFoundException import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional @@ -16,9 +17,9 @@ class LinkRepositoryToProjectCommandHandler( @Transactional override fun handle(command: LinkRepositoryToProjectCommand) { projects.findById(command.projectId) - ?: error("project not found: ${command.projectId}") + ?: throw NotFoundException("Project", command.projectId.value.toString()) repositories.findById(command.repositoryId) - ?: error("repository not found: ${command.repositoryId}") + ?: throw NotFoundException("Repository", command.repositoryId.value.toString()) junction.link(command.projectId, command.repositoryId) } } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandler.kt index 67ee783..c40bb51 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandler.kt @@ -5,6 +5,7 @@ import com.jorisjonkers.personalstack.agents.domain.model.Workspace import com.jorisjonkers.personalstack.agents.domain.port.AgentGatewayClient import com.jorisjonkers.personalstack.agents.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.NotFoundException import org.springframework.stereotype.Component /** @@ -20,7 +21,9 @@ class OpenPullRequestCommandHandler( private val gateway: AgentGatewayClient, ) : CommandHandler { override fun handle(command: OpenPullRequestCommand) { - val workspace = workspaces.findById(command.workspaceId) ?: error("workspace not found: ${command.workspaceId}") + val workspace = + workspaces.findById(command.workspaceId) + ?: throw NotFoundException("Workspace", command.workspaceId.value.toString()) require(!workspace.hasRunnerSetupGuard()) { "workspace runner setup operation is in progress: ${workspace.id.value}" } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt index 01da1f9..c5d0f81 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt @@ -25,6 +25,7 @@ import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.StagedInputR import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.StartAgentSessionRequest import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.TurnResponse import com.jorisjonkers.personalstack.common.command.CommandBus +import com.jorisjonkers.personalstack.common.exception.NotFoundException import com.jorisjonkers.personalstack.common.web.ProblemDetail import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema @@ -213,9 +214,11 @@ class AgentSessionController( val workspaceModelId = WorkspaceId(workspaceId) val session = sessions.findById(WorkspaceAgentSessionId(sessionId)) - ?: error("session not found: $sessionId") + ?: throw NotFoundException("AgentSession", sessionId.toString()) require(session.workspaceId == workspaceModelId) { "session does not belong to workspace: $sessionId" } - val workspace = workspaces.findById(workspaceModelId) ?: error("workspace not found: $workspaceId") + val workspace = + workspaces.findById(workspaceModelId) + ?: throw NotFoundException("Workspace", workspaceId.toString()) val gatewayAgentId = session.gatewayAgentId ?: throw AgentRunnerUnavailableException( @@ -254,7 +257,7 @@ class AgentSessionController( val workspaceModelId = WorkspaceId(workspaceId) val session = sessions.findById(WorkspaceAgentSessionId(sessionId)) - ?: error("session not found: $sessionId") + ?: throw NotFoundException("AgentSession", sessionId.toString()) require(session.workspaceId == workspaceModelId) { "session does not belong to workspace: $sessionId" } return session } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandlerTest.kt index 67af759..a82e833 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandlerTest.kt @@ -6,6 +6,7 @@ import com.jorisjonkers.personalstack.agents.domain.model.Project import com.jorisjonkers.personalstack.agents.domain.model.ProjectId import com.jorisjonkers.personalstack.agents.domain.port.GithubLinkRepository import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository +import com.jorisjonkers.personalstack.common.exception.NotFoundException import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -56,11 +57,15 @@ class AddGithubLinkCommandHandlerTest { fun `handle errors on unknown project`() { val missing = ProjectId.random() every { projects.findById(missing) } returns null - assertThrows { - handler.handle( - AddGithubLinkCommand(GithubLinkId.random(), missing, "x", "git@github.com:o/r.git"), - ) - } + + val ex = + assertThrows { + handler.handle( + AddGithubLinkCommand(GithubLinkId.random(), missing, "x", "git@github.com:o/r.git"), + ) + } + + assertThat(ex.message).contains("Project", missing.value.toString()) } @Test diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandlerTest.kt index fc2eadb..c084eb7 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandlerTest.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agents.application.command import com.jorisjonkers.personalstack.agents.domain.model.Project import com.jorisjonkers.personalstack.agents.domain.model.ProjectId import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository +import com.jorisjonkers.personalstack.common.exception.DomainException import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -65,8 +66,14 @@ class CreateProjectCommandHandlerTest { updatedAt = Instant.now(), ) every { projects.findBySlug("shared") } returns existing - assertThrows { - handler.handle(CreateProjectCommand(ProjectId.random(), "Mine", "shared")) - } + // DomainException, not IllegalStateException: the caller picked the + // slug, so the advice has to be allowed to hand the message back. + val ex = + assertThrows { + handler.handle(CreateProjectCommand(ProjectId.random(), "Mine", "shared")) + } + + assertThat(ex.code).isEqualTo("SLUG_ALREADY_IN_USE") + assertThat(ex.message).contains("shared") } } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandlerTest.kt index deab931..88d2936 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandlerTest.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agents.application.command import com.jorisjonkers.personalstack.agents.domain.model.Repository import com.jorisjonkers.personalstack.agents.domain.model.RepositoryId import com.jorisjonkers.personalstack.agents.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.common.exception.DomainException import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -89,10 +90,16 @@ class CreateRepositoryCommandHandlerTest { updatedAt = Instant.now(), ) every { repositories.findByRepoUrl("git@github.com:owner/repo.git") } returns existing - assertThrows { - handler.handle( - CreateRepositoryCommand(RepositoryId.random(), "agents", "git@github.com:owner/repo.git"), - ) - } + // DomainException, not IllegalStateException: the caller supplied the + // URL, so the advice has to be allowed to hand the message back. + val ex = + assertThrows { + handler.handle( + CreateRepositoryCommand(RepositoryId.random(), "agents", "git@github.com:owner/repo.git"), + ) + } + + assertThat(ex.code).isEqualTo("REPOSITORY_ALREADY_REGISTERED") + assertThat(ex.message).contains("git@github.com:owner/repo.git") } } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandlerTest.kt index 8606e19..e39a079 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandlerTest.kt @@ -7,9 +7,11 @@ import com.jorisjonkers.personalstack.agents.domain.model.RepositoryId import com.jorisjonkers.personalstack.agents.domain.port.ProjectRepositoryRepository import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository import com.jorisjonkers.personalstack.agents.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.common.exception.NotFoundException import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.time.Instant @@ -50,19 +52,29 @@ class LinkRepositoryToProjectCommandHandlerTest { @Test fun `handle errors on missing project`() { val r = repository() + val missing = ProjectId.random() every { projects.findById(any()) } returns null - assertThrows { - handler.handle(LinkRepositoryToProjectCommand(ProjectId.random(), r.id)) - } + + val ex = + assertThrows { + handler.handle(LinkRepositoryToProjectCommand(missing, r.id)) + } + + assertThat(ex.message).contains("Project", missing.value.toString()) } @Test fun `handle errors on missing repository`() { val p = project() + val missing = RepositoryId.random() every { projects.findById(p.id) } returns p every { repositories.findById(any()) } returns null - assertThrows { - handler.handle(LinkRepositoryToProjectCommand(p.id, RepositoryId.random())) - } + + val ex = + assertThrows { + handler.handle(LinkRepositoryToProjectCommand(p.id, missing)) + } + + assertThat(ex.message).contains("Repository", missing.value.toString()) } } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandlerTest.kt index e56c671..17f5a24 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandlerTest.kt @@ -7,9 +7,11 @@ import com.jorisjonkers.personalstack.agents.domain.model.WorkspaceId import com.jorisjonkers.personalstack.agents.domain.model.WorkspaceStatus import com.jorisjonkers.personalstack.agents.domain.port.AgentGatewayClient import com.jorisjonkers.personalstack.agents.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.common.exception.NotFoundException import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.time.Instant @@ -41,9 +43,13 @@ class OpenPullRequestCommandHandlerTest { fun `handle errors for unknown workspaces`() { val id = WorkspaceId.random() every { workspaces.findById(id) } returns null - assertThrows { - handler.handle(OpenPullRequestCommand(id, "/x", "t", "b")) - } + + val ex = + assertThrows { + handler.handle(OpenPullRequestCommand(id, "/x", "t", "b")) + } + + assertThat(ex.message).contains("Workspace", id.value.toString()) } @Test diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionControllerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionControllerTest.kt index 00a7a7c..4108ccd 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionControllerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionControllerTest.kt @@ -314,6 +314,35 @@ class AgentSessionControllerTest { ).andExpect(status().isServiceUnavailable) } + @Test + fun `unknown session is a 404 that still names what was missing`() { + // Was error(…) → a 409 whose message the advice now withholds. As a + // NotFoundException it is both the right status and a detail the + // caller can act on. + val mvc = + MockMvcBuilders + .standaloneSetup( + AgentSessionController( + commandBus, + turnHistory, + sessions, + workspaces, + gateway, + restartAgentSession, + ), + ).setControllerAdvice(GlobalExceptionHandler(), AgentRunnerUnavailableExceptionHandler()) + .build() + every { sessions.findById(sessionId) } returns null + + mvc + .perform( + post("/api/v1/workspaces/${workspaceId.value}/sessions/${sessionId.value}/staged-inputs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"content":"x","name":"n"}"""), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.detail").value("AgentSession not found: ${sessionId.value}")) + } + private fun agentSession() = WorkspaceAgentSession( id = sessionId, From 03550680ccbbb3b2a26ce012f104b79776d0c96e Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Thu, 27 Aug 2026 11:18:34 +0200 Subject: [PATCH 2/2] refactor(api): reuse requireSessionInWorkspace in stageInput The added NotFoundException took stageInput to three throws, one over detekt's ThrowsCount limit. The session lookup and ownership check it inlined were already requireSessionInWorkspace verbatim, so calling that removes the duplication and the violation together. Co-Authored-By: Claude Opus 5 (1M context) --- .../agents/infrastructure/web/AgentSessionController.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt index c5d0f81..d6be9a3 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt @@ -212,10 +212,7 @@ class AgentSessionController( @RequestBody req: StageInputRequest, ): ResponseEntity { val workspaceModelId = WorkspaceId(workspaceId) - val session = - sessions.findById(WorkspaceAgentSessionId(sessionId)) - ?: throw NotFoundException("AgentSession", sessionId.toString()) - require(session.workspaceId == workspaceModelId) { "session does not belong to workspace: $sessionId" } + val session = requireSessionInWorkspace(workspaceId, sessionId) val workspace = workspaces.findById(workspaceModelId) ?: throw NotFoundException("Workspace", workspaceId.toString())