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:
2026-06-03 13:18:17 +04:00
parent 57cf6f09f4
commit 622b331de3
15 changed files with 836 additions and 11 deletions
@@ -3,6 +3,7 @@ package com.correx.apps.server
import com.correx.apps.server.logging.LoggingEventStore import com.correx.apps.server.logging.LoggingEventStore
import com.correx.apps.server.registry.FileSystemWorkflowRegistry import com.correx.apps.server.registry.FileSystemWorkflowRegistry
import com.correx.apps.server.registry.ProviderRegistry 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.approvals.domain.DefaultApprovalEngine
import com.correx.core.artifacts.kind.ArtifactKind import com.correx.core.artifacts.kind.ArtifactKind
import com.correx.core.artifacts.kind.ConfigArtifactKind import com.correx.core.artifacts.kind.ConfigArtifactKind
@@ -258,6 +259,18 @@ fun main() {
artifactStore = artifactStore, artifactStore = artifactStore,
bootRoots = setOf(workspaceRoot, workingDir), 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( val module = ServerModule(
orchestrator = orchestrator, orchestrator = orchestrator,
eventStore = eventStore, eventStore = eventStore,
@@ -275,6 +288,7 @@ fun main() {
sessionUndoService = sessionUndoService, sessionUndoService = sessionUndoService,
modelSwapper = modelSwapper, modelSwapper = modelSwapper,
resourceProbe = resourceProbe, resourceProbe = resourceProbe,
workspaceResolver = workspaceResolver,
) )
module.start() module.start()
log.info("==============================") log.info("==============================")
@@ -3,6 +3,7 @@ package com.correx.apps.server
import com.correx.apps.server.approval.ApprovalCoordinator import com.correx.apps.server.approval.ApprovalCoordinator
import com.correx.apps.server.registry.ProviderRegistry import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry 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.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository 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. // probe (static path / non-GPU host); the managed path wires an NVIDIA-backed probe.
val resourceProbe: com.correx.infrastructure.inference.commons.ResourceProbe = val resourceProbe: com.correx.infrastructure.inference.commons.ResourceProbe =
com.correx.infrastructure.inference.commons.UnavailableProbe, 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. // Long-lived scope owned by the module — backs the event-store subscription.
// SupervisorJob so one failure doesn't kill the whole module; // SupervisorJob so one failure doesn't kill the whole module;
// Dispatchers.Default since the work is non-blocking and CPU-light. // 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, * 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). * 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) { activeSessionJobs.computeIfAbsent(sessionId) {
moduleScope.launch { moduleScope.launch {
runCatching { runCatching {
orchestrator.run(sessionId, graph, defaultOrchestrationConfig) orchestrator.run(sessionId, graph, sessionConfig)
}.onFailure { ex -> }.onFailure { ex ->
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex) log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
eventStore.append( eventStore.append(
@@ -70,4 +70,12 @@ sealed class ClientMessage {
/** Operator request to clear the manual-swap pin; per-stage selection resumes. */ /** Operator request to clear the manual-swap pin; per-stage selection resumes. */
@Serializable @Serializable
data object ClearModelPin : ClientMessage() 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.WorkflowDto
import com.correx.apps.server.protocol.StageToolDecl import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl 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.GrantScope
import com.correx.core.approvals.Tier import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalGrantCreatedEvent import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent 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.events.StoredEvent
import com.correx.core.events.types.GrantId import com.correx.core.events.types.GrantId
import com.correx.core.events.stores.EventStore 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.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.inference.ProviderHealth import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import com.correx.infrastructure.inference.commons.ResourceProbe import com.correx.infrastructure.inference.commons.ResourceProbe
import com.correx.infrastructure.inference.commons.ResourceSnapshot import com.correx.infrastructure.inference.commons.ResourceSnapshot
@@ -68,6 +71,13 @@ class GlobalStreamHandler(private val module: ServerModule) {
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(it))) 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( val bridge = SessionEventBridge(
eventStore = module.eventStore, eventStore = module.eventStore,
artifactStore = module.artifactStore, artifactStore = module.artifactStore,
@@ -100,7 +110,11 @@ class GlobalStreamHandler(private val module: ServerModule) {
.onSuccess { msg -> .onSuccess { msg ->
log.debug("recv: {}", msg::class.simpleName) log.debug("recv: {}", msg::class.simpleName)
runCatching { runCatching {
handleClientMessage(session, msg, sendFrame) handleClientMessage(
session, msg, sendFrame, pendingWorkingDir, sessionStarted,
onHello = { pendingWorkingDir = it },
onSessionStarted = { sessionStarted = true },
)
}.onFailure { }.onFailure {
log.error("handle failed", it) log.error("handle failed", it)
} }
@@ -180,10 +194,25 @@ class GlobalStreamHandler(private val module: ServerModule) {
session: DefaultWebSocketServerSession, session: DefaultWebSocketServerSession,
msg: ClientMessage, msg: ClientMessage,
sendFrame: suspend (ServerMessage) -> Unit, sendFrame: suspend (ServerMessage) -> Unit,
pendingWorkingDir: String?,
sessionStarted: Boolean,
onHello: (String) -> Unit,
onSessionStarted: () -> Unit,
) { ) {
when (msg) { when (msg) {
is ClientMessage.Ping -> Unit 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.StartChatSession -> handleStartChatSession(session, msg, sendFrame)
is ClientMessage.CancelSession -> { is ClientMessage.CancelSession -> {
runCatching { module.orchestrator.cancel(msg.sessionId) } runCatching { module.orchestrator.cancel(msg.sessionId) }
@@ -353,7 +382,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
private suspend fun handleStartSession( private suspend fun handleStartSession(
session: DefaultWebSocketServerSession, session: DefaultWebSocketServerSession,
msg: ClientMessage.StartSession, msg: ClientMessage.StartSession,
sendFrame: suspend (ServerMessage) -> Unit, workingDir: String?,
) { ) {
val graph = module.workflowRegistry.find(msg.workflowId) val graph = module.workflowRegistry.find(msg.workflowId)
log.info("find returned: {}", graph) log.info("find returned: {}", graph)
@@ -369,6 +398,48 @@ class GlobalStreamHandler(private val module: ServerModule) {
val sessionId: SessionId = TypeId(UUID.randomUUID().toString()) val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
log.info("starting session={} workflow={}", sessionId.value, msg.workflowId) 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 // 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 // orchestrator never launches — no silent data loss. The exception propagates to the
// runCatching in handleClientMessage (caller) and is logged there. // 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. // Only launch orchestrator after protocol messages are delivered successfully.
// Uses launchSessionRun() so the job is tracked in activeSessionJobs — this prevents // Uses launchSessionRun() so the job is tracked in activeSessionJobs — this prevents
// the OrchestrationResumedEvent subscription from spuriously launching a duplicate resume. // 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")
}
}
+45 -1
View File
@@ -1,6 +1,9 @@
package protocol package protocol
import "testing" import (
"encoding/json"
"testing"
)
// Golden wire frames as emitted by the Kotlin server (kotlinx.serialization). These // 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 // 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, "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})
}
+4
View File
@@ -6,6 +6,7 @@ package ws
import ( import (
"context" "context"
"net/url" "net/url"
"os"
"time" "time"
"github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/protocol"
@@ -102,6 +103,9 @@ func (c *Client) Run(ctx context.Context) {
backoff = initialBackoff backoff = initialBackoff
attempt = 0 attempt = 0
c.emit(Status{Connected: true}) c.emit(Status{Connected: true})
if cwd, err := os.Getwd(); err == nil {
_ = conn.WriteMessage(websocket.TextMessage, protocol.Hello(cwd))
}
c.pump(ctx, conn) c.pump(ctx, conn)
c.emit(Status{Reconnecting: true}) c.emit(Status{Reconnecting: true})
} }
@@ -23,6 +23,7 @@ import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision import com.correx.core.transitions.resolution.TransitionDecision
import com.correx.core.validation.model.ValidationContext import com.correx.core.validation.model.ValidationContext
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.util.* import java.util.*
import java.util.concurrent.* import java.util.concurrent.*
import java.util.concurrent.atomic.* import java.util.concurrent.atomic.*
@@ -65,10 +66,25 @@ class ReplayOrchestrator(
graph: WorkflowGraph, graph: WorkflowGraph,
config: OrchestrationConfig, config: OrchestrationConfig,
): WorkflowResult { ): WorkflowResult {
emitWorkflowStarted(sessionId, graph, config)
val session = repositories.sessionRepository.getSession(sessionId) 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 // Execute the start stage before entering the step loop
return when (val result = enterStage(ctx, graph.start, session)) { return when (val result = enterStage(ctx, graph.start, session)) {
@@ -0,0 +1,6 @@
package com.correx.core.sessions
data class BoundWorkspace(
val workspaceRoot: String,
val allowedPaths: List<String>,
)
@@ -1,5 +1,6 @@
package com.correx.core.sessions 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.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.StoredEvent
@@ -29,10 +30,20 @@ class DefaultSessionReducer : SessionReducer {
val createdAt = state.createdAt val createdAt = state.createdAt
?: event.metadata.timestamp ?: event.metadata.timestamp
val boundWorkspace = when (payload) {
is SessionWorkspaceBoundEvent ->
BoundWorkspace(
workspaceRoot = payload.workspaceRoot,
allowedPaths = payload.allowedPaths,
)
else -> state.boundWorkspace
}
return state.copy( return state.copy(
status = newStatus, status = newStatus,
createdAt = createdAt, createdAt = createdAt,
updatedAt = event.metadata.timestamp updatedAt = event.metadata.timestamp,
boundWorkspace = boundWorkspace,
) )
} }
} }
@@ -7,4 +7,5 @@ data class SessionState(
val createdAt: Instant? = null, val createdAt: Instant? = null,
val updatedAt: Instant? = null, val updatedAt: Instant? = null,
val invalidTransitions: Int = 0, val invalidTransitions: Int = 0,
val boundWorkspace: BoundWorkspace? = null,
) )
+8
View File
@@ -14,6 +14,14 @@ dependencies {
testImplementation "org.jetbrains.kotlin:kotlin-test" testImplementation "org.jetbrains.kotlin:kotlin-test"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test"
testImplementation project(':infrastructure:persistence') 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 } tasks.named("koverVerify").configure { enabled = false }
@@ -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<WorkspaceContext?>(null)
private val spyProvider = WorkspaceToolRegistryProvider { ws ->
capturedWorkspace.set(ws)
WorkspaceTools(
registry = object : ToolRegistry {
override fun resolve(name: String): Tool? = null
override fun all(): List<Tool> = 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<InferenceState> {
override fun rebuild(sessionId: SessionId) = InferenceState()
}
val orchReplayer = object : EventReplayer<OrchestrationState> {
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<ContextEntry>,
budget: TokenBudget,
): ContextPack = InferenceFixtures.contextPack()
},
inferenceRouter = object : InferenceRouter {
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
): 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,
)
}
}
@@ -1,5 +1,6 @@
import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent 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.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.TransitionExecutedEvent 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.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId import com.correx.core.events.types.TransitionId
import com.correx.core.sessions.BoundWorkspace
import com.correx.core.sessions.DefaultSessionReducer import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionState import com.correx.core.sessions.SessionState
import com.correx.core.sessions.SessionStatus import com.correx.core.sessions.SessionStatus
@@ -227,6 +229,55 @@ class DefaultSessionReducerTest {
assertEquals(timestamp, result.updatedAt) 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() = private fun initialState() =
SessionState( SessionState(
status = SessionStatus.CREATED status = SessionStatus.CREATED