feat(server): Task 3.3 — NarrationSubscriber + wiring
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.correx.apps.server
|
package com.correx.apps.server
|
||||||
|
|
||||||
import com.correx.apps.server.approval.ApprovalCoordinator
|
import com.correx.apps.server.approval.ApprovalCoordinator
|
||||||
|
import com.correx.apps.server.narration.NarrationSubscriber
|
||||||
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.apps.server.workspace.WorkspaceResolver
|
||||||
@@ -65,6 +66,7 @@ class ServerModule(
|
|||||||
// Dispatchers.Default since the work is non-blocking and CPU-light.
|
// Dispatchers.Default since the work is non-blocking and CPU-light.
|
||||||
val moduleScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
val moduleScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
approvalCoordinator: ApprovalCoordinator? = null,
|
approvalCoordinator: ApprovalCoordinator? = null,
|
||||||
|
val narrationMaxPerRun: Int = 100,
|
||||||
) {
|
) {
|
||||||
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||||
orchestrator = orchestrator,
|
orchestrator = orchestrator,
|
||||||
@@ -105,6 +107,10 @@ class ServerModule(
|
|||||||
launchSessionResume(sessionId, graph)
|
launchSessionResume(sessionId, graph)
|
||||||
}
|
}
|
||||||
.launchIn(moduleScope)
|
.launchIn(moduleScope)
|
||||||
|
|
||||||
|
// Live-only: subscribeAll() replays nothing and ServerModule is never built under
|
||||||
|
// ReplayOrchestrator, so narration never re-fires on restart/replay (invariant #8).
|
||||||
|
NarrationSubscriber(eventStore = eventStore, routerFacade = routerFacade, scope = moduleScope, maxPerRun = narrationMaxPerRun).start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.correx.apps.server.narration
|
||||||
|
|
||||||
|
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||||
|
import com.correx.core.events.events.StageCompletedEvent
|
||||||
|
import com.correx.core.events.events.StageFailedEvent
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowStartedEvent
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.router.RouterFacade
|
||||||
|
import com.correx.core.router.model.NarrationTrigger
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class NarrationSubscriber(
|
||||||
|
private val eventStore: EventStore,
|
||||||
|
private val routerFacade: RouterFacade,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val maxPerRun: Int = DEFAULT_MAX_PER_RUN,
|
||||||
|
) {
|
||||||
|
private data class SessionLane(val channel: Channel<NarrationTrigger>, var used: Int)
|
||||||
|
|
||||||
|
private val lanes = ConcurrentHashMap<String, SessionLane>()
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
eventStore.subscribeAll().onEach { handle(it) }.launchIn(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handle(event: StoredEvent) {
|
||||||
|
val sid = event.metadata.sessionId
|
||||||
|
when (val p = event.payload) {
|
||||||
|
is WorkflowStartedEvent -> lanes[sid.value]?.let { it.used = 0 }
|
||||||
|
is StageCompletedEvent -> enqueue(
|
||||||
|
sid,
|
||||||
|
NarrationTrigger(kind = "stage_completed", instruction = "Stage ${p.stageId.value} completed. Summarise what was accomplished."),
|
||||||
|
)
|
||||||
|
is StageFailedEvent -> enqueue(
|
||||||
|
sid,
|
||||||
|
NarrationTrigger(kind = "stage_failed", instruction = "Stage ${p.stageId.value} failed: ${p.reason}. Explain what went wrong."),
|
||||||
|
)
|
||||||
|
is WorkflowCompletedEvent -> enqueue(
|
||||||
|
sid,
|
||||||
|
NarrationTrigger(kind = "workflow_completed", instruction = "The workflow finished successfully. Provide a brief summary."),
|
||||||
|
)
|
||||||
|
is WorkflowFailedEvent -> enqueue(
|
||||||
|
sid,
|
||||||
|
NarrationTrigger(kind = "workflow_failed", instruction = "The workflow failed: ${p.reason}. Explain the failure to the user."),
|
||||||
|
)
|
||||||
|
is OrchestrationPausedEvent -> enqueue(
|
||||||
|
sid,
|
||||||
|
NarrationTrigger(kind = "paused", instruction = "Orchestration paused: ${p.reason}. Inform the user."),
|
||||||
|
)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger) {
|
||||||
|
val lane = lanes.computeIfAbsent(sessionId.value) { startLane(sessionId) }
|
||||||
|
if (lane.used >= maxPerRun) {
|
||||||
|
log.debug("narration budget reached for session {}; skipping {}", sessionId.value, trigger.kind)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lane.used += 1
|
||||||
|
lane.channel.trySend(trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLane(sessionId: SessionId): SessionLane {
|
||||||
|
val channel = Channel<NarrationTrigger>(capacity = Channel.UNLIMITED)
|
||||||
|
scope.launch {
|
||||||
|
for (trigger in channel) {
|
||||||
|
runCatching { routerFacade.narrate(sessionId, trigger) }
|
||||||
|
.onFailure { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
log.warn(
|
||||||
|
"narration failed for session {} trigger {}: {}",
|
||||||
|
sessionId.value,
|
||||||
|
trigger.kind,
|
||||||
|
e.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SessionLane(channel, used = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_MAX_PER_RUN = 100
|
||||||
|
private val log = LoggerFactory.getLogger(NarrationSubscriber::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package com.correx.apps.server.narration
|
||||||
|
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.NewEvent
|
||||||
|
import com.correx.core.events.events.StageCompletedEvent
|
||||||
|
import com.correx.core.events.events.StageFailedEvent
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowStartedEvent
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
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.events.types.TransitionId
|
||||||
|
import com.correx.core.router.ChatMode
|
||||||
|
import com.correx.core.router.RouterFacade
|
||||||
|
import com.correx.core.router.model.NarrationTrigger
|
||||||
|
import com.correx.core.router.model.RouterResponse
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
class NarrationSubscriberTest {
|
||||||
|
|
||||||
|
private val sessionId = SessionId("session-1")
|
||||||
|
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 64)
|
||||||
|
|
||||||
|
private val fakeStore = object : EventStore {
|
||||||
|
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
|
||||||
|
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
|
||||||
|
override fun read(sessionId: SessionId): List<StoredEvent> = emptyList()
|
||||||
|
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = emptyList()
|
||||||
|
override fun lastSequence(sessionId: SessionId): Long = 0L
|
||||||
|
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = liveFlow
|
||||||
|
override fun allEvents(): Sequence<StoredEvent> = emptySequence()
|
||||||
|
override fun allSessionIds(): Set<SessionId> = emptySet()
|
||||||
|
override fun subscribeAll(): Flow<StoredEvent> = liveFlow
|
||||||
|
override suspend fun lastGlobalSequence(): Long = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingRouterFacade : RouterFacade {
|
||||||
|
val calls = CopyOnWriteArrayList<Pair<SessionId, NarrationTrigger>>()
|
||||||
|
|
||||||
|
override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse =
|
||||||
|
error("unused in narration tests")
|
||||||
|
|
||||||
|
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
|
||||||
|
calls.add(sessionId to trigger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storedEvent(payload: EventPayload, seq: Long): StoredEvent = StoredEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId("evt-$seq"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = timestamp,
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
sequence = seq,
|
||||||
|
sessionSequence = seq,
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `triggers are narrated in order for matching events`(): Unit = runBlocking {
|
||||||
|
val facade = RecordingRouterFacade()
|
||||||
|
NarrationSubscriber(fakeStore, facade, scope).start()
|
||||||
|
|
||||||
|
// Wait for subscription to be attached
|
||||||
|
while (liveFlow.subscriptionCount.value == 0) yield()
|
||||||
|
|
||||||
|
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
|
||||||
|
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
|
||||||
|
liveFlow.emit(storedEvent(StageFailedEvent(sessionId, StageId("b"), TransitionId("t2"), reason = "x"), seq = 3L))
|
||||||
|
liveFlow.emit(storedEvent(WorkflowCompletedEvent(sessionId, StageId("c"), totalStages = 2), seq = 4L))
|
||||||
|
|
||||||
|
withTimeout(2_000L) {
|
||||||
|
while (facade.calls.size < 3) yield()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(3, facade.calls.size)
|
||||||
|
assertEquals("stage_completed", facade.calls[0].second.kind)
|
||||||
|
assertEquals("stage_failed", facade.calls[1].second.kind)
|
||||||
|
assertEquals("workflow_completed", facade.calls[2].second.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `maxPerRun=1 skips second trigger after first narration`(): Unit = runBlocking {
|
||||||
|
val facade = RecordingRouterFacade()
|
||||||
|
NarrationSubscriber(fakeStore, facade, scope, maxPerRun = 1).start()
|
||||||
|
|
||||||
|
while (liveFlow.subscriptionCount.value == 0) yield()
|
||||||
|
|
||||||
|
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
|
||||||
|
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
|
||||||
|
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 3L))
|
||||||
|
|
||||||
|
withTimeout(2_000L) {
|
||||||
|
while (facade.calls.size < 1) yield()
|
||||||
|
}
|
||||||
|
// Give a moment for a second call to arrive (it shouldn't)
|
||||||
|
kotlinx.coroutines.delay(100L)
|
||||||
|
|
||||||
|
assertEquals(1, facade.calls.size)
|
||||||
|
assertEquals("stage_completed", facade.calls[0].second.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a throwing narrate does not prevent subsequent narrations`(): Unit = runBlocking {
|
||||||
|
var callCount = 0
|
||||||
|
val facade = object : RouterFacade {
|
||||||
|
val calls = CopyOnWriteArrayList<String>()
|
||||||
|
|
||||||
|
override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse =
|
||||||
|
error("unused")
|
||||||
|
|
||||||
|
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
|
||||||
|
callCount++
|
||||||
|
if (callCount == 1) throw RuntimeException("simulated failure")
|
||||||
|
calls.add(trigger.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NarrationSubscriber(fakeStore, facade, scope).start()
|
||||||
|
|
||||||
|
while (liveFlow.subscriptionCount.value == 0) yield()
|
||||||
|
|
||||||
|
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
|
||||||
|
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
|
||||||
|
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 3L))
|
||||||
|
|
||||||
|
withTimeout(2_000L) {
|
||||||
|
while (facade.calls.size < 1) yield()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, facade.calls.size)
|
||||||
|
assertEquals("stage_completed", facade.calls[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user