feat: undo session file mutations via server endpoint + CLI command

This commit is contained in:
2026-05-31 11:01:05 +04:00
parent 95e79bffb8
commit 7682429b3d
7 changed files with 228 additions and 0 deletions
@@ -5,6 +5,7 @@ import com.correx.apps.cli.commands.ProviderCommand
import com.correx.apps.cli.commands.RunCommand
import com.correx.apps.cli.commands.SessionCommand
import com.correx.apps.cli.commands.StatusCommand
import com.correx.apps.cli.commands.UndoCommand
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.flag
@@ -23,4 +24,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
ApproveCommand(),
StatusCommand(),
ProviderCommand(),
UndoCommand(),
)
@@ -0,0 +1,29 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.DEFAULT_PORT
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking
class UndoCommand : CliktCommand(name = "undo") {
private val sessionId by argument("SESSION_ID")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val client = HttpClient(CIO)
try {
val response = client.post("http://$host:$portInt/sessions/$sessionId/undo")
echo(response.bodyAsText())
} finally {
client.close()
}
}
}
@@ -171,6 +171,13 @@ fun main() {
embedder = embedder,
l3MemoryStore = l3MemoryStore,
)
val sessionUndoService = com.correx.apps.server.undo.SessionUndoService(
eventStore = eventStore,
reverser = com.correx.infrastructure.tools.filesystem.FileMutationReverser(
artifactStore = artifactStore,
allowedRoots = setOf(workspaceRoot, workingDir),
),
)
val module = ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
@@ -183,6 +190,7 @@ fun main() {
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
sessionUndoService = sessionUndoService,
)
module.start()
log.info("==============================")
@@ -48,6 +48,7 @@ class ServerModule(
val orchestrationRepository: OrchestrationRepository,
val approvalRepository: DefaultApprovalRepository,
val toolRegistry: ToolRegistry,
val sessionUndoService: com.correx.apps.server.undo.SessionUndoService,
// Long-lived scope owned by the module — backs the event-store subscription.
// SupervisorJob so one failure doesn't kill the whole module;
// Dispatchers.Default since the work is non-blocking and CPU-light.
@@ -42,6 +42,7 @@ fun Route.sessionRoutes(module: ServerModule) {
route("/{id}") {
getSessionRoute(module)
cancelSessionRoute(module)
undoSessionRoute(module)
getEventsRoute(module)
webSocket("/stream") {
val id = call.parameters["id"] ?: return@webSocket
@@ -88,6 +89,15 @@ private fun Route.cancelSessionRoute(module: ServerModule) {
}
}
private fun Route.undoSessionRoute(module: ServerModule) {
post("/undo") {
val id = call.parameters["id"]
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
val summary = module.sessionUndoService.undo(TypeId(id))
call.respond(HttpStatusCode.OK, summary)
}
}
private fun Route.getEventsRoute(module: ServerModule) {
get("/events") {
val id = call.parameters["id"]
@@ -0,0 +1,51 @@
package com.correx.apps.server.undo
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.infrastructure.tools.filesystem.FileMutationReverser
import com.correx.infrastructure.tools.filesystem.RevertResult
import kotlinx.serialization.Serializable
@Serializable
data class UndoSummary(
val sessionId: String,
val reverted: Int,
val deleted: Int,
val rejected: Int,
val failed: Int,
val details: List<String> = emptyList(),
)
/**
* Reverses every file mutation a session performed, newest-first, using only the
* event log + CAS (the reverser). Newest-first so a path written multiple times in
* the session ends at its pre-session content.
*/
class SessionUndoService(
private val eventStore: EventStore,
private val reverser: FileMutationReverser,
) {
suspend fun undo(sessionId: SessionId): UndoSummary {
val mutations = eventStore.read(sessionId)
.mapNotNull { it.payload as? FileWrittenEvent }
.asReversed()
var reverted = 0
var deleted = 0
var rejected = 0
var failed = 0
val details = mutableListOf<String>()
for (event in mutations) {
when (val result = reverser.revert(event)) {
RevertResult.Restored -> { reverted++; details += "restored ${event.path}" }
RevertResult.Deleted -> { deleted++; details += "deleted ${event.path}" }
is RevertResult.Rejected -> { rejected++; details += "rejected ${event.path}: ${result.reason}" }
is RevertResult.Failed -> { failed++; details += "failed ${event.path}: ${result.reason}" }
}
}
return UndoSummary(sessionId.value, reverted, deleted, rejected, failed, details)
}
}
@@ -0,0 +1,127 @@
package com.correx.apps.server.undo
import com.correx.core.artifactstore.ArtifactStore
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.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.ToolInvocationId
import com.correx.infrastructure.tools.filesystem.FileMutationReverser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import java.nio.file.Files
class SessionUndoServiceTest {
private class FakeArtifactStore : ArtifactStore {
val blobs = linkedMapOf<String, ByteArray>()
override suspend fun put(bytes: ByteArray): ArtifactId {
val id = ArtifactId(bytes.toString(Charsets.UTF_8).hashCode().toString())
blobs[id.value] = bytes
return id
}
override suspend fun get(id: ArtifactId): ByteArray? = blobs[id.value]
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
private class FakeEventStore(private val events: List<StoredEvent>) : EventStore {
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
override fun read(sessionId: SessionId): List<StoredEvent> = events
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
events.filter { it.sequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? = events.lastOrNull()?.sequence
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = 0L
override fun allEvents(): Sequence<StoredEvent> = emptySequence()
override fun allSessionIds(): Set<SessionId> = emptySet()
}
private fun storedEvent(payload: EventPayload, sessionId: SessionId, seq: Long): StoredEvent =
StoredEvent(
metadata = EventMetadata(
eventId = EventId("e$seq"),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
@Test
fun `undo restores a file that was overwritten`(): Unit = runBlocking {
val dir = Files.createTempDirectory("undo-test").toRealPath()
val target = dir.resolve("file.txt")
Files.writeString(target, "NEW")
val fakeStore = FakeArtifactStore()
val preImageHash = fakeStore.put("OLD".toByteArray()).value
val sessionId = SessionId("session-overwrite")
val event = FileWrittenEvent(
invocationId = ToolInvocationId("inv-1"),
sessionId = sessionId,
path = target.toString(),
preImageHash = preImageHash,
postImageHash = null,
preExisted = true,
timestampMs = 0L,
)
val fakeEventStore = FakeEventStore(listOf(storedEvent(event, sessionId, 1L)))
val reverser = FileMutationReverser(fakeStore, setOf(dir))
val service = SessionUndoService(fakeEventStore, reverser)
val summary = service.undo(sessionId)
assertEquals(1, summary.reverted)
assertEquals(0, summary.deleted)
assertEquals(0, summary.rejected)
assertEquals(0, summary.failed)
assertEquals("OLD", Files.readString(target))
}
@Test
fun `undo deletes a file that was newly created`(): Unit = runBlocking {
val dir = Files.createTempDirectory("undo-new").toRealPath()
val target = dir.resolve("new.txt")
Files.writeString(target, "CREATED")
val fakeStore = FakeArtifactStore()
val sessionId = SessionId("session-create")
val event = FileWrittenEvent(
invocationId = ToolInvocationId("inv-2"),
sessionId = sessionId,
path = target.toString(),
preImageHash = null,
postImageHash = null,
preExisted = false,
timestampMs = 0L,
)
val fakeEventStore = FakeEventStore(listOf(storedEvent(event, sessionId, 1L)))
val reverser = FileMutationReverser(fakeStore, setOf(dir))
val service = SessionUndoService(fakeEventStore, reverser)
val summary = service.undo(sessionId)
assertEquals(0, summary.reverted)
assertEquals(1, summary.deleted)
assertEquals(0, summary.rejected)
assertEquals(0, summary.failed)
assertFalse(Files.exists(target))
}
}