feat(partnerctl): add --log-level option for run command and wire runtime log level to Partner-Core

This commit is contained in:
2026-05-09 14:12:32 +08:00
parent d72b34acfd
commit 8cb876b532
6 changed files with 154 additions and 115 deletions

View File

@@ -4,11 +4,7 @@ import picocli.CommandLine
import work.slhaf.partner.ctl.commands.control.*
import work.slhaf.partner.ctl.i18n.I18n.text
import work.slhaf.partner.ctl.support.CommandInterrupted
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.time.LocalDateTime
@CommandLine.Command(
name = "run",
@@ -26,6 +22,14 @@ class RunCommand : Runnable {
)
var background: Boolean = false
@CommandLine.Option(
names = ["-l", "--log-level"],
descriptionKey = "cli.log.option.level.description",
defaultValue = "INFO",
converter = [LogLevelConverter::class],
)
lateinit var logLevel: LogLevel
override fun run() {
val home = resolvePartnerHome()
val partnerJar = resolvePartnerJar(home)
@@ -35,98 +39,10 @@ class RunCommand : Runnable {
}
if (background) {
runInBackground(home, partnerJar)
runInBackground(home, partnerJar, logLevel)
} else {
runInForeground(home, partnerJar)
runInForeground(home, partnerJar, logLevel)
}
}
}
private fun runInForeground(home: Path, partnerJar: Path) {
val logFile = resolveLogFile(home)
Files.createDirectories(logFile.parent)
val process = createPartnerProcessBuilder(home, partnerJar)
.inheritIO()
.start()
appendControlLog(logFile, text("control.run.log.foregroundStarting", process.pid(), partnerJar))
val shutdownHook = Thread {
if (process.isAlive) {
process.destroy()
}
}
Runtime.getRuntime().addShutdownHook(shutdownHook)
val exitCode = process.waitFor()
runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) }
appendControlLog(logFile, text("control.run.log.exited", exitCode))
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.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
appendControlLog(logFile, text("control.run.log.backgroundStarting", process.pid(), partnerJar))
Thread.sleep(BACKGROUND_START_CHECK_MILLIS)
if (!process.isAlive) {
val exitCode = process.exitValue()
appendControlLog(logFile, text("control.run.log.exited", exitCode))
Files.deleteIfExists(pidFile)
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
}
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", "-DPARTNER_HOME=$home", "-jar", partnerJar.toString())
.apply {
environment()["PARTNER_HOME"] = home.toString()
}
}
private fun appendControlLog(logFile: Path, message: String) {
Files.createDirectories(logFile.parent)
Files.newOutputStream(
logFile,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND,
).use { output ->
writeControlLog(output, message)
}
}
private fun writeControlLog(output: OutputStream, message: String) {
output.write("[partnerctl ${LocalDateTime.now()}] $message\n".toByteArray())
output.flush()
}
private const val BACKGROUND_START_CHECK_MILLIS = 500L

View File

