refactorPass.kt
src/jvmMain/kotlin/afield/app/server/refactorPass.kt · 187 lines
package afield.app.server
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
The server side of the refactor-pass review panel: two routes that drive tools/refactor_pass.py
in the docs-improvement checkout, one job at a time.
GET /api/refactor-pass answers status, a review item, a change or the delivery diff by running
the CLI and parsing its JSON; POST starts an action, or cancels the running one. Both refuse a
request that is not a same-site docs client, because the answers carry private workspace source.
No part of a request reaches a shell. The browser sends an action name and fields, and
refactorPassArguments rebuilds the argument list from a fixed set of actions, so a field that
fails its length, character-class or absolute-path check yields no command at all. A started job
streams its output to a log file under the checkout's .field-backdoor/refactor-pass/jobs,
keeping the last 24,000 characters in memory for the panel to poll; a query that has not answered
in a minute is killed along with its descendants and reported as a timeout.
internal data class RefactorPassAction
CLI-backed private refactor review; the browser never supplies a shell command.
@Serializable
internal data class RefactorPassAction(
val action: String,
val plan: String = "",
val item: String = "",
val state: String = "",
val reason: String = "",
val recipe: String = "",
val workspaces: List<String> = emptyList(),
val checks: String = "",
val session: String = "",
val root: String = "",
val destination: String = "",
)
@Serializable
private data class RefactorPassResponse(val job: DocsPassJob, val data: JsonElement = JsonNull, val error: String = "")
internal val refactorPassBatches
internal val refactorPassBatches = setOf("prose", "display-text", "dynamic-names", "languages", "syntax", "generated", "source-layout")
private val refactorItemId = Regex("[0-9a-f]{24}")
private val refactorJson = Json { encodeDefaults = true }
internal fun refactorPassArguments(request: RefactorPassAction): List<String>?
internal fun refactorPassArguments(request: RefactorPassAction): List<String>? = when (request.action) {
"create" -> if (request.plan.length in 1..4096 && request.plan.none { it.code < 32 } &&
runCatching { Path.of(request.plan).isAbsolute }.getOrDefault(false)) listOf("create", "--plan", request.plan) else null
"replan", "rehearse", "prepare", "restore", "validate", "land", "close", "continue-review" -> listOf(request.action)
"start" -> if (request.recipe.length in 2..100_000 && request.workspaces.size <= 10 && request.workspaces.all(::refactorAbsolutePath))
listOf("start", "--recipe-json", request.recipe) + request.workspaces.flatMap { listOf("--workspace", it) } else null
"configure" -> if (request.checks.length in 2..100_000) listOf("configure", "--checks-json", request.checks) else null
"resume" -> if (refactorAbsolutePath(request.session)) listOf("resume", "--session", request.session) else null
"publish" -> if (request.root.matches(Regex("[0-9]+")) && refactorAbsolutePath(request.destination))
listOf("publish", "--root", request.root, "--destination", request.destination) else null
"decide" -> if (refactorItemId.matches(request.item) && request.state in setOf("patch", "preserve", "adapter", "question", "open") &&
request.reason.isNotBlank() && request.reason.length <= 8000 && request.reason.none { it.code < 32 && it != '\n' && it != '\t' })
listOf("decide", "--item", request.item, "--state", request.state, "--reason", request.reason) else null
else -> null
}
private fun refactorAbsolutePath(value: String): Boolean = value.length in 1..4096 && value.none { it.code < 32 } &&
runCatching { Path.of(value).isAbsolute }.getOrDefault(false)
internal fun refactorTerminalPlan(launch: TerminalLaunch, checkout: Path, uv: String): Result<TerminalPlan>
An explicit terminal kind uses the pass CLI's writer lease and its own non-landing brief.
body — 65 lines, from line 81
internal fun refactorTerminalPlan(launch: TerminalLaunch, checkout: Path, uv: String): Result<TerminalPlan> {
if ((launch.batch !in refactorPassBatches && launch.batch != "session") || launch.text.length > 4000 || launch.text.any { it.code < 32 && it != '\n' && it != '\t' })
return Result.failure(IllegalArgumentException("invalid refactor batch or direction"))
if (!Files.isRegularFile(checkout.resolve(".field-backdoor/refactor-pass/current.json")))
return Result.failure(IllegalStateException("Import a refactor plan first"))
return Result.success(TerminalPlan(refactorCommand(checkout, uv) +
listOf("agent", "--batch", launch.batch, "--direction", launch.text), checkout, "Refactor: ${launch.batch}"))
}
private fun refactorCommand(checkout: Path, uv: String = terminalExecutable("uv")): List<String> =
listOf(uv, "run", "--project", checkout.toString(), "--group", "backdoor", "python", "tools/refactor_pass.py", "--repo", checkout.toString())
private object RefactorPassJobs {
private val monitor = Any()
private var job = DocsPassJob()
private var activeProcess: Process? = null
fun snapshot(): DocsPassJob = synchronized(monitor) { job }
fun start(directory: Path, arguments: List<String>): DocsPassJob = synchronized(monitor) {
check(!job.running) { "A refactor pass operation is already running" }
val logDirectory = directory.resolve(".field-backdoor/refactor-pass/jobs")
Files.createDirectories(logDirectory)
val logFile = logDirectory.resolve("${System.currentTimeMillis()}-${arguments.first()}.log")
val child = ProcessBuilder(refactorCommand(directory) + arguments).directory(directory.toFile()).redirectErrorStream(true).start()
activeProcess = child
job = DocsPassJob(name = arguments.first(), running = true, startedAtEpochMs = System.currentTimeMillis(), logFile = logFile.toString())
thread(name = "refactor-pass-${arguments.first()}", isDaemon = true) {
try {
Files.newBufferedWriter(logFile).use { file ->
child.inputStream.bufferedReader().forEachLine { line ->
file.write(line); file.newLine(); file.flush()
synchronized(monitor) { job = job.copy(logTail = (job.logTail + line + "\n").takeLast(24_000)) }
}
}
val exit = child.waitFor()
synchronized(monitor) { job = job.copy(running = false, finishedAtEpochMs = System.currentTimeMillis(), exitCode = exit) }
} catch (error: Exception) {
child.destroy()
synchronized(monitor) { job = job.copy(running = false, exitCode = 1, logTail = error.toString()) }
}
}
job
}
fun cancel(): DocsPassJob = synchronized(monitor) {
activeProcess?.takeIf { it.isAlive }?.let { process ->
process.descendants().forEach { it.destroy() }
process.destroy()
}
job
}
fun query(directory: Path, arguments: List<String>): Pair<Int, String> {
val process = ProcessBuilder(refactorCommand(directory) + arguments).directory(directory.toFile()).redirectErrorStream(true).start()
val output = StringBuilder()
val reader = thread(isDaemon = true) { process.inputStream.bufferedReader().use { output.append(it.readText()) } }
if (!process.waitFor(60, TimeUnit.SECONDS)) {
process.descendants().forEach { it.destroy() }; process.destroyForcibly()
reader.join(2000)
return 124 to "Refactor query timed out"
}
reader.join()
return process.exitValue() to output.toString()
}
}
internal fun Route.installRefactorPassRoutes(policy: DocsAccessPolicy)
Reads contain private workspace source, so they share the terminal's loopback/same-site policy.
internal fun Route.installRefactorPassRoutes(policy: DocsAccessPolicy) {
get("/api/refactor-pass") {
if (call.refuseUnlessSameSiteDocsClient(docsPassPolicy(policy))) return@get
val checkout = docsImprovementCheckout() ?: return@get call.respond(HttpStatusCode.ServiceUnavailable, "No checkout")
val item = call.request.queryParameters["item"]
if (item != null && !refactorItemId.matches(item)) return@get call.respond(HttpStatusCode.BadRequest, "Invalid review item")
val change = call.request.queryParameters["change"]
if (change != null && !refactorItemId.matches(change)) return@get call.respond(HttpStatusCode.BadRequest, "Invalid change")
val args = when {
call.request.queryParameters["delivery"] != null -> listOf("delivery-diff")
item != null -> listOf("item", "--item", item)
change != null -> listOf("changes", "--change", change)
call.request.queryParameters["changes"] != null -> listOf("changes")
else -> listOf("status")
}
val (code, output) = withContext(Dispatchers.IO) { RefactorPassJobs.query(checkout, args) }
val data = if (code == 0) runCatching { refactorJson.parseToJsonElement(output) }.getOrDefault(JsonNull) else JsonNull
call.respondText(refactorJson.encodeToString(RefactorPassResponse.serializer(),
RefactorPassResponse(RefactorPassJobs.snapshot(), data, if (code == 0) "" else output)), ContentType.Application.Json)
}
post("/api/refactor-pass") {
if (call.refuseUnlessSameSiteDocsClient(docsPassPolicy(policy))) return@post
val checkout = docsImprovementCheckout() ?: return@post call.respond(HttpStatusCode.ServiceUnavailable, "No checkout")
val request = call.receive<RefactorPassAction>()
if (request.action == "cancel") {
return@post call.respondText(refactorJson.encodeToString(DocsPassJob.serializer(), RefactorPassJobs.cancel()), ContentType.Application.Json)
}
val arguments = refactorPassArguments(request)
?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid refactor request")
if (arguments.first() in setOf("decide", "configure")) {
val (code, output) = withContext(Dispatchers.IO) { RefactorPassJobs.query(checkout, arguments) }
return@post call.respondText(output, ContentType.Application.Json,
if (code == 0) HttpStatusCode.OK else HttpStatusCode.Conflict)
}
val result = runCatching { withContext(Dispatchers.IO) { RefactorPassJobs.start(checkout, arguments) } }
result.onSuccess { call.respondText(refactorJson.encodeToString(DocsPassJob.serializer(), it), ContentType.Application.Json) }
.onFailure { call.respond(HttpStatusCode.Conflict, it.message ?: "Could not start operation") }
}
}