diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt index 5f073929..4dd24b73 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt @@ -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(), ) diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/UndoCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/UndoCommand.kt new file mode 100644 index 00000000..799c5b8f --- /dev/null +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/UndoCommand.kt @@ -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() + } + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 43c1af4f..81b38078 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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("==============================") diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index c625bd67..04dc471f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -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. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt index ddcc1b86..43fe1585 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt @@ -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"] diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/undo/SessionUndoService.kt b/apps/server/src/main/kotlin/com/correx/apps/server/undo/SessionUndoService.kt new file mode 100644 index 00000000..2d530d29 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/undo/SessionUndoService.kt @@ -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 = 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() + + 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) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/undo/SessionUndoServiceTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/undo/SessionUndoServiceTest.kt new file mode 100644 index 00000000..03165c3e --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/undo/SessionUndoServiceTest.kt @@ -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() + 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) : EventStore { + override suspend fun append(event: NewEvent): StoredEvent = error("unused") + override suspend fun appendAll(events: List): List = error("unused") + override fun read(sessionId: SessionId): List = events + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + events.filter { it.sequence >= fromSequence } + override fun lastSequence(sessionId: SessionId): Long? = events.lastOrNull()?.sequence + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + override fun subscribeAll(): Flow = emptyFlow() + override suspend fun lastGlobalSequence(): Long = 0L + override fun allEvents(): Sequence = emptySequence() + override fun allSessionIds(): Set = 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)) + } +}