diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionSummary.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionSummary.kt new file mode 100644 index 00000000..7946bbc4 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionSummary.kt @@ -0,0 +1,16 @@ +package com.correx.core.sessions + +import com.correx.core.events.types.SessionId +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class SessionSummary( + val sessionId: SessionId, + val intent: String?, + val workflowId: String?, + val status: String, + val createdAt: Instant?, + val lastActivityAt: Instant?, + val stageCount: Int, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionSummaryProjector.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionSummaryProjector.kt new file mode 100644 index 00000000..8973b5a5 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionSummaryProjector.kt @@ -0,0 +1,46 @@ +package com.correx.core.sessions + +import com.correx.core.events.events.InitialIntentEvent +import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TransitionExecutedEvent +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.types.SessionId + +class SessionSummaryProjector { + fun project(sessionId: SessionId, events: List): SessionSummary { + var intent: String? = null + var workflowId: String? = null + var stageCount = 0 + var status = "UNKNOWN" + val createdAt = events.firstOrNull()?.metadata?.timestamp + val lastActivityAt = events.lastOrNull()?.metadata?.timestamp + + events.forEach { event -> + when (val p = event.payload) { + is InitialIntentEvent -> intent = p.intent + is WorkflowStartedEvent -> { + workflowId = p.workflowId + status = SessionStatus.ACTIVE.name + } + is TransitionExecutedEvent -> stageCount++ + is OrchestrationPausedEvent -> status = SessionStatus.PAUSED.name + is WorkflowCompletedEvent -> status = SessionStatus.COMPLETED.name + is WorkflowFailedEvent -> status = SessionStatus.FAILED.name + else -> Unit + } + } + + return SessionSummary( + sessionId = sessionId, + intent = intent, + workflowId = workflowId, + status = status, + createdAt = createdAt, + lastActivityAt = lastActivityAt, + stageCount = stageCount, + ) + } +} diff --git a/core/sessions/src/test/kotlin/com/correx/core/sessions/SessionSummaryProjectorTest.kt b/core/sessions/src/test/kotlin/com/correx/core/sessions/SessionSummaryProjectorTest.kt new file mode 100644 index 00000000..c52e49c8 --- /dev/null +++ b/core/sessions/src/test/kotlin/com/correx/core/sessions/SessionSummaryProjectorTest.kt @@ -0,0 +1,92 @@ +package com.correx.core.sessions + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.InitialIntentEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TransitionExecutedEvent +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 kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class SessionSummaryProjectorTest { + + private val projector = SessionSummaryProjector() + private val sessionId = SessionId("s1") + + private fun stored( + payload: com.correx.core.events.events.EventPayload, + eventId: String = "e1", + timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z"), + sequence: Long = 1L, + ) = StoredEvent( + metadata = EventMetadata( + eventId = EventId(eventId), + sessionId = sessionId, + timestamp = timestamp, + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = sequence, + sessionSequence = sequence, + payload = payload, + ) + + @Test + fun `empty event list produces zeroed summary`() { + val summary = projector.project(sessionId, emptyList()) + + assertEquals(sessionId, summary.sessionId) + assertNull(summary.intent) + assertNull(summary.workflowId) + assertNull(summary.createdAt) + assertNull(summary.lastActivityAt) + assertEquals(0, summary.stageCount) + } + + @Test + fun `InitialIntentEvent populates intent`() { + val event = stored(InitialIntentEvent(sessionId, "add auth layer"), eventId = "e1") + + val summary = projector.project(sessionId, listOf(event)) + + assertEquals("add auth layer", summary.intent) + } + + @Test + fun `two TransitionExecutedEvents yield stageCount of two`() { + val t1 = stored( + TransitionExecutedEvent(sessionId, StageId("s1"), StageId("s2"), TransitionId("t1")), + eventId = "e1", sequence = 1L, + ) + val t2 = stored( + TransitionExecutedEvent(sessionId, StageId("s2"), StageId("s3"), TransitionId("t2")), + eventId = "e2", sequence = 2L, + ) + + val summary = projector.project(sessionId, listOf(t1, t2)) + + assertEquals(2, summary.stageCount) + } + + @Test + fun `createdAt is first event timestamp and lastActivityAt is last event timestamp`() { + val first = Instant.parse("2026-01-01T10:00:00Z") + val last = Instant.parse("2026-01-01T11:00:00Z") + val e1 = stored(InitialIntentEvent(sessionId, "intent"), eventId = "e1", timestamp = first, sequence = 1L) + val e2 = stored( + TransitionExecutedEvent(sessionId, StageId("a"), StageId("b"), TransitionId("t1")), + eventId = "e2", timestamp = last, sequence = 2L, + ) + + val summary = projector.project(sessionId, listOf(e1, e2)) + + assertEquals(first, summary.createdAt) + assertEquals(last, summary.lastActivityAt) + } +}