feat: FileWrittenEvent + reduce file mutations onto ToolState
This commit is contained in:
@@ -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
|
||||||
@@ -11,6 +11,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent
|
|||||||
import com.correx.core.events.events.ChatTurnEvent
|
import com.correx.core.events.events.ChatTurnEvent
|
||||||
import com.correx.core.events.events.ContextTruncatedEvent
|
import com.correx.core.events.events.ContextTruncatedEvent
|
||||||
import com.correx.core.events.events.EventPayload
|
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.L3MemoryRetrievedEvent
|
||||||
import com.correx.core.events.events.InferenceCompletedEvent
|
import com.correx.core.events.events.InferenceCompletedEvent
|
||||||
import com.correx.core.events.events.InferenceFailedEvent
|
import com.correx.core.events.events.InferenceFailedEvent
|
||||||
@@ -51,6 +52,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(ToolExecutionFailedEvent::class)
|
subclass(ToolExecutionFailedEvent::class)
|
||||||
subclass(ToolExecutionRejectedEvent::class)
|
subclass(ToolExecutionRejectedEvent::class)
|
||||||
subclass(ToolCallAssessedEvent::class)
|
subclass(ToolCallAssessedEvent::class)
|
||||||
|
subclass(FileWrittenEvent::class)
|
||||||
subclass(SteeringNoteAddedEvent::class)
|
subclass(SteeringNoteAddedEvent::class)
|
||||||
subclass(StageFailedEvent::class)
|
subclass(StageFailedEvent::class)
|
||||||
subclass(StageCompletedEvent::class)
|
subclass(StageCompletedEvent::class)
|
||||||
|
|||||||
+45
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.core.tools
|
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.StoredEvent
|
||||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
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.ToolExecutionStartedEvent
|
||||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||||
import com.correx.core.events.types.ToolInvocationId
|
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.ToolCallAssessmentRecord
|
||||||
import com.correx.core.tools.state.ToolInvocationRecord
|
import com.correx.core.tools.state.ToolInvocationRecord
|
||||||
import com.correx.core.tools.state.ToolInvocationStatus
|
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
|
else -> state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -18,4 +18,5 @@ data class ToolInvocationRecord(
|
|||||||
val requestedAt: Instant,
|
val requestedAt: Instant,
|
||||||
val completedAt: Instant? = null,
|
val completedAt: Instant? = null,
|
||||||
val assessment: ToolCallAssessmentRecord? = null,
|
val assessment: ToolCallAssessmentRecord? = null,
|
||||||
|
val mutations: List<FileMutationRecord> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user