diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/PartnerCtl.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/PartnerCtl.kt index 6c0d6640..5cce8dcc 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/PartnerCtl.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/PartnerCtl.kt @@ -15,6 +15,7 @@ import kotlin.system.exitProcess InitCommand::class, RunCommand::class, ShutdownCommand::class, + LogCommand::class, ChatCommand::class, ConfigCommand::class, ModuleCommand::class, diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ControlCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ControlCommand.kt index 9c5835d3..eb7ff46b 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ControlCommand.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ControlCommand.kt @@ -3,16 +3,22 @@ package work.slhaf.partner.ctl.commands import picocli.CommandLine import work.slhaf.partner.ctl.i18n.I18n.text import work.slhaf.partner.ctl.support.CommandInterrupted -import work.slhaf.partner.ctl.support.inheritCommand +import java.io.InputStream +import java.io.OutputStream import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +import java.nio.file.StandardOpenOption import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException +import kotlin.concurrent.thread @CommandLine.Command(name = "run", description = ["Start Partner agent."]) class RunCommand : Runnable { + @CommandLine.Option(names = ["-d", "--background"], description = ["Run Partner in background."]) + var background: Boolean = false + override fun run() { val home = resolvePartnerHome() val partnerJar = resolvePartnerJar(home) @@ -21,13 +27,10 @@ class RunCommand : Runnable { throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar)) } - val exitCode = inheritCommand( - command = listOf("java", "-jar", partnerJar.toString()), - environment = mapOf("PARTNER_HOME" to home.toString()), - ) - - if (exitCode != 0) { - throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode) + if (background) { + runInBackground(home, partnerJar) + } else { + runInForeground(home, partnerJar) } } } @@ -50,9 +53,11 @@ class ShutdownCommand : Runnable { override fun run() { val home = resolvePartnerHome() val partnerJar = resolvePartnerJar(home) - val processes = findPartnerProcesses(partnerJar) + val pidFile = resolvePidFile(home) + val processes = findPartnerProcesses(home, partnerJar) if (processes.isEmpty()) { + cleanupStalePidFile(pidFile) println(text("control.shutdown.info.notRunning", partnerJar)) return } @@ -66,6 +71,7 @@ class ShutdownCommand : Runnable { val stopped = waitForExit(process, timeoutSeconds) if (stopped) { println(text("control.shutdown.success.stopped", pid)) + deletePidFileIfMatches(pidFile, pid) return@forEach } @@ -74,6 +80,7 @@ class ShutdownCommand : Runnable { process.destroyForcibly() if (waitForExit(process, timeoutSeconds)) { println(text("control.shutdown.success.stopped", pid)) + deletePidFileIfMatches(pidFile, pid) } else { failed = true println(text("control.shutdown.error.notStopped", pid)) @@ -90,6 +97,108 @@ class ShutdownCommand : Runnable { } } +@CommandLine.Command(name = "log", description = ["Show Partner logs."]) +class LogCommand : Runnable { + + @CommandLine.Option(names = ["--tail"], description = ["Number of log lines to show before exiting or following."]) + var tailLines: Int = 200 + + @CommandLine.Option(names = ["-f", "--follow"], description = ["Follow appended log output."]) + var follow: Boolean = false + + override fun run() { + val home = resolvePartnerHome() + val logFile = resolveLogFile(home) + + if (!Files.exists(logFile)) { + if (!follow) { + println(text("control.log.info.notFound", logFile)) + return + } + println(text("control.log.info.waiting", logFile)) + waitForLogFile(logFile) + } + + printLastLines(logFile, tailLines) + + if (follow) { + followLog(logFile) + } + } +} + +private fun runInForeground(home: Path, partnerJar: Path) { + val logFile = resolveLogFile(home) + Files.createDirectories(logFile.parent) + + val process = createPartnerProcessBuilder(home, partnerJar) + .redirectInput(ProcessBuilder.Redirect.INHERIT) + .redirectErrorStream(true) + .start() + + val shutdownHook = Thread { + if (process.isAlive) { + process.destroy() + } + } + Runtime.getRuntime().addShutdownHook(shutdownHook) + + val logOutput = Files.newOutputStream( + logFile, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND, + ) + + val copier = thread(name = "partnerctl-run-log-tee") { + logOutput.use { output -> + tee(process.inputStream, output) + } + } + + val exitCode = process.waitFor() + copier.join() + runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) } + + if (exitCode != 0) { + throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode) + } +} + +private fun runInBackground(home: Path, partnerJar: Path) { + val pidFile = resolvePidFile(home) + val logFile = resolveLogFile(home) + val existingProcess = findPartnerProcesses(home, partnerJar).firstOrNull() + + if (existingProcess != null) { + throw CommandInterrupted(text("control.run.error.alreadyRunning", existingProcess.pid())) + } + + Files.createDirectories(pidFile.parent) + Files.createDirectories(logFile.parent) + + val process = createPartnerProcessBuilder(home, partnerJar) + .redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile())) + .redirectErrorStream(true) + .start() + + Files.writeString( + pidFile, + process.pid().toString(), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + ) + + println(text("control.run.success.backgroundStarted", process.pid())) + println(text("control.run.info.logFile", logFile)) +} + +private fun createPartnerProcessBuilder(home: Path, partnerJar: Path): ProcessBuilder { + return ProcessBuilder("java", "-jar", partnerJar.toString()) + .apply { + environment()["PARTNER_HOME"] = home.toString() + } +} + private fun resolvePartnerHome(): Path { val home = System.getenv("PARTNER_HOME") ?.trim() @@ -104,23 +213,73 @@ private fun resolvePartnerJar(home: Path): Path { return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize() } -private fun findPartnerProcesses(partnerJar: Path): List { - val currentPid = ProcessHandle.current().pid() - val jarPath = partnerJar.toString() +private fun resolvePidFile(home: Path): Path { + return home.resolve("state").resolve("run").resolve("partner.pid").toAbsolutePath().normalize() +} +private fun resolveLogFile(home: Path): Path { + return home.resolve("state").resolve("trace").resolve("log").resolve("partner-core.log").toAbsolutePath().normalize() +} + +private fun findPartnerProcesses(home: Path, partnerJar: Path): List { + val pidFile = resolvePidFile(home) + val pidProcess = readPidProcess(pidFile, partnerJar) + if (pidProcess != null) { + return listOf(pidProcess) + } + + cleanupStalePidFile(pidFile) + return findPartnerProcessesByCommandLine(partnerJar) +} + +private fun readPidProcess(pidFile: Path, partnerJar: Path): ProcessHandle? { + if (!Files.isRegularFile(pidFile)) return null + + val pid = Files.readString(pidFile).trim().toLongOrNull() ?: return null + val process = ProcessHandle.of(pid).orElse(null) ?: return null + + return if (process.isAlive && isPartnerProcess(process, partnerJar)) { + process + } else { + null + } +} + +private fun cleanupStalePidFile(pidFile: Path) { + if (!Files.isRegularFile(pidFile)) return + val pid = Files.readString(pidFile).trim().toLongOrNull() + val process = pid?.let { ProcessHandle.of(it).orElse(null) } + if (process == null || !process.isAlive) { + Files.deleteIfExists(pidFile) + } +} + +private fun deletePidFileIfMatches(pidFile: Path, pid: Long) { + if (!Files.isRegularFile(pidFile)) return + val recordedPid = Files.readString(pidFile).trim().toLongOrNull() + if (recordedPid == pid) { + Files.deleteIfExists(pidFile) + } +} + +private fun findPartnerProcessesByCommandLine(partnerJar: Path): List { + val currentPid = ProcessHandle.current().pid() return ProcessHandle.allProcesses() .filter { it.pid() != currentPid } .filter { it.isAlive } - .filter { process -> - val info = process.info() - val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray() - val commandLine = info.commandLine().orElse("") ?: "" - - arguments.any { it == jarPath } || commandLine.contains(jarPath) - } + .filter { isPartnerProcess(it, partnerJar) } .toList() } +private fun isPartnerProcess(process: ProcessHandle, partnerJar: Path): Boolean { + val jarPath = partnerJar.toString() + val info = process.info() + val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray() + val commandLine = info.commandLine().orElse("") ?: "" + + return arguments.any { it == jarPath } || commandLine.contains(jarPath) +} + private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean { return try { process.onExit().get(timeoutSeconds, TimeUnit.SECONDS) @@ -128,4 +287,79 @@ private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean { } catch (_: TimeoutException) { false } -} \ No newline at end of file +} + +private fun tee(input: InputStream, logOutput: OutputStream) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + System.out.write(buffer, 0, read) + System.out.flush() + logOutput.write(buffer, 0, read) + logOutput.flush() + } +} + +private fun printLastLines(logFile: Path, lineCount: Int) { + if (lineCount <= 0) return + + val lines = ArrayDeque() + Files.newBufferedReader(logFile).use { reader -> + while (true) { + val line = reader.readLine() ?: break + if (lines.size == lineCount) { + lines.removeFirst() + } + lines.addLast(line) + } + } + + lines.forEach(::println) +} + +private fun waitForLogFile(logFile: Path) { + while (!Files.exists(logFile)) { + Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) + } +} + +private fun followLog(logFile: Path) { + var position = Files.size(logFile) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (true) { + if (!Files.exists(logFile)) { + Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) + continue + } + + val size = Files.size(logFile) + if (size < position) { + position = 0 + } + + if (size > position) { + Files.newInputStream(logFile).use { input -> + var skipped = input.skip(position) + while (skipped < position) { + val current = input.skip(position - skipped) + if (current <= 0) break + skipped += current + } + + while (true) { + val read = input.read(buffer) + if (read < 0) break + System.out.write(buffer, 0, read) + System.out.flush() + position += read + } + } + } + + Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS) + } +} + +private const val LOG_FOLLOW_INTERVAL_MILLIS = 500L diff --git a/PartnerCtl/src/main/resources/i18n/messages.properties b/PartnerCtl/src/main/resources/i18n/messages.properties index 7bb87175..98b51daf 100644 --- a/PartnerCtl/src/main/resources/i18n/messages.properties +++ b/PartnerCtl/src/main/resources/i18n/messages.properties @@ -92,6 +92,11 @@ sourceBuild.tool.mvn.reason=Required to build Partner from source. Command faile control.run.error.jarNotFound=Partner runtime jar does not exist: {0}. Run partnerctl init first to initialize Partner. control.run.error.exited=Partner exited with code {0} +control.run.error.alreadyRunning=Partner is already running. pid={0} +control.run.success.backgroundStarted=Partner started in background. pid={0} +control.run.info.logFile=Log file: {0} +control.log.info.notFound=Partner log file does not exist: {0} +control.log.info.waiting=Waiting for Partner log file: {0} control.shutdown.info.notRunning=No running Partner process found for {0} control.shutdown.info.stopping=Stopping Partner process pid={0} control.shutdown.success.stopped=Partner process pid={0} stopped diff --git a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties index c5a0124f..7adce33b 100644 --- a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties +++ b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties @@ -92,6 +92,11 @@ sourceBuild.tool.mvn.reason=从源码构建 Partner 需要 mvn。命令失败: control.run.error.jarNotFound=Partner runtime jar 不存在:{0}。请先执行 partnerctl init 初始化 Partner。 control.run.error.exited=Partner 退出,退出码:{0} +control.run.error.alreadyRunning=Partner 已经在运行。pid={0} +control.run.success.backgroundStarted=Partner 已在后台启动。pid={0} +control.run.info.logFile=日志文件:{0} +control.log.info.notFound=Partner 日志文件不存在:{0} +control.log.info.waiting=正在等待 Partner 日志文件:{0} control.shutdown.info.notRunning=没有找到对应 {0} 的运行中 Partner 进程 control.shutdown.info.stopping=正在停止 Partner 进程 pid={0} control.shutdown.success.stopped=Partner 进程 pid={0} 已停止