@@ -1,8 +1,14 @@
package work.slhaf.partner.ctl.commands.control
import picocli.CommandLine
import work.slhaf.partner.ctl.i18n.I18n.text
import work.slhaf.partner.ctl.support.CommandInterrupted
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.time.LocalDateTime
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
@@ -25,7 +31,8 @@ fun resolvePidFile(home: Path): Path {
}
fun resolveLogFile(home: Path): Path {
return home.resolve("state").resolve("trace").resolve("log").resolve("partner-core.log").toAbsolutePath().normalize()
return home.resolve("state").resolve("trace").resolve("log").resolve("partner-core.log").toAbsolutePath()
.normalize()
}
fun findPartnerProcesses(home: Path, partnerJar: Path): List<ProcessHandle> {
@@ -95,3 +102,115 @@ private fun isPartnerProcess(process: ProcessHandle, partnerJar: Path): Boolean
return arguments.any { it == jarPath } || commandLine.contains(jarPath)
}
fun runInForeground(home: Path, partnerJar: Path, logLevel: LogLevel) {
val logFile = resolveLogFile(home)
Files.createDirectories(logFile.parent)
val process = createPartnerProcessBuilder(home, partnerJar, logLevel)
.inheritIO()
.start()
appendControlLog(logFile, text("control.run.log.foregroundStarting", process.pid(), partnerJar))
val shutdownHook = Thread {
if (process.isAlive) {
process.destroy()
}
}
Runtime.getRuntime().addShutdownHook(shutdownHook)
val exitCode = process.waitFor()
runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) }
appendControlLog(logFile, text("control.run.log.exited", exitCode))
if (exitCode != 0) {
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
}
}
fun runInBackground(home: Path, partnerJar: Path, logLevel: LogLevel) {
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, logLevel)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
appendControlLog(logFile, text("control.run.log.backgroundStarting", process.pid(), partnerJar))
Thread.sleep(BACKGROUND_START_CHECK_MILLIS)
if (!process.isAlive) {
val exitCode = process.exitValue()
appendControlLog(logFile, text("control.run.log.exited", exitCode))
Files.deleteIfExists(pidFile)
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
}
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, logLevel: LogLevel): ProcessBuilder {
return ProcessBuilder(
"java",
"-Dpartner.log.level=${logLevel.name.uppercase()}",
"-DPARTNER_HOME=$home",
"-jar",
partnerJar.toString()
)
.apply {
environment()["PARTNER_HOME"] = home.toString()
}
}
private fun appendControlLog(logFile: Path, message: String) {
Files.createDirectories(logFile.parent)
Files.newOutputStream(
logFile,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND,
).use { output ->
writeControlLog(output, message)
}
}
private fun writeControlLog(output: OutputStream, message: String) {
output.write("[partnerctl ${LocalDateTime.now()}] $message\n".toByteArray())
output.flush()
}
private const val BACKGROUND_START_CHECK_MILLIS = 500L
enum class LogLevel {
TRACE, DEBUG, INFO, WARN, ERROR
}
class LogLevelConverter : CommandLine.ITypeConverter<LogLevel> {
override fun convert(value: String): LogLevel {
return LogLevel.entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
} ?: throw CommandLine.TypeConversionException(
"invalid log level '$value'. Valid values: ${
LogLevel.entries.joinToString(", ") { it.name.lowercase() }
}"
)
}
}

View File

@@ -8,6 +8,7 @@ cli.shutdown.option.force.description=Forcefully kill matching Partner process i
cli.log.description=Show Partner logs.
cli.log.option.tail.description=Number of log lines to show before exiting or following.
cli.log.option.follow.description=Follow appended log output.
cli.log.option.level.description=Set Partner runtime log level. Available values: TRACE, DEBUG, INFO, WARN, ERROR. Default: INFO.
cli.chat.description=Start an interactive chat client.
cli.config.description=Manage Partner configuration.
cli.module.description=Manage Partner modules.

View File

@@ -2,12 +2,13 @@ cli.partnerctl.description=Partner 命令行工具。
cli.init.description=初始化 Partner agent。
cli.run.description=启动 Partner agent。
cli.run.option.background.description=后台运行 Partner。
cli.shutdown.description=停止 Partner agent。
cli.shutdown.option.timeout.description=优雅停止后等待的秒数,超时后失败或强制停止。
cli.shutdown.option.force.description=如果匹配的 Partner 进程没有在超时前退出,则强制结束进程。
cli.log.description=查看 Partner 日志。
cli.log.option.tail.description=退出或 follow 前显示的日志行数。
cli.log.option.follow.description=持续跟随新增日志输出。
cli.log.option.level.description=指定 Partner 运行时日志等级。可选值TRACE、DEBUG、INFO、WARN、ERROR。默认值INFO。
cli.shutdown.description=停止 Partner agent。
cli.chat.description=启动交互式聊天客户端。
cli.config.description=管理 Partner 配置。
cli.module.description=管理 Partner 模块。