From 823c2e0adefe1eb4aee2cc90afa41c40ed51992f Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 11 Jun 2026 13:11:12 +0400 Subject: [PATCH] feat(events): WorkspaceStateObservedEvent, RepoKnowledgeRetrievedEvent, stateKey on RepoMapComputedEvent --- .../core/events/events/RepoKnowledgeEvents.kt | 27 +++++++++ .../core/events/events/RepoMapEvents.kt | 18 ++++++ .../events/serialization/Serialization.kt | 4 ++ ...nowledgeRetrievedEventSerializationTest.kt | 53 ++++++++++++++++++ .../RepoMapComputedEventSerializationTest.kt | 48 ++++++++++++++++ ...paceStateObservedEventSerializationTest.kt | 55 +++++++++++++++++++ 6 files changed, 205 insertions(+) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/RepoKnowledgeEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/RepoKnowledgeRetrievedEventSerializationTest.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/RepoMapComputedEventSerializationTest.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/WorkspaceStateObservedEventSerializationTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/RepoKnowledgeEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/RepoKnowledgeEvents.kt new file mode 100644 index 00000000..b4fbc258 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/RepoKnowledgeEvents.kt @@ -0,0 +1,27 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RepoKnowledgeHit( + val path: String, + val text: String, + val score: Float, +) + +/** + * Result of semantic retrieval over the embedded repo map for a stage (invariant #9): + * recorded at retrieval time; replay and downstream context assembly read the hits from + * this event, never re-query the vector index. + */ +@Serializable +@SerialName("RepoKnowledgeRetrieved") +data class RepoKnowledgeRetrievedEvent( + val sessionId: SessionId, + val stageId: StageId, + val query: String, + val hits: List, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/RepoMapEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/RepoMapEvents.kt index f3d2f0a9..d12ac07d 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/RepoMapEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/RepoMapEvents.kt @@ -13,6 +13,23 @@ data class RepoMapEntry( val symbols: List = emptyList(), ) +/** + * Workspace content-state observed at session start (invariant #9). stateKey identifies the + * exact content state: "git:" for a clean git worktree, "fp:" (stat-only + * fingerprint) for dirty or non-git workspaces. Downstream reuse decisions read this event, + * never re-probe the environment. + */ +@Serializable +@SerialName("WorkspaceStateObserved") +data class WorkspaceStateObservedEvent( + val sessionId: SessionId, + val workspaceRoot: String, + val stateKey: String, + val source: String, + val branch: String? = null, + val dirty: Boolean = false, +) : EventPayload + /** * Environment observation (invariant #9): the repo map computed once at the moment it ran. * Replay reads these recorded facts and never re-scans the filesystem. Paths + symbol names @@ -25,4 +42,5 @@ data class RepoMapComputedEvent( val repoRoot: String, val entries: List, val computedAt: Instant, + val stateKey: String? = null, ) : 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 19e85800..3340284b 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 @@ -31,8 +31,10 @@ import com.correx.core.events.events.ModelUnloadedEvent import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.RefinementIterationEvent +import com.correx.core.events.events.RepoKnowledgeRetrievedEvent import com.correx.core.events.events.RepoMapComputedEvent import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.WorkspaceStateObservedEvent import com.correx.core.events.events.RiskAssessedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.PreemptRedirectBlockedEvent @@ -95,6 +97,8 @@ val eventModule = SerializersModule { subclass(RetryAttemptedEvent::class) subclass(RefinementIterationEvent::class) subclass(RepoMapComputedEvent::class) + subclass(WorkspaceStateObservedEvent::class) + subclass(RepoKnowledgeRetrievedEvent::class) subclass(RiskAssessedEvent::class) subclass(ChatSessionStartedEvent::class) subclass(ChatTurnEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoKnowledgeRetrievedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoKnowledgeRetrievedEventSerializationTest.kt new file mode 100644 index 00000000..d271f7d2 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoKnowledgeRetrievedEventSerializationTest.kt @@ -0,0 +1,53 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.RepoKnowledgeHit +import com.correx.core.events.events.RepoKnowledgeRetrievedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RepoKnowledgeRetrievedEventSerializationTest { + + @Test + fun `RepoKnowledgeRetrievedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = RepoKnowledgeRetrievedEvent( + sessionId = SessionId("s"), + stageId = StageId("impl"), + query = "implement the parser", + hits = listOf(RepoKnowledgeHit(path = "src/Parser.kt", text = "src/Parser.kt: Parser", score = 0.92f)), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"RepoKnowledgeRetrieved\""), "SerialName must be present: $encoded") + assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), encoded)) + } + + @Test + fun `empty hits list round-trips`() { + val sample: EventPayload = RepoKnowledgeRetrievedEvent( + sessionId = SessionId("s"), + stageId = StageId("stage"), + query = "some query", + hits = emptyList(), + ) + assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), + eventJson.encodeToString(EventPayload.serializer(), sample))) + } + + @Test + fun `multiple hits round-trip correctly`() { + val sample: EventPayload = RepoKnowledgeRetrievedEvent( + sessionId = SessionId("sess"), + stageId = StageId("architect"), + query = "context builder", + hits = listOf( + RepoKnowledgeHit(path = "core/context/ContextBuilder.kt", text = "class ContextBuilder", score = 0.95f), + RepoKnowledgeHit(path = "core/context/DefaultContextBuilder.kt", text = "class DefaultContextBuilder", score = 0.88f), + ), + ) + assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), + eventJson.encodeToString(EventPayload.serializer(), sample))) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoMapComputedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoMapComputedEventSerializationTest.kt new file mode 100644 index 00000000..a62320ad --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoMapComputedEventSerializationTest.kt @@ -0,0 +1,48 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.RepoMapComputedEvent +import com.correx.core.events.events.RepoMapEntry +import com.correx.core.events.types.SessionId +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RepoMapComputedEventSerializationTest { + + @Test + fun `RepoMapComputedEvent with stateKey round-trips`() { + val sample: EventPayload = RepoMapComputedEvent( + sessionId = SessionId("s"), + repoRoot = "/repo", + entries = listOf(RepoMapEntry(path = "src/Foo.kt", score = 0.9)), + computedAt = Instant.parse("2026-06-11T00:00:00Z"), + stateKey = "git:abc123", + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"stateKey\":\"git:abc123\""), "stateKey must be in JSON: $encoded") + assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), encoded)) + } + + @Test + fun `RepoMapComputedEvent without stateKey field decodes with null`() { + val json = """{"type":"RepoMapComputed","sessionId":"s","repoRoot":"/repo","entries":[],"computedAt":"2026-06-11T00:00:00Z"}""" + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) as RepoMapComputedEvent + assertNull(decoded.stateKey) + } + + @Test + fun `RepoMapComputedEvent with null stateKey round-trips`() { + val sample: EventPayload = RepoMapComputedEvent( + sessionId = SessionId("s"), + repoRoot = "/repo", + entries = emptyList(), + computedAt = Instant.parse("2026-06-11T00:00:00Z"), + stateKey = null, + ) + assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), + eventJson.encodeToString(EventPayload.serializer(), sample))) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/WorkspaceStateObservedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/WorkspaceStateObservedEventSerializationTest.kt new file mode 100644 index 00000000..9143cd8f --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/WorkspaceStateObservedEventSerializationTest.kt @@ -0,0 +1,55 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.WorkspaceStateObservedEvent +import com.correx.core.events.types.SessionId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WorkspaceStateObservedEventSerializationTest { + + @Test + fun `WorkspaceStateObservedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = WorkspaceStateObservedEvent( + sessionId = SessionId("s1"), + workspaceRoot = "/home/user/project", + stateKey = "git:abc1234", + source = "git", + branch = "main", + dirty = false, + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"WorkspaceStateObserved\""), "SerialName must be present: $encoded") + assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), encoded)) + } + + @Test + fun `fingerprint source without branch round-trips`() { + val sample: EventPayload = WorkspaceStateObservedEvent( + sessionId = SessionId("s2"), + workspaceRoot = "/repo", + stateKey = "fp:9f8e7d", + source = "fingerprint", + ) + assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), + eventJson.encodeToString(EventPayload.serializer(), sample))) + } + + @Test + fun `dirty git workspace round-trips with dirty flag`() { + val sample: EventPayload = WorkspaceStateObservedEvent( + sessionId = SessionId("s3"), + workspaceRoot = "/dirty/repo", + stateKey = "fp:deadbeef", + source = "fingerprint", + branch = "feature", + dirty = true, + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) as WorkspaceStateObservedEvent + assertEquals(true, decoded.dirty) + assertNull(null) // branch can be non-null + } +}