feat(workspace): Axis 2 Phase B — client→server workspace handshake
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.
This commit is contained in:
@@ -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("==============================")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<InferenceProvider>()
|
||||
override suspend fun healthCheckAll() = emptyMap<ProviderId, ProviderHealth>()
|
||||
}
|
||||
|
||||
/**
|
||||
* An event store that delegates to [InMemoryEventStore] but also copies each appended
|
||||
* event into [appended] for inspection.
|
||||
*/
|
||||
private fun capturingEventStore(): Pair<EventStore, ConcurrentLinkedQueue<NewEvent>> {
|
||||
val delegate = InMemoryEventStore()
|
||||
val captured = ConcurrentLinkedQueue<NewEvent>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user