mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 16:53:04 +08:00
feat(partnerctl): add background run mode and log command with pid/log file management
This commit is contained in:
@@ -15,6 +15,7 @@ import kotlin.system.exitProcess
|
|||||||
InitCommand::class,
|
InitCommand::class,
|
||||||
RunCommand::class,
|
RunCommand::class,
|
||||||
ShutdownCommand::class,
|
ShutdownCommand::class,
|
||||||
|
LogCommand::class,
|
||||||
ChatCommand::class,
|
ChatCommand::class,
|
||||||
ConfigCommand::class,
|
ConfigCommand::class,
|
||||||
ModuleCommand::class,
|
ModuleCommand::class,
|
||||||
|
|||||||
@@ -3,16 +3,22 @@ package work.slhaf.partner.ctl.commands
|
|||||||
import picocli.CommandLine
|
import picocli.CommandLine
|
||||||
import work.slhaf.partner.ctl.i18n.I18n.text
|
import work.slhaf.partner.ctl.i18n.I18n.text
|
||||||
import work.slhaf.partner.ctl.support.CommandInterrupted
|
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.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.TimeoutException
|
import java.util.concurrent.TimeoutException
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
@CommandLine.Command(name = "run", description = ["Start Partner agent."])
|
@CommandLine.Command(name = "run", description = ["Start Partner agent."])
|
||||||
class RunCommand : Runnable {
|
class RunCommand : Runnable {
|
||||||
|
|
||||||
|
@CommandLine.Option(names = ["-d", "--background"], description = ["Run Partner in background."])
|
||||||
|
var background: Boolean = false
|
||||||
|
|
||||||
override fun run() {
|
override fun run() {
|
||||||
val home = resolvePartnerHome()
|
val home = resolvePartnerHome()
|
||||||
val partnerJar = resolvePartnerJar(home)
|
val partnerJar = resolvePartnerJar(home)
|
||||||
@@ -21,13 +27,10 @@ class RunCommand : Runnable {
|
|||||||
throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar))
|
throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar))
|
||||||
}
|
}
|
||||||
|
|
||||||
val exitCode = inheritCommand(
|
if (background) {
|
||||||
command = listOf("java", "-jar", partnerJar.toString()),
|
runInBackground(home, partnerJar)
|
||||||
environment = mapOf("PARTNER_HOME" to home.toString()),
|
} else {
|
||||||
)
|
runInForeground(home, partnerJar)
|
||||||
|
|
||||||
if (exitCode != 0) {
|
|
||||||
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,9 +53,11 @@ class ShutdownCommand : Runnable {
|
|||||||
override fun run() {
|
override fun run() {
|
||||||
val home = resolvePartnerHome()
|
val home = resolvePartnerHome()
|
||||||
val partnerJar = resolvePartnerJar(home)
|
val partnerJar = resolvePartnerJar(home)
|
||||||
val processes = findPartnerProcesses(partnerJar)
|
val pidFile = resolvePidFile(home)
|
||||||
|
val processes = findPartnerProcesses(home, partnerJar)
|
||||||
|
|
||||||
if (processes.isEmpty()) {
|
if (processes.isEmpty()) {
|
||||||
|
cleanupStalePidFile(pidFile)
|
||||||
println(text("control.shutdown.info.notRunning", partnerJar))
|
println(text("control.shutdown.info.notRunning", partnerJar))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,7 @@ class ShutdownCommand : Runnable {
|
|||||||
val stopped = waitForExit(process, timeoutSeconds)
|
val stopped = waitForExit(process, timeoutSeconds)
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
println(text("control.shutdown.success.stopped", pid))
|
println(text("control.shutdown.success.stopped", pid))
|
||||||
|
deletePidFileIfMatches(pidFile, pid)
|
||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +80,7 @@ class ShutdownCommand : Runnable {
|
|||||||
process.destroyForcibly()
|
process.destroyForcibly()
|
||||||
if (waitForExit(process, timeoutSeconds)) {
|
if (waitForExit(process, timeoutSeconds)) {
|
||||||
println(text("control.shutdown.success.stopped", pid))
|
println(text("control.shutdown.success.stopped", pid))
|
||||||
|
deletePidFileIfMatches(pidFile, pid)
|
||||||
} else {
|
} else {
|
||||||
failed = true
|
failed = true
|
||||||
println(text("control.shutdown.error.notStopped", pid))
|
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 {
|
private fun resolvePartnerHome(): Path {
|
||||||
val home = System.getenv("PARTNER_HOME")
|
val home = System.getenv("PARTNER_HOME")
|
||||||
?.trim()
|
?.trim()
|
||||||
@@ -104,21 +213,71 @@ private fun resolvePartnerJar(home: Path): Path {
|
|||||||
return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize()
|
return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findPartnerProcesses(partnerJar: Path): List<ProcessHandle> {
|
private fun resolvePidFile(home: Path): Path {
|
||||||
val currentPid = ProcessHandle.current().pid()
|
return home.resolve("state").resolve("run").resolve("partner.pid").toAbsolutePath().normalize()
|
||||||
val jarPath = partnerJar.toString()
|
}
|
||||||
|
|
||||||
|
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<ProcessHandle> {
|
||||||
|
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<ProcessHandle> {
|
||||||
|
val currentPid = ProcessHandle.current().pid()
|
||||||
return ProcessHandle.allProcesses()
|
return ProcessHandle.allProcesses()
|
||||||
.filter { it.pid() != currentPid }
|
.filter { it.pid() != currentPid }
|
||||||
.filter { it.isAlive }
|
.filter { it.isAlive }
|
||||||
.filter { process ->
|
.filter { isPartnerProcess(it, partnerJar) }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPartnerProcess(process: ProcessHandle, partnerJar: Path): Boolean {
|
||||||
|
val jarPath = partnerJar.toString()
|
||||||
val info = process.info()
|
val info = process.info()
|
||||||
val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray()
|
val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray()
|
||||||
val commandLine = info.commandLine().orElse("") ?: ""
|
val commandLine = info.commandLine().orElse("") ?: ""
|
||||||
|
|
||||||
arguments.any { it == jarPath } || commandLine.contains(jarPath)
|
return arguments.any { it == jarPath } || commandLine.contains(jarPath)
|
||||||
}
|
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
|
private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
|
||||||
@@ -129,3 +288,78 @@ private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<String>()
|
||||||
|
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
|
||||||
|
|||||||
@@ -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.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.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.notRunning=No running Partner process found for {0}
|
||||||
control.shutdown.info.stopping=Stopping Partner process pid={0}
|
control.shutdown.info.stopping=Stopping Partner process pid={0}
|
||||||
control.shutdown.success.stopped=Partner process pid={0} stopped
|
control.shutdown.success.stopped=Partner process pid={0} stopped
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ sourceBuild.tool.mvn.reason=从源码构建 Partner 需要 mvn。命令失败:
|
|||||||
|
|
||||||
control.run.error.jarNotFound=Partner runtime jar 不存在:{0}。请先执行 partnerctl init 初始化 Partner。
|
control.run.error.jarNotFound=Partner runtime jar 不存在:{0}。请先执行 partnerctl init 初始化 Partner。
|
||||||
control.run.error.exited=Partner 退出,退出码:{0}
|
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.notRunning=没有找到对应 {0} 的运行中 Partner 进程
|
||||||
control.shutdown.info.stopping=正在停止 Partner 进程 pid={0}
|
control.shutdown.info.stopping=正在停止 Partner 进程 pid={0}
|
||||||
control.shutdown.success.stopped=Partner 进程 pid={0} 已停止
|
control.shutdown.success.stopped=Partner 进程 pid={0} 已停止
|
||||||
|
|||||||
Reference in New Issue
Block a user