From 622b331de371ace5230c7332737aea211ab6f6e3 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 3 Jun 2026 13:18:17 +0400 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20Axis=202=20Phase=20B=20?= =?UTF-8?q?=E2=80=94=20client=E2=86=92server=20workspace=20handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the workspace handshake end to end so a session's workspace is bound from the client's cwd at connect time and is event-sourced for replay. - Go TUI sends Hello{workingDir=os.Getwd()} as the first WS frame on connect (protocol.go encoder + client.go connect path; golden test pins the wire format against the Kotlin discriminator). - Server adds ClientMessage.Hello, stashes the per-connection workingDir, and on session start resolves it through WorkspaceResolver's trust pipeline, emits SessionWorkspaceBoundEvent (invariant #9), and threads the resolved workspace into OrchestrationConfig for the live run. A Hello after the first StartSession is ignored (warn); a rejected path binds the resolver fallback. - Replay derives the workspace from the recorded event: SessionState gains boundWorkspace, DefaultSessionReducer fills it from SessionWorkspaceBoundEvent, and ReplayOrchestrator uses it (Path.of only, no filesystem re-query — invariant #8) with graceful fallback to config for pre-Phase-B logs. Absent Hello / null resolver degrades to the prior config-workspace behavior. --- .../kotlin/com/correx/apps/server/Main.kt | 14 + .../com/correx/apps/server/ServerModule.kt | 12 +- .../apps/server/protocol/ClientMessage.kt | 8 + .../apps/server/ws/GlobalStreamHandler.kt | 83 ++++- .../apps/server/ws/WorkspaceHandshakeTest.kt | 344 ++++++++++++++++++ apps/tui-go/internal/protocol/golden_test.go | 46 ++- apps/tui-go/internal/protocol/protocol.go | 6 + apps/tui-go/internal/ws/client.go | 4 + .../orchestration/ReplayOrchestrator.kt | 22 +- .../correx/core/sessions/BoundWorkspace.kt | 6 + .../core/sessions/DefaultSessionReducer.kt | 13 +- .../com/correx/core/sessions/SessionState.kt | 1 + testing/kernel/build.gradle | 8 + .../kotlin/ReplayWorkspaceDerivationTest.kt | 229 ++++++++++++ .../test/kotlin/DefaultSessionReducerTest.kt | 51 +++ 15 files changed, 836 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/ws/WorkspaceHandshakeTest.kt create mode 100644 core/sessions/src/main/kotlin/com/correx/core/sessions/BoundWorkspace.kt create mode 100644 testing/kernel/src/test/kotlin/ReplayWorkspaceDerivationTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 832647ab..0f8c5954 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -3,6 +3,7 @@ package com.correx.apps.server import com.correx.apps.server.logging.LoggingEventStore import com.correx.apps.server.registry.FileSystemWorkflowRegistry import com.correx.apps.server.registry.ProviderRegistry +import com.correx.apps.server.workspace.WorkspaceResolver import com.correx.core.approvals.domain.DefaultApprovalEngine import com.correx.core.artifacts.kind.ArtifactKind import com.correx.core.artifacts.kind.ConfigArtifactKind @@ -258,6 +259,18 @@ fun main() { artifactStore = artifactStore, bootRoots = setOf(workspaceRoot, workingDir), ) + val bootWorkspaceContext = WorkspaceContext( + workspaceRoot = workspaceRoot, + workingDir = workingDir, + allowedPaths = setOf(workspaceRoot, workingDir), + privilegedLocations = privilegedLocations, + ) + val allowedWorkspaceRoots = toolsConfig.allowedWorkspaceRoots.map { Path.of(it) } + val workspaceResolver = WorkspaceResolver( + bootDefault = bootWorkspaceContext, + privilegedLocations = privilegedLocations, + allowedWorkspaceRoots = allowedWorkspaceRoots, + ) val module = ServerModule( orchestrator = orchestrator, eventStore = eventStore, @@ -275,6 +288,7 @@ fun main() { sessionUndoService = sessionUndoService, modelSwapper = modelSwapper, resourceProbe = resourceProbe, + workspaceResolver = workspaceResolver, ) module.start() log.info("==============================") diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index c5a44560..1d48baeb 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -3,6 +3,7 @@ package com.correx.apps.server import com.correx.apps.server.approval.ApprovalCoordinator import com.correx.apps.server.registry.ProviderRegistry import com.correx.apps.server.registry.WorkflowRegistry +import com.correx.apps.server.workspace.WorkspaceResolver import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalRepository @@ -56,6 +57,9 @@ class ServerModule( // probe (static path / non-GPU host); the managed path wires an NVIDIA-backed probe. val resourceProbe: com.correx.infrastructure.inference.commons.ResourceProbe = com.correx.infrastructure.inference.commons.UnavailableProbe, + // Resolves a client-supplied cwd into a trusted WorkspaceContext using the trust pipeline + // (canonicalize → privileged check → allow-list clamp → boot fallback). + val workspaceResolver: WorkspaceResolver? = null, // Long-lived scope owned by the module — backs the event-store subscription. // SupervisorJob so one failure doesn't kill the whole module; // Dispatchers.Default since the work is non-blocking and CPU-light. @@ -110,11 +114,15 @@ class ServerModule( * run job is invisible to the guard and a second `resume()` is spuriously launched, * duplicating inference calls (the root cause of the healthcheck script corruption). */ - fun launchSessionRun(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) { + fun launchSessionRun( + sessionId: SessionId, + graph: com.correx.core.transitions.graph.WorkflowGraph, + sessionConfig: OrchestrationConfig = defaultOrchestrationConfig, + ) { activeSessionJobs.computeIfAbsent(sessionId) { moduleScope.launch { runCatching { - orchestrator.run(sessionId, graph, defaultOrchestrationConfig) + orchestrator.run(sessionId, graph, sessionConfig) }.onFailure { ex -> log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex) eventStore.append( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index ee3b6b4a..85db2c0b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -70,4 +70,12 @@ sealed class ClientMessage { /** Operator request to clear the manual-swap pin; per-stage selection resumes. */ @Serializable data object ClearModelPin : ClientMessage() + + /** + * First frame sent by the client on every (re)connect. Carries the client's + * working directory so the server can bind a workspace for subsequent sessions. + * Handled before any session starts; ignored (falls back to boot default) if absent. + */ + @Serializable + data class Hello(val workingDir: String) : ClientMessage() } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 2fad1d0e..83a50911 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -11,12 +11,14 @@ import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.WorkflowDto import com.correx.apps.server.protocol.StageToolDecl import com.correx.apps.server.protocol.ToolDecl +import com.correx.apps.server.workspace.WorkspaceResolution import com.correx.core.approvals.GrantScope import com.correx.core.approvals.Tier import com.correx.core.events.events.ApprovalGrantCreatedEvent import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.GrantId import com.correx.core.events.stores.EventStore @@ -24,6 +26,7 @@ import com.correx.core.events.types.EventId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.inference.ProviderHealth +import com.correx.core.kernel.orchestration.WorkspaceContext import com.correx.core.utils.TypeId import com.correx.infrastructure.inference.commons.ResourceProbe import com.correx.infrastructure.inference.commons.ResourceSnapshot @@ -68,6 +71,13 @@ class GlobalStreamHandler(private val module: ServerModule) { session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(it))) } + // Stash the working directory received in the Hello frame (first frame on connect). + // Null means no Hello was received; handleStartSession falls back to boot default. + var pendingWorkingDir: String? = null + // Guard: once StartSession has been handled the Hello window is closed. A late Hello + // has no session to bind to and its workingDir is silently stale — ignore it. + var sessionStarted = false + val bridge = SessionEventBridge( eventStore = module.eventStore, artifactStore = module.artifactStore, @@ -100,7 +110,11 @@ class GlobalStreamHandler(private val module: ServerModule) { .onSuccess { msg -> log.debug("recv: {}", msg::class.simpleName) runCatching { - handleClientMessage(session, msg, sendFrame) + handleClientMessage( + session, msg, sendFrame, pendingWorkingDir, sessionStarted, + onHello = { pendingWorkingDir = it }, + onSessionStarted = { sessionStarted = true }, + ) }.onFailure { log.error("handle failed", it) } @@ -180,10 +194,25 @@ class GlobalStreamHandler(private val module: ServerModule) { session: DefaultWebSocketServerSession, msg: ClientMessage, sendFrame: suspend (ServerMessage) -> Unit, + pendingWorkingDir: String?, + sessionStarted: Boolean, + onHello: (String) -> Unit, + onSessionStarted: () -> Unit, ) { when (msg) { is ClientMessage.Ping -> Unit - is ClientMessage.StartSession -> handleStartSession(session, msg, sendFrame) + is ClientMessage.Hello -> { + if (sessionStarted) { + log.warn("recv Hello after session already started (workingDir={}); ignoring", msg.workingDir) + } else { + log.info("recv Hello: workingDir={}", msg.workingDir) + onHello(msg.workingDir) + } + } + is ClientMessage.StartSession -> { + handleStartSession(session, msg, pendingWorkingDir) + onSessionStarted() + } is ClientMessage.StartChatSession -> handleStartChatSession(session, msg, sendFrame) is ClientMessage.CancelSession -> { runCatching { module.orchestrator.cancel(msg.sessionId) } @@ -353,7 +382,7 @@ class GlobalStreamHandler(private val module: ServerModule) { private suspend fun handleStartSession( session: DefaultWebSocketServerSession, msg: ClientMessage.StartSession, - sendFrame: suspend (ServerMessage) -> Unit, + workingDir: String?, ) { val graph = module.workflowRegistry.find(msg.workflowId) log.info("find returned: {}", graph) @@ -369,6 +398,48 @@ class GlobalStreamHandler(private val module: ServerModule) { val sessionId: SessionId = TypeId(UUID.randomUUID().toString()) log.info("starting session={} workflow={}", sessionId.value, msg.workflowId) + // Resolve workspace from the Hello-frame working directory (invariant #9: record the + // resolver's decision at handshake time as an event; replay reads the recorded fact). + val resolvedWorkspace: WorkspaceContext? = module.workspaceResolver?.let { resolver -> + val resolution = withContext(Dispatchers.IO) { resolver.resolve(workingDir) } + when (resolution) { + is WorkspaceResolution.Bound -> { + log.info("workspace bound: root={}", resolution.workspace.workspaceRoot) + resolution.workspace + } + is WorkspaceResolution.Rejected -> { + // Deliberate: a rejected client path still binds the resolver's fallback + // workspace. The fallback root is recorded in SessionWorkspaceBoundEvent so + // replay reproduces the same binding; the rejection reason is audit-logged + // here and does NOT become a field on the event (YAGNI). + log.warn( + "workspace rejected: session={} reason={} fallback={}", + sessionId.value, resolution.reason, resolution.fallback.workspaceRoot, + ) + resolution.fallback + } + } + } + + // Emit SessionWorkspaceBoundEvent when a workspace was resolved (invariant #9). + resolvedWorkspace?.let { ws -> + module.eventStore.append(NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = SessionWorkspaceBoundEvent( + sessionId = sessionId, + workspaceRoot = ws.workspaceRoot.toString(), + allowedPaths = ws.allowedPaths.map { it.toString() }, + ), + )) + } + // Send StageToolManifest FIRST. If the WS is already closed, this throws and the // orchestrator never launches — no silent data loss. The exception propagates to the // runCatching in handleClientMessage (caller) and is logged there. @@ -391,10 +462,14 @@ class GlobalStreamHandler(private val module: ServerModule) { ), ))) + // Thread the resolved workspace into the OrchestrationConfig so the live run uses + // the bound workspace root for tool path containment and per-session tool registry. + val sessionConfig = module.defaultOrchestrationConfig.copy(workspace = resolvedWorkspace) + // Only launch orchestrator after protocol messages are delivered successfully. // Uses launchSessionRun() so the job is tracked in activeSessionJobs — this prevents // the OrchestrationResumedEvent subscription from spuriously launching a duplicate resume. - module.launchSessionRun(sessionId, graph) + module.launchSessionRun(sessionId, graph, sessionConfig) } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/ws/WorkspaceHandshakeTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/ws/WorkspaceHandshakeTest.kt new file mode 100644 index 00000000..00468c29 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/ws/WorkspaceHandshakeTest.kt @@ -0,0 +1,344 @@ +package com.correx.apps.server.ws + +import com.correx.apps.server.ServerModule +import com.correx.apps.server.configureServer +import com.correx.apps.server.protocol.ClientMessage +import com.correx.apps.server.registry.ProviderRegistry +import com.correx.apps.server.registry.WorkflowRegistry +import com.correx.apps.server.registry.WorkflowSummary +import com.correx.apps.server.undo.SessionUndoService +import com.correx.apps.server.workspace.WorkspaceResolver +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.SessionWorkspaceBoundEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.StageId +import com.correx.core.inference.DefaultInferenceRouter +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.ProviderHealth +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.orchestration.WorkspaceContext +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.infrastructure.InfrastructureModule +import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy +import com.correx.infrastructure.inference.commons.UnavailableProbe +import com.correx.infrastructure.persistence.InMemoryEventStore +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.server.testing.testApplication +import io.ktor.websocket.Frame +import kotlinx.coroutines.delay +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * Focused integration tests for the Axis-2 Phase-B workspace handshake. + * + * Protocol sequence under test: + * Hello(workingDir) → StartSession(workflowId) → SessionWorkspaceBoundEvent emitted + * + * Absent-Hello fallback: + * StartSession(workflowId) → no crash, no SessionWorkspaceBoundEvent (resolver not called) + * + * Note: the orchestrator is not driven to completion; we only assert on the event log + * after the handshake is processed and before the orchestrator runs far enough to interfere. + */ +class WorkspaceHandshakeTest { + + private val protocolJson = Json { classDiscriminator = "type"; ignoreUnknownKeys = true } + private fun encode(msg: ClientMessage): String = protocolJson.encodeToString(msg) + + // ---- infrastructure helpers ------------------------------------------------ + + private val noopArtifactStore: ArtifactStore = object : ArtifactStore { + override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop") + override suspend fun get(id: ArtifactId): ByteArray? = null + override suspend fun flushBefore(commit: suspend () -> Unit) = commit() + } + + private val noopProviderRegistry: ProviderRegistry = object : ProviderRegistry { + override fun listAll() = emptyList() + override suspend fun healthCheckAll() = emptyMap() + } + + /** + * An event store that delegates to [InMemoryEventStore] but also copies each appended + * event into [appended] for inspection. + */ + private fun capturingEventStore(): Pair> { + val delegate = InMemoryEventStore() + val captured = ConcurrentLinkedQueue() + val store = object : EventStore by delegate { + override suspend fun append(event: NewEvent): StoredEvent { + captured.add(event) + return delegate.append(event) + } + } + return store to captured + } + + private fun buildWorkflowRegistry(graph: WorkflowGraph): WorkflowRegistry = object : WorkflowRegistry { + override fun listAll() = listOf(WorkflowSummary(graph.id, description = "")) + override fun find(workflowId: String) = if (workflowId == graph.id) graph else null + } + + private fun minimalGraph(): WorkflowGraph = WorkflowGraph( + id = "handshake-test-workflow", + stages = mapOf(StageId("s1") to StageConfig(allowedTools = emptySet())), + transitions = emptySet(), + start = StageId("s1"), + ) + + private fun buildModule( + eventStore: EventStore, + workflowRegistry: WorkflowRegistry, + workspaceResolver: WorkspaceResolver?, + tempDir: Path, + ): ServerModule { + val provider = InfrastructureModule.createLlamaCppProvider( + modelId = "test-model", + modelPath = "/dev/null", + baseUrl = "http://127.0.0.1:1", + ) + val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(provider)) + val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy()) + + val toolConfig = com.correx.infrastructure.tools.ToolConfig( + shell = com.correx.infrastructure.tools.ShellConfig( + enabled = false, allowedExecutables = emptySet(), workingDir = tempDir, + ), + fileRead = com.correx.infrastructure.tools.FileReadConfig( + enabled = false, allowedPaths = setOf(tempDir), + ), + fileWrite = com.correx.infrastructure.tools.FileWriteConfig( + enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir, + ), + fileEdit = com.correx.infrastructure.tools.FileEditConfig( + enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir, + ), + ) + val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig) + val eventDispatcher = com.correx.core.events.EventDispatcher(eventStore) + val toolExecutor = InfrastructureModule.createToolExecutor( + registry = toolRegistry, + eventDispatcher = eventDispatcher, + workDir = tempDir, + artifactStore = null, + ) + + val engines = OrchestratorEngines( + transitionResolver = com.correx.core.transitions.resolution.DefaultTransitionResolver { + condition, ctx -> condition.evaluate(ctx) + }, + contextPackBuilder = com.correx.core.context.builder.DefaultContextPackBuilder( + com.correx.core.context.compression.DefaultContextCompressor(), + ), + inferenceRouter = inferenceRouter, + validationPipeline = com.correx.core.validation.pipeline.ValidationPipeline(validators = emptyList()), + approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(), + riskAssessor = com.correx.core.risk.DefaultRiskAssessor(), + toolRegistry = toolRegistry, + toolExecutor = toolExecutor, + ) + + val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = com.correx.core.inference.InferenceRepository( + DefaultEventReplayer(eventStore, com.correx.core.inference.InferenceProjector()), + ), + orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ), + sessionRepository = DefaultSessionRepository( + DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), + ), + artifactRepository = InfrastructureModule.createArtifactRepository(eventStore), + approvalRepository = InfrastructureModule.createApprovalRepository(eventStore), + ) + + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines, + retryCoordinator = DefaultRetryCoordinator(eventStore), + artifactStore = noopArtifactStore, + tokenizer = provider.tokenizer, + ) + + val routerFacade = InfrastructureModule.createRouterFacade( + eventStore = eventStore, + inferenceRouter = inferenceRouter, + config = com.correx.core.router.model.RouterConfig(), + tokenizer = provider.tokenizer, + ) + + val sessionUndoService = SessionUndoService( + eventStore = eventStore, + artifactStore = noopArtifactStore, + bootRoots = setOf(tempDir), + ) + + return ServerModule( + orchestrator = orchestrator, + eventStore = eventStore, + artifactStore = noopArtifactStore, + sessionRepository = repositories.sessionRepository, + workflowRegistry = workflowRegistry, + providerRegistry = noopProviderRegistry, + defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir), + routerFacade = routerFacade, + orchestrationRepository = repositories.orchestrationRepository, + approvalRepository = repositories.approvalRepository, + toolRegistry = toolRegistry, + sessionUndoService = sessionUndoService, + resourceProbe = UnavailableProbe, + workspaceResolver = workspaceResolver, + ) + } + + // ---- tests ----------------------------------------------------------------- + + @Test + fun `Hello followed by StartSession emits SessionWorkspaceBoundEvent with resolved workspace`( + @TempDir tempDir: Path, + ) { + val (eventStore, captured) = capturingEventStore() + val bootWorkspace = WorkspaceContext( + workspaceRoot = tempDir, + workingDir = tempDir, + allowedPaths = setOf(tempDir), + ) + val resolver = WorkspaceResolver( + bootDefault = bootWorkspace, + privilegedLocations = emptyList(), + allowedWorkspaceRoots = emptyList(), + ) + val module = buildModule(eventStore, buildWorkflowRegistry(minimalGraph()), resolver, tempDir) + module.start() + + testApplication { + application { configureServer(module) } + val client = createClient { install(WebSockets) } + + client.webSocket("/stream") { + // Drain initial snapshot (WorkflowList, provider health, ResourceStatus) + delay(100) + + // Phase 1: send Hello with the tempDir as workingDir + send(Frame.Text(encode(ClientMessage.Hello(workingDir = tempDir.toString())))) + delay(50) + + // Phase 2: start a session + send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)))) + + // Give the server time to process the event emission (before orchestrator runs) + delay(200) + } + } + + val bound = captured.firstOrNull { it.payload is SessionWorkspaceBoundEvent } + assertNotNull(bound, "SessionWorkspaceBoundEvent must be emitted after Hello + StartSession") + + val payload = bound!!.payload as SessionWorkspaceBoundEvent + assert(payload.workspaceRoot == tempDir.toRealPath().toString()) { + "workspaceRoot must equal resolved tempDir, got: ${payload.workspaceRoot}" + } + } + + @Test + fun `Hello after StartSession is ignored - first session workspace is unaffected`( + @TempDir tempDir: Path, + ) { + // Sequence: Hello(dir1) → StartSession → Hello(dir2) [late, must be ignored] + // The SessionWorkspaceBoundEvent must record dir1's resolution, not dir2. + val (eventStore, captured) = capturingEventStore() + val bootWorkspace = WorkspaceContext( + workspaceRoot = tempDir, + workingDir = tempDir, + allowedPaths = setOf(tempDir), + ) + val resolver = WorkspaceResolver( + bootDefault = bootWorkspace, + privilegedLocations = emptyList(), + allowedWorkspaceRoots = emptyList(), + ) + val module = buildModule(eventStore, buildWorkflowRegistry(minimalGraph()), resolver, tempDir) + module.start() + + testApplication { + application { configureServer(module) } + val client = createClient { install(WebSockets) } + + client.webSocket("/stream") { + delay(100) + + // Hello with the real tempDir + send(Frame.Text(encode(ClientMessage.Hello(workingDir = tempDir.toString())))) + delay(50) + + // StartSession — this closes the Hello window + send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)))) + delay(100) + + // Late Hello with a different path — must be ignored + send(Frame.Text(encode(ClientMessage.Hello(workingDir = "/tmp/late-hello-should-be-ignored")))) + delay(200) + } + } + + // Exactly one SessionWorkspaceBoundEvent, recording tempDir (not the late Hello path) + val boundEvents = captured.filter { it.payload is SessionWorkspaceBoundEvent } + assert(boundEvents.size == 1) { "Expected exactly 1 SessionWorkspaceBoundEvent, got ${boundEvents.size}" } + val payload = boundEvents.first().payload as SessionWorkspaceBoundEvent + assert(payload.workspaceRoot == tempDir.toRealPath().toString()) { + "workspaceRoot must equal first Hello's resolved dir, got: ${payload.workspaceRoot}" + } + } + + @Test + fun `absent Hello causes graceful fallback - no crash and no SessionWorkspaceBoundEvent when resolver is null`( + @TempDir tempDir: Path, + ) { + // workspaceResolver = null simulates the legacy no-resolver path (Phase A default). + // No Hello is sent. The server must not crash and must not emit SessionWorkspaceBoundEvent. + val (eventStore, captured) = capturingEventStore() + val module = buildModule(eventStore, buildWorkflowRegistry(minimalGraph()), null, tempDir) + module.start() + + testApplication { + application { configureServer(module) } + val client = createClient { install(WebSockets) } + + client.webSocket("/stream") { + delay(100) + + // No Hello — send StartSession directly + send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)))) + delay(200) + } + } + + val bound = captured.firstOrNull { it.payload is SessionWorkspaceBoundEvent } + assertNull(bound, "No SessionWorkspaceBoundEvent must be emitted when workspaceResolver is null") + } +} diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index ca3a66d2..2660eb3a 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -1,6 +1,9 @@ package protocol -import "testing" +import ( + "encoding/json" + "testing" +) // Golden wire frames as emitted by the Kotlin server (kotlinx.serialization). These // strings are the cross-language contract: the Kotlin side pins the encode in @@ -111,3 +114,44 @@ func TestDecodeGoldenServerFrames(t *testing.T) { }) } } + +// TestEncodeGoldenClientFrames pins the Go→Kotlin wire format for client messages. +// The discriminator is the fully-qualified Kotlin class name (kotlinx default for +// sealed classes without @SerialName), keyed under "type". These bytes must be +// accepted verbatim by the Kotlin server's ProtocolSerializer / Json decoder. +func TestEncodeGoldenClientFrames(t *testing.T) { + cases := []struct { + name string + frame []byte + wantType string + check func(t *testing.T, raw map[string]any) + }{ + { + // Hello is the first frame on every (re)connect — carries the client cwd so + // the server can bind a workspace before any session starts. + name: "Hello carries workingDir", + frame: Hello("/home/kami/Projects/correx"), + wantType: clientPrefix + "Hello", + check: func(t *testing.T, raw map[string]any) { + if raw["workingDir"] != "/home/kami/Projects/correx" { + t.Fatalf("Hello workingDir = %v", raw["workingDir"]) + } + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var raw map[string]any + if err := json.Unmarshal(c.frame, &raw); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if raw["type"] != c.wantType { + t.Fatalf("type = %q, want %q", raw["type"], c.wantType) + } + if c.check != nil { + c.check(t, raw) + } + }) + } +} diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 2bf5aa81..35f5da9c 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -254,3 +254,9 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN "toolName": toolName, }) } + +// Hello is the first frame sent on every (re)connect. It carries the client's +// working directory so the server can bind a workspace before any session starts. +func Hello(workingDir string) []byte { + return encode("Hello", map[string]any{"workingDir": workingDir}) +} diff --git a/apps/tui-go/internal/ws/client.go b/apps/tui-go/internal/ws/client.go index e08350d0..e2dec59e 100644 --- a/apps/tui-go/internal/ws/client.go +++ b/apps/tui-go/internal/ws/client.go @@ -6,6 +6,7 @@ package ws import ( "context" "net/url" + "os" "time" "github.com/correx/tui-go/internal/protocol" @@ -102,6 +103,9 @@ func (c *Client) Run(ctx context.Context) { backoff = initialBackoff attempt = 0 c.emit(Status{Connected: true}) + if cwd, err := os.Getwd(); err == nil { + _ = conn.WriteMessage(websocket.TextMessage, protocol.Hello(cwd)) + } c.pump(ctx, conn) c.emit(Status{Reconnecting: true}) } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt index 5235adfc..279751cb 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt @@ -23,6 +23,7 @@ import com.correx.core.transitions.graph.WorkflowGraph import com.correx.core.transitions.resolution.TransitionDecision import com.correx.core.validation.model.ValidationContext import org.slf4j.LoggerFactory +import java.nio.file.Path import java.util.* import java.util.concurrent.* import java.util.concurrent.atomic.* @@ -65,10 +66,25 @@ class ReplayOrchestrator( graph: WorkflowGraph, config: OrchestrationConfig, ): WorkflowResult { - emitWorkflowStarted(sessionId, graph, config) - val session = repositories.sessionRepository.getSession(sessionId) - val ctx = ReplayContext(graph, sessionId, graph.start, 0, config) + + // Derive workspace from the recorded SessionWorkspaceBoundEvent (invariants #1, #8, #9). + // The resolver ran at handshake time; replay reads its recorded decision — no live FS calls. + val effectiveConfig = session.state.boundWorkspace + ?.let { bw -> + config.copy( + workspace = WorkspaceContext( + workspaceRoot = Path.of(bw.workspaceRoot), + workingDir = Path.of(bw.workspaceRoot), + allowedPaths = bw.allowedPaths.map { Path.of(it) }.toSet(), + ) + ) + } + ?: config + + emitWorkflowStarted(sessionId, graph, effectiveConfig) + + val ctx = ReplayContext(graph, sessionId, graph.start, 0, effectiveConfig) // Execute the start stage before entering the step loop return when (val result = enterStage(ctx, graph.start, session)) { diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundWorkspace.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundWorkspace.kt new file mode 100644 index 00000000..ff34ace3 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundWorkspace.kt @@ -0,0 +1,6 @@ +package com.correx.core.sessions + +data class BoundWorkspace( + val workspaceRoot: String, + val allowedPaths: List, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt index d570408e..2d09a601 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt @@ -1,5 +1,6 @@ package com.correx.core.sessions +import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StoredEvent @@ -29,10 +30,20 @@ class DefaultSessionReducer : SessionReducer { val createdAt = state.createdAt ?: event.metadata.timestamp + val boundWorkspace = when (payload) { + is SessionWorkspaceBoundEvent -> + BoundWorkspace( + workspaceRoot = payload.workspaceRoot, + allowedPaths = payload.allowedPaths, + ) + else -> state.boundWorkspace + } + return state.copy( status = newStatus, createdAt = createdAt, - updatedAt = event.metadata.timestamp + updatedAt = event.metadata.timestamp, + boundWorkspace = boundWorkspace, ) } } diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt index d087e90b..c5039e99 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt @@ -7,4 +7,5 @@ data class SessionState( val createdAt: Instant? = null, val updatedAt: Instant? = null, val invalidTransitions: Int = 0, + val boundWorkspace: BoundWorkspace? = null, ) diff --git a/testing/kernel/build.gradle b/testing/kernel/build.gradle index 899e30b1..6b97d198 100644 --- a/testing/kernel/build.gradle +++ b/testing/kernel/build.gradle @@ -14,6 +14,14 @@ dependencies { testImplementation "org.jetbrains.kotlin:kotlin-test" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test" testImplementation project(':infrastructure:persistence') + testImplementation project(':core:artifacts') + testImplementation project(':core:artifacts-store') + testImplementation project(':core:approvals') + testImplementation project(':core:context') + testImplementation project(':core:transitions') + testImplementation project(':core:validation') + testImplementation project(':core:risk') + testImplementation project(':core:tools') } tasks.named("koverVerify").configure { enabled = false } diff --git a/testing/kernel/src/test/kotlin/ReplayWorkspaceDerivationTest.kt b/testing/kernel/src/test/kotlin/ReplayWorkspaceDerivationTest.kt new file mode 100644 index 00000000..a82f6cfa --- /dev/null +++ b/testing/kernel/src/test/kotlin/ReplayWorkspaceDerivationTest.kt @@ -0,0 +1,229 @@ +import com.correx.core.approvals.ApprovalProjector +import com.correx.core.approvals.DefaultApprovalReducer +import com.correx.core.approvals.DefaultApprovalRepository +import com.correx.core.artifacts.DefaultArtifactReducer +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.context.builder.ContextPackBuilder +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextPack +import com.correx.core.context.model.TokenBudget +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.SessionWorkspaceBoundEvent +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.orchestration.OrchestrationState +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.ContextPackId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.InferenceRouter +import com.correx.core.inference.InferenceState +import com.correx.core.inference.ModelCapability +import com.correx.core.kernel.execution.ReplayStrategy +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.orchestration.ReplayOrchestrator +import com.correx.core.kernel.orchestration.WorkspaceContext +import com.correx.core.kernel.orchestration.WorkspaceToolRegistryProvider +import com.correx.core.kernel.orchestration.WorkspaceTools +import com.correx.core.risk.NoOpRiskAssessor +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.registry.ToolRegistry +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionCondition +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.TransitionDecision +import com.correx.core.transitions.resolution.TransitionResolver +import com.correx.core.utils.TypeId +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository +import com.correx.testing.fixtures.InferenceFixtures +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +class ReplayWorkspaceDerivationTest { + + private val sessionId = SessionId("s-replay-ws") + private val stageId = StageId("stage-1") + + /** Spy: records the WorkspaceContext passed to forWorkspace(). */ + private val capturedWorkspace = AtomicReference(null) + private val spyProvider = WorkspaceToolRegistryProvider { ws -> + capturedWorkspace.set(ws) + WorkspaceTools( + registry = object : ToolRegistry { + override fun resolve(name: String): Tool? = null + override fun all(): List = emptyList() + }, + executor = object : ToolExecutor { + override suspend fun execute(request: ToolRequest): ToolResult = + ToolResult.Success(invocationId = request.invocationId, output = "") + }, + ) + } + + @Test + fun `replay derives workspace from SessionWorkspaceBoundEvent`(): Unit = runBlocking { + val store = InMemoryEventStore() + val recordedRoot = "/recorded/workspace" + seedSessionWorkspaceBoundEvent(store, workspaceRoot = recordedRoot) + + val orchestrator = buildOrchestrator(store) + val config = buildConfig(defaultWorkspaceRoot = "/default/workspace") + + orchestrator.run(sessionId, buildGraph(), config) + + assertEquals(recordedRoot, capturedWorkspace.get()?.workspaceRoot?.toString()) + } + + @Test + fun `replay falls back to config workspace when no SessionWorkspaceBoundEvent`(): Unit = runBlocking { + val store = InMemoryEventStore() + val defaultRoot = "/default/workspace" + + val orchestrator = buildOrchestrator(store) + val config = buildConfig(defaultWorkspaceRoot = defaultRoot) + + orchestrator.run(sessionId, buildGraph(), config) + + assertEquals(defaultRoot, capturedWorkspace.get()?.workspaceRoot?.toString()) + } + + // ---- helpers ---- + + private suspend fun seedSessionWorkspaceBoundEvent(store: InMemoryEventStore, workspaceRoot: String) { + store.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId("e-ws"), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = SessionWorkspaceBoundEvent( + sessionId = sessionId, + workspaceRoot = workspaceRoot, + allowedPaths = listOf(workspaceRoot), + ), + ) + ) + } + + private fun buildConfig(defaultWorkspaceRoot: String) = OrchestrationConfig( + replayStrategy = ReplayStrategy.SkipInference, + workspace = WorkspaceContext( + workspaceRoot = Path.of(defaultWorkspaceRoot), + workingDir = Path.of(defaultWorkspaceRoot), + allowedPaths = setOf(Path.of(defaultWorkspaceRoot)), + ), + ) + + private fun buildGraph(): WorkflowGraph { + val terminal = StageId("terminal") + return WorkflowGraph( + id = "test-wf", + start = stageId, + stages = mapOf( + stageId to StageConfig(produces = emptyList(), allowedTools = emptySet()), + terminal to StageConfig(produces = emptyList(), allowedTools = emptySet()), + ), + transitions = setOf( + TransitionEdge( + id = com.correx.core.events.types.TransitionId("t1"), + from = stageId, + to = terminal, + condition = TransitionCondition { _ -> true }, + ) + ), + ) + } + + private fun buildOrchestrator(store: InMemoryEventStore): ReplayOrchestrator { + val sessionReplayer = DefaultEventReplayer(store, SessionProjector(DefaultSessionReducer())) + val sessionRepository = DefaultSessionRepository(sessionReplayer) + + val artifactStore = object : ArtifactStore { + override suspend fun put(bytes: ByteArray): ArtifactId = + TypeId("00".repeat(32)) + override suspend fun get(id: ArtifactId): ByteArray? = null + override suspend fun flushBefore(commit: suspend () -> Unit) { commit() } + } + + val inferenceReplayer = object : EventReplayer { + override fun rebuild(sessionId: SessionId) = InferenceState() + } + val orchReplayer = object : EventReplayer { + override fun rebuild(sessionId: SessionId) = OrchestrationState() + } + + val repositories = OrchestratorRepositories( + eventStore = store, + inferenceRepository = InferenceRepository(inferenceReplayer), + orchestrationRepository = OrchestrationRepository(orchReplayer), + sessionRepository = sessionRepository, + artifactRepository = LiveArtifactRepository(store, DefaultArtifactReducer()), + approvalRepository = DefaultApprovalRepository( + DefaultEventReplayer(store, ApprovalProjector(DefaultApprovalReducer())) + ), + ) + + val engines = OrchestratorEngines( + transitionResolver = object : TransitionResolver { + override fun resolve(graph: WorkflowGraph, context: EvaluationContext): TransitionDecision = + TransitionDecision.Move( + to = StageId("terminal"), + transitionId = com.correx.core.events.types.TransitionId("t1"), + ) + }, + contextPackBuilder = object : ContextPackBuilder { + override fun build( + id: ContextPackId, + sessionId: SessionId, + stageId: StageId, + entries: List, + budget: TokenBudget, + ): ContextPack = InferenceFixtures.contextPack() + }, + inferenceRouter = object : InferenceRouter { + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + ): InferenceProvider = throw UnsupportedOperationException("should use replay provider") + }, + validationPipeline = ValidationPipeline(validators = emptyList()), + approvalEngine = com.correx.core.approvals.domain.NoOpApprovalEngine(), + riskAssessor = NoOpRiskAssessor(), + workspaceToolRegistryProvider = spyProvider, + ) + + return ReplayOrchestrator( + repositories = repositories, + engines = engines, + cancellations = ConcurrentHashMap(), + strategy = ReplayStrategy.SkipInference, + artifactStore = artifactStore, + ) + } +} diff --git a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt index 810052b0..1746440f 100644 --- a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt +++ b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt @@ -1,5 +1,6 @@ import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.TransitionExecutedEvent @@ -9,6 +10,7 @@ import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId +import com.correx.core.sessions.BoundWorkspace import com.correx.core.sessions.DefaultSessionReducer import com.correx.core.sessions.SessionState import com.correx.core.sessions.SessionStatus @@ -227,6 +229,55 @@ class DefaultSessionReducerTest { assertEquals(timestamp, result.updatedAt) } + @Test + fun `SessionWorkspaceBoundEvent sets boundWorkspace in state`() { + val result = reducer.reduce( + state = initialState(), + event = stored( + sessionId = sessionId, + payload = SessionWorkspaceBoundEvent( + sessionId = sessionId, + workspaceRoot = "/home/user/project", + allowedPaths = listOf("/home/user/project"), + ) + ) + ) + + assertEquals( + BoundWorkspace( + workspaceRoot = "/home/user/project", + allowedPaths = listOf("/home/user/project"), + ), + result.boundWorkspace, + ) + } + + @Test + fun `boundWorkspace is preserved by subsequent unrelated events`() { + val withWorkspace = initialState().copy( + boundWorkspace = BoundWorkspace( + workspaceRoot = "/home/user/project", + allowedPaths = listOf("/home/user/project"), + ) + ) + + val result = reducer.reduce( + state = withWorkspace, + event = stored( + sessionId = sessionId, + payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1")) + ) + ) + + assertEquals( + BoundWorkspace( + workspaceRoot = "/home/user/project", + allowedPaths = listOf("/home/user/project"), + ), + result.boundWorkspace, + ) + } + private fun initialState() = SessionState( status = SessionStatus.CREATED