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.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user