diff --git a/.idea/misc.xml b/.idea/misc.xml index 02e5ed06..d760821e 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,26 +1,27 @@ - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + 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 dd1a0443..6c0d6640 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/PartnerCtl.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/PartnerCtl.kt @@ -14,6 +14,7 @@ import kotlin.system.exitProcess subcommands = [ InitCommand::class, RunCommand::class, + ShutdownCommand::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 new file mode 100644 index 00000000..9c5835d3 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ControlCommand.kt @@ -0,0 +1,131 @@ +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.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +@CommandLine.Command(name = "run", description = ["Start Partner agent."]) +class RunCommand : Runnable { + + override fun run() { + val home = resolvePartnerHome() + val partnerJar = resolvePartnerJar(home) + + if (!Files.isRegularFile(partnerJar)) { + 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) + } + } +} + +@CommandLine.Command(name = "shutdown", description = ["Shutdown Partner agent."]) +class ShutdownCommand : Runnable { + + @CommandLine.Option( + names = ["--timeout"], + description = ["Seconds to wait after graceful termination before failing or forcing shutdown."] + ) + var timeoutSeconds: Long = 10 + + @CommandLine.Option( + names = ["-f", "--force"], + description = ["Forcefully kill matching Partner process if it does not exit before timeout."] + ) + var force: Boolean = false + + override fun run() { + val home = resolvePartnerHome() + val partnerJar = resolvePartnerJar(home) + val processes = findPartnerProcesses(partnerJar) + + if (processes.isEmpty()) { + println(text("control.shutdown.info.notRunning", partnerJar)) + return + } + + var failed = false + processes.forEach { process -> + val pid = process.pid() + println(text("control.shutdown.info.stopping", pid)) + process.destroy() + + val stopped = waitForExit(process, timeoutSeconds) + if (stopped) { + println(text("control.shutdown.success.stopped", pid)) + return@forEach + } + + if (force) { + println(text("control.shutdown.warn.force", pid)) + process.destroyForcibly() + if (waitForExit(process, timeoutSeconds)) { + println(text("control.shutdown.success.stopped", pid)) + } else { + failed = true + println(text("control.shutdown.error.notStopped", pid)) + } + } else { + failed = true + println(text("control.shutdown.error.notStoppedUseForce", pid)) + } + } + + if (failed) { + throw CommandInterrupted(text("control.shutdown.error.failed")) + } + } +} + +private fun resolvePartnerHome(): Path { + val home = System.getenv("PARTNER_HOME") + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { Paths.get(it) } + ?: Paths.get(System.getProperty("user.home"), ".partner") + + return home.toAbsolutePath().normalize() +} + +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() + + 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) + } + .toList() +} + +private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean { + return try { + process.onExit().get(timeoutSeconds, TimeUnit.SECONDS) + true + } catch (_: TimeoutException) { + false + } +} \ No newline at end of file diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/RunCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/RunCommand.kt deleted file mode 100644 index 9576cecd..00000000 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/RunCommand.kt +++ /dev/null @@ -1,10 +0,0 @@ -package work.slhaf.partner.ctl.commands - -import picocli.CommandLine - -@CommandLine.Command(name = "run") -class RunCommand : Runnable{ - override fun run() { - TODO("Not yet implemented") - } -} \ No newline at end of file diff --git a/PartnerCtl/src/main/resources/i18n/messages.properties b/PartnerCtl/src/main/resources/i18n/messages.properties index 19832ad8..7bb87175 100644 --- a/PartnerCtl/src/main/resources/i18n/messages.properties +++ b/PartnerCtl/src/main/resources/i18n/messages.properties @@ -89,3 +89,13 @@ sourceBuild.tool.java.reason=Required to run Maven. Command failed: java --versi sourceBuild.tool.javac.reason=Required to compile Partner from source. Install a JDK, not just a JRE. Command failed: javac --version sourceBuild.tool.git.reason=Required to clone Partner source. Command failed: git --version sourceBuild.tool.mvn.reason=Required to build Partner from source. Command failed: mvn --version + +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.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 +control.shutdown.warn.force=Force killing Partner process pid={0} +control.shutdown.error.notStopped=Partner process pid={0} did not stop +control.shutdown.error.notStoppedUseForce=Partner process pid={0} did not stop. Use --force to kill it forcibly. +control.shutdown.error.failed=Failed to stop one or more Partner processes diff --git a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties index 54dad2c4..c5a0124f 100644 --- a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties +++ b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties @@ -89,3 +89,13 @@ sourceBuild.tool.java.reason=运行 Maven 需要 java。命令失败:java --ve sourceBuild.tool.javac.reason=从源码编译 Partner 需要 javac。请安装 JDK,而不只是 JRE。命令失败:javac --version sourceBuild.tool.git.reason=克隆 Partner 源码需要 git。命令失败:git --version sourceBuild.tool.mvn.reason=从源码构建 Partner 需要 mvn。命令失败:mvn --version + +control.run.error.jarNotFound=Partner runtime jar 不存在:{0}。请先执行 partnerctl init 初始化 Partner。 +control.run.error.exited=Partner 退出,退出码:{0} +control.shutdown.info.notRunning=没有找到对应 {0} 的运行中 Partner 进程 +control.shutdown.info.stopping=正在停止 Partner 进程 pid={0} +control.shutdown.success.stopped=Partner 进程 pid={0} 已停止 +control.shutdown.warn.force=正在强制结束 Partner 进程 pid={0} +control.shutdown.error.notStopped=Partner 进程 pid={0} 未停止 +control.shutdown.error.notStoppedUseForce=Partner 进程 pid={0} 未停止。使用 --force 可强制结束。 +control.shutdown.error.failed=一个或多个 Partner 进程停止失败