From 0e688f3dafbdb820f2120a37ce6c9b81f86fb4ba Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 31 May 2026 10:28:01 +0400 Subject: [PATCH] feat: FileWrittenEvent + reduce file mutations onto ToolState --- .../core/events/events/FileMutationEvents.kt | 27 +++++ .../events/serialization/Serialization.kt | 2 + .../FileWrittenEventSerializationTest.kt | 45 +++++++++ .../correx/core/tools/DefaultToolReducer.kt | 13 +++ .../core/tools/state/FileMutationRecord.kt | 16 +++ .../core/tools/state/ToolInvocationRecord.kt | 1 + .../core/tools/FileMutationReducerTest.kt | 98 +++++++++++++++++++ 7 files changed, 202 insertions(+) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/FileMutationEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/FileWrittenEventSerializationTest.kt create mode 100644 core/tools/src/main/kotlin/com/correx/core/tools/state/FileMutationRecord.kt create mode 100644 core/tools/src/test/kotlin/com/correx/core/tools/FileMutationReducerTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/FileMutationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/FileMutationEvents.kt new file mode 100644 index 00000000..5d186cd9 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/FileMutationEvents.kt @@ -0,0 +1,27 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * First-class record of a file mutation performed by a tool. Makes file writes + * reversible from the log + CAS (invariants #1/#5): [preImageHash]/[postImageHash] + * are content-addressed blobs in the ArtifactStore. A newly-created file has + * `preExisted = false` and a null [preImageHash]; a delete has a null + * [postImageHash]. Undo restores the pre-image blob (or deletes) through the + * same atomic write seam. + */ +@Serializable +@SerialName("FileWritten") +data class FileWrittenEvent( + val invocationId: ToolInvocationId, + val sessionId: SessionId, + val path: String, + val preImageHash: String? = null, + val postImageHash: String? = null, + val preExisted: Boolean, + val mode: String = "0644", + val timestampMs: Long, +) : 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 5a455d79..cd98fa5e 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 @@ -11,6 +11,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatTurnEvent import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.FileWrittenEvent import com.correx.core.events.events.L3MemoryRetrievedEvent import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceFailedEvent @@ -51,6 +52,7 @@ val eventModule = SerializersModule { subclass(ToolExecutionFailedEvent::class) subclass(ToolExecutionRejectedEvent::class) subclass(ToolCallAssessedEvent::class) + subclass(FileWrittenEvent::class) subclass(SteeringNoteAddedEvent::class) subclass(StageFailedEvent::class) subclass(StageCompletedEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/FileWrittenEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/FileWrittenEventSerializationTest.kt new file mode 100644 index 00000000..6a27c7ee --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/FileWrittenEventSerializationTest.kt @@ -0,0 +1,45 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.FileWrittenEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.ToolInvocationId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FileWrittenEventSerializationTest { + + private val sample = FileWrittenEvent( + invocationId = ToolInvocationId("inv-1"), + sessionId = SessionId("sess-1"), + path = "src/Main.kt", + preImageHash = "aaaa", + postImageHash = "bbbb", + preExisted = true, + mode = "0644", + timestampMs = 7L, + ) + + @Test + fun `round-trips as polymorphic EventPayload`() { + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"FileWritten\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } + + @Test + fun `decodes hand-written FileWritten JSON for a new file`() { + val json = """ + {"type":"FileWritten","invocationId":"inv-9","sessionId":"s","path":"new.txt", + "postImageHash":"cccc","preExisted":false,"timestampMs":1} + """.trimIndent() + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + assertTrue(decoded is FileWrittenEvent) + decoded as FileWrittenEvent + assertEquals(false, decoded.preExisted) + assertEquals(null, decoded.preImageHash) + assertEquals("cccc", decoded.postImageHash) + } +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt b/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt index 44f04b51..70c04c32 100644 --- a/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt +++ b/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt @@ -1,5 +1,6 @@ package com.correx.core.tools +import com.correx.core.events.events.FileWrittenEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.ToolCallAssessedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent @@ -8,6 +9,7 @@ import com.correx.core.events.events.ToolExecutionRejectedEvent import com.correx.core.events.events.ToolExecutionStartedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.state.FileMutationRecord import com.correx.core.tools.state.ToolCallAssessmentRecord import com.correx.core.tools.state.ToolInvocationRecord import com.correx.core.tools.state.ToolInvocationStatus @@ -51,6 +53,17 @@ class DefaultToolReducer : ToolReducer { ), ) } + is FileWrittenEvent -> state.updateRecord(p.invocationId) { + it.copy( + mutations = it.mutations + FileMutationRecord( + path = p.path, + preImageHash = p.preImageHash, + postImageHash = p.postImageHash, + preExisted = p.preExisted, + mode = p.mode, + ), + ) + } else -> state } } diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/state/FileMutationRecord.kt b/core/tools/src/main/kotlin/com/correx/core/tools/state/FileMutationRecord.kt new file mode 100644 index 00000000..9a154e4f --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/state/FileMutationRecord.kt @@ -0,0 +1,16 @@ +package com.correx.core.tools.state + +import kotlinx.serialization.Serializable + +/** + * Projection of a [com.correx.core.events.events.FileWrittenEvent] — one file + * mutation by a tool invocation, with the CAS hashes needed to reverse it. + */ +@Serializable +data class FileMutationRecord( + val path: String, + val preImageHash: String? = null, + val postImageHash: String? = null, + val preExisted: Boolean, + val mode: String = "0644", +) diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt index 97398e0f..18ec8e1b 100644 --- a/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt +++ b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt @@ -18,4 +18,5 @@ data class ToolInvocationRecord( val requestedAt: Instant, val completedAt: Instant? = null, val assessment: ToolCallAssessmentRecord? = null, + val mutations: List = emptyList(), ) diff --git a/core/tools/src/test/kotlin/com/correx/core/tools/FileMutationReducerTest.kt b/core/tools/src/test/kotlin/com/correx/core/tools/FileMutationReducerTest.kt new file mode 100644 index 00000000..b9d4f8c3 --- /dev/null +++ b/core/tools/src/test/kotlin/com/correx/core/tools/FileMutationReducerTest.kt @@ -0,0 +1,98 @@ +package com.correx.core.tools + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.FileWrittenEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.ToolRequest +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.ToolInvocationId +import com.correx.core.tools.state.ToolState +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FileMutationReducerTest { + + private val reducer = DefaultToolReducer() + private val inv = ToolInvocationId("inv-1") + private val session = SessionId("s-1") + private val stage = StageId("st-1") + + private fun stored(payload: EventPayload, seq: Long) = StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = session, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + + private val requested = ToolInvocationRequestedEvent( + invocationId = inv, + sessionId = session, + stageId = stage, + toolName = "file_write", + tier = Tier.T2, + request = ToolRequest(inv, session, stage, "file_write", mapOf("path" to "src/A.kt")), + ) + + @Test + fun `file mutations accumulate onto the matching invocation record`() { + var state = ToolState() + state = reducer.reduce(state, stored(requested, 0)) + state = reducer.reduce( + state, + stored( + FileWrittenEvent( + invocationId = inv, sessionId = session, path = "src/A.kt", + preImageHash = "old", postImageHash = "new", preExisted = true, mode = "0644", timestampMs = 1L, + ), + 1, + ), + ) + state = reducer.reduce( + state, + stored( + FileWrittenEvent( + invocationId = inv, sessionId = session, path = "src/B.kt", + preImageHash = null, postImageHash = "fresh", preExisted = false, mode = "0644", timestampMs = 2L, + ), + 2, + ), + ) + + val mutations = state.invocations.single().mutations + assertEquals(2, mutations.size) + assertEquals("src/A.kt", mutations[0].path) + assertEquals("old", mutations[0].preImageHash) + assertTrue(mutations[0].preExisted) + assertEquals("src/B.kt", mutations[1].path) + assertEquals(null, mutations[1].preImageHash) + assertEquals(false, mutations[1].preExisted) + } + + @Test + fun `file mutation for unknown invocation is a no-op`() { + val state = reducer.reduce( + ToolState(), + stored( + FileWrittenEvent( + invocationId = inv, sessionId = session, path = "x", preExisted = false, timestampMs = 0L, + ), + 0, + ), + ) + assertEquals(0, state.invocations.size) + } +}