diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt index ebcd6ab2..da552b48 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt @@ -27,3 +27,13 @@ data class OperatorProfileBoundEvent( val preferredModels: List, val conventions: List, ) : EventPayload + +@Serializable +@SerialName("ProjectProfileBound") +data class ProjectProfileBoundEvent( + val sessionId: SessionId, + val workspaceRoot: String, + val about: String, + val conventions: List, + val commands: Map, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 3c136fde..19e85800 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -12,6 +12,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatTurnEvent import com.correx.core.events.events.RouterNarrationEvent import com.correx.core.events.events.OperatorProfileBoundEvent +import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.EventPayload @@ -100,6 +101,7 @@ val eventModule = SerializersModule { subclass(RouterNarrationEvent::class) subclass(SessionWorkspaceBoundEvent::class) subclass(OperatorProfileBoundEvent::class) + subclass(ProjectProfileBoundEvent::class) subclass(L3MemoryRetrievedEvent::class) subclass(ContextTruncatedEvent::class) subclass(ExecutionPlanLockedEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt new file mode 100644 index 00000000..58627b9a --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt @@ -0,0 +1,40 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.ProjectProfileBoundEvent +import com.correx.core.events.types.SessionId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProjectProfileBoundEventSerializationTest { + + @Test + fun `ProjectProfileBoundEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = ProjectProfileBoundEvent( + sessionId = SessionId("s1"), + workspaceRoot = "/repo", + about = "Event-sourced orchestration kernel", + conventions = listOf("no bare try-catch", "events are the only source of truth"), + commands = mapOf("build" to "./gradlew build", "test" to "./gradlew check"), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"ProjectProfileBound\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } + + @Test + fun `round-trips with empty fields`() { + val sample: EventPayload = ProjectProfileBoundEvent( + sessionId = SessionId("s0"), + workspaceRoot = "/empty", + about = "", + conventions = emptyList(), + commands = emptyMap(), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundProjectProfile.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundProjectProfile.kt new file mode 100644 index 00000000..907f0f58 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundProjectProfile.kt @@ -0,0 +1,7 @@ +package com.correx.core.sessions + +data class BoundProjectProfile( + val about: String, + val conventions: List, + val commands: Map, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt index 238de513..d6622dbd 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt @@ -1,6 +1,7 @@ package com.correx.core.sessions import com.correx.core.events.events.OperatorProfileBoundEvent +import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent @@ -51,12 +52,22 @@ class DefaultSessionReducer : SessionReducer { else -> state.boundProfile } + val boundProjectProfile = when (payload) { + is ProjectProfileBoundEvent -> BoundProjectProfile( + about = payload.about, + conventions = payload.conventions, + commands = payload.commands, + ) + else -> state.boundProjectProfile + } + return state.copy( status = newStatus, createdAt = createdAt, updatedAt = event.metadata.timestamp, boundWorkspace = boundWorkspace, boundProfile = boundProfile, + boundProjectProfile = boundProjectProfile, ) } } diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt index 7d74e242..d2d21f48 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt @@ -9,4 +9,5 @@ data class SessionState( val invalidTransitions: Int = 0, val boundWorkspace: BoundWorkspace? = null, val boundProfile: BoundProfile? = null, + val boundProjectProfile: BoundProjectProfile? = null, ) diff --git a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt index 1746440f..878d367c 100644 --- a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt +++ b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt @@ -10,6 +10,8 @@ import com.correx.core.events.events.WorkflowStartedEvent 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.events.events.ProjectProfileBoundEvent +import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundWorkspace import com.correx.core.sessions.DefaultSessionReducer import com.correx.core.sessions.SessionState @@ -278,6 +280,53 @@ class DefaultSessionReducerTest { ) } + @Test + fun `ProjectProfileBoundEvent reduces into boundProjectProfile`() { + val result = reducer.reduce( + state = initialState(), + event = stored( + sessionId = sessionId, + payload = ProjectProfileBoundEvent( + sessionId = sessionId, + workspaceRoot = "/repo", + about = "kernel project", + conventions = listOf("no bare try-catch"), + commands = mapOf("test" to "./gradlew check"), + ), + ), + ) + + assertEquals( + BoundProjectProfile( + about = "kernel project", + conventions = listOf("no bare try-catch"), + commands = mapOf("test" to "./gradlew check"), + ), + result.boundProjectProfile, + ) + } + + @Test + fun `boundProjectProfile is preserved by subsequent unrelated events`() { + val withProfile = initialState().copy( + boundProjectProfile = BoundProjectProfile( + about = "kernel project", + conventions = listOf("no bare try-catch"), + commands = emptyMap(), + ), + ) + + val result = reducer.reduce( + state = withProfile, + event = stored( + sessionId = sessionId, + payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1")), + ), + ) + + assertEquals("kernel project", result.boundProjectProfile?.about) + } + private fun initialState() = SessionState( status = SessionStatus.CREATED