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
@@ -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)) {
@@ -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
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,
)
}
}
@@ -7,4 +7,5 @@ data class SessionState(
val createdAt: Instant? = null,
val updatedAt: Instant? = null,
val invalidTransitions: Int = 0,
val boundWorkspace: BoundWorkspace? = null,
)