feat(tui,server,config): live config editor + artifact viewer

Two operator features over the existing WebSocket protocol, plus a tui-go nav fix.

Config editor (g / palette "config"):
- core:config gains a thin registry stack: OrchestrationKnobs ([orchestration]
  block), CorrexConfigWriter (round-trip TOML serializer; parseToml(write(c))==c),
  ConfigHolder (AtomicReference live config), and EditableConfig (allowlist of
  editable fields + patch applier; security fields are absent so they can never
  be patched).
- ConfigService validates -> persists (atomic temp+move) -> swaps the holder ->
  rebuilds config-derived services. Live-apply is scoped to safe seams: stage
  timeout, compaction threshold (now a supplier), router knobs (routerFacade
  rebuild), and personalization/project toggles all take effect on the next
  session/turn; in-flight sessions keep the config they started with.
  server.host/port are persisted + badged "restart required", not hot-swapped.
- Protocol: GetConfig / UpdateConfig / ConfigSnapshot; TUI overlay with
  type-aware editing (bool toggle, enum cycle, inline int/string edit) and save.

Artifact viewer (v / palette "artifacts"):
- Server assembles a session's artifacts from the event log and resolves bytes
  from the CAS store (StreamQueries.listArtifacts) — a replay-neutral snapshot
  read. TUI overlay lists artifacts and shows scrollable content.

tui-go fix: workflow failure no longer clears selectedID (which yanked the user
to the list); listNav lands on the first session when nothing is selected
instead of wrapping to a random one.

Config is not event-sourced (it lives in TOML); editing stays outside the log.
This commit is contained in:
2026-06-09 10:18:35 +04:00
parent 89487db72a
commit a92b5a3531
27 changed files with 1629 additions and 72 deletions
@@ -0,0 +1,18 @@
package com.correx.core.config
import java.util.concurrent.atomic.AtomicReference
/**
* Live, swappable [CorrexConfig]. Services that consult [get] at use-time pick up edits applied
* via [set] without a restart. Config is not event-sourced — it lives in TOML — so this holder
* is the single in-memory authority for the running server's current configuration.
*/
class ConfigHolder(initial: CorrexConfig) {
private val ref = AtomicReference(initial)
fun get(): CorrexConfig = ref.get()
fun set(cfg: CorrexConfig) {
ref.set(cfg)
}
}
@@ -123,6 +123,15 @@ object ConfigLoader {
}
}
/** Loads and parses the config at an explicit [path], returning defaults if it is absent. */
fun loadFrom(path: Path): CorrexConfig {
if (!Files.exists(path)) return CorrexConfig()
return runCatching { parseToml(Files.readString(path)) }.getOrElse { e ->
System.err.println("Warning: Failed to parse config at $path: ${e.message}")
CorrexConfig()
}
}
fun configPath(): Path {
val envPath = System.getenv("CORREX_CONFIG")
return if (envPath != null) {
@@ -483,7 +492,7 @@ object ConfigLoader {
val narration = NarrationSettings(
temperature = asDouble(routerNarrationSection["temperature"], 0.7),
topP = asDouble(routerNarrationSection["top_p"], 0.9),
maxTokens = asInt(routerNarrationSection["max_tokens"], 1024),
maxTokens = asInt(routerNarrationSection["max_tokens"], 4096),
maxPerRun = asInt(routerNarrationSection["max_per_run"], 100),
)
@@ -532,6 +541,13 @@ object ConfigLoader {
injectTopK = asInt(projectSection["inject_top_k"], 30),
)
val orchestrationSection = sections["orchestration"] ?: emptyMap()
val orchestration = OrchestrationKnobs(
stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], 180_000),
journalCompactionTokenThreshold =
asInt(orchestrationSection["journal_compaction_token_threshold"], 2_000),
)
val modelsSettings = ModelsSettings(
defaultModel = asStringOrNull(modelsSection["default_model"]),
llamaServerBin = asString(modelsSection["llama_server_bin"], "llama-server"),
@@ -561,9 +577,13 @@ object ConfigLoader {
artifacts = artifacts,
project = project,
personalization = personalization,
orchestration = orchestration,
)
}
/** Test-only entry point to the otherwise-private TOML parser (same module). */
internal fun parseTomlForTest(content: String): CorrexConfig = parseToml(content)
private fun asString(value: Any?, default: String = ""): String {
return when (value) {
is String -> value
@@ -599,6 +619,16 @@ object ConfigLoader {
}
}
private fun asLong(value: Any?, default: Long): Long {
return when (value) {
is Long -> value
is Int -> value.toLong()
is Double -> value.toLong()
is String -> value.toLongOrNull() ?: default
else -> default
}
}
private fun asDouble(value: Any?, default: Double = 0.0): Double {
return when (value) {
is Double -> value
@@ -15,6 +15,19 @@ data class CorrexConfig(
val artifacts: List<ArtifactKindConfig> = emptyList(),
val project: ProjectConfig = ProjectConfig(),
val personalization: PersonalizationConfig = PersonalizationConfig(),
val orchestration: OrchestrationKnobs = OrchestrationKnobs(),
)
/**
* Operator-tunable orchestration knobs. [stageTimeoutMs] bounds a single stage's inference
* turn; local models on large turns can need minutes. [journalCompactionTokenThreshold] is the
* decision-journal size at which compaction kicks in. Mirrors the kernel OrchestrationConfig
* defaults so the config file and the runtime agree when the section is absent.
*/
@Serializable
data class OrchestrationKnobs(
val stageTimeoutMs: Long = 180_000,
val journalCompactionTokenThreshold: Int = 2_000,
)
@Serializable
@@ -134,12 +147,15 @@ data class GenerationSettings(
)
/** Router narration knobs. Larger maxTokens than chat so reasoning models can
* finish "thinking" and still emit the line; maxPerRun caps narrations per run. */
* finish "thinking" and still emit the line; maxPerRun caps narrations per run.
* 1024 proved too tight — reasoning models spent the whole budget thinking and
* returned empty content (finishReason=length), so narrations never reached the
* TUI. 4096 leaves room to finish reasoning and still produce the line. */
@Serializable
data class NarrationSettings(
val temperature: Double = 0.7,
val topP: Double = 0.9,
val maxTokens: Int = 1024,
val maxTokens: Int = 4096,
val maxPerRun: Int = 100,
)
@@ -0,0 +1,158 @@
package com.correx.core.config
/**
* Serializes a [CorrexConfig] back to TOML text that [ConfigLoader] reads identically:
* the invariant is `parseToml(write(cfg)) == cfg` for every field the loader understands.
*
* The writer **regenerates** the whole file from the config object — it does not preserve
* hand-written comments or formatting in an existing config.toml. This is intentional: the
* config is edited through the TUI, which owns the file once a save happens.
*
* Keys mirror the snake_case names [ConfigLoader.buildConfig] reads. Nullable values and
* empty maps are omitted (the loader reconstructs null / emptyMap from their absence). Lists
* that the loader substitutes a default for when empty (privileged locations, interpreter
* executables, ignore globs) are always emitted with their current contents.
*/
object CorrexConfigWriter {
fun write(cfg: CorrexConfig): String {
val b = StringBuilder()
b.section("server")
b.kv("host", str(cfg.server.host))
b.kv("port", cfg.server.port)
b.section("tui")
b.kv("theme", str(cfg.tui.theme))
b.kv("session_list_limit", cfg.tui.sessionListLimit)
b.section("cli")
b.kv("default_output", str(cfg.cli.defaultOutput))
b.section("tools")
b.kv("sandbox_root", str(cfg.tools.sandboxRoot))
b.kv("working_dir", str(cfg.tools.workingDir))
b.kv("workspace_root", str(cfg.tools.workspaceRoot))
b.kv("default_system_prompt_path", str(cfg.tools.defaultSystemPromptPath))
b.kv("privileged_locations", list(cfg.tools.privilegedLocations))
b.kv("interpreter_executables", list(cfg.tools.interpreterExecutables))
b.kv("network_allowed_hosts", list(cfg.tools.networkAllowedHosts))
b.kv("network_denied_hosts", list(cfg.tools.networkDeniedHosts))
b.kv("allowed_workspace_roots", list(cfg.tools.allowedWorkspaceRoots))
b.section("tools.shell")
b.kv("enabled", cfg.tools.shellEnabled)
b.kv("allowed_executables", list(cfg.tools.shellAllowedExecutables))
b.section("tools.file_read")
b.kv("enabled", cfg.tools.fileReadEnabled)
b.section("tools.file_write")
b.kv("enabled", cfg.tools.fileWriteEnabled)
b.section("tools.file_edit")
b.kv("enabled", cfg.tools.fileEditEnabled)
b.section("router")
b.kv("conversation_keep_last", cfg.router.conversationKeepLast)
b.kv("retrieval_k", cfg.router.retrievalK)
b.kv("token_budget", cfg.router.tokenBudget)
b.section("router.embedder")
b.kv("backend", str(cfg.router.embedder.backend))
b.kv("dimension", cfg.router.embedder.dimension)
cfg.router.embedder.url?.let { b.kv("url", str(it)) }
cfg.router.embedder.modelId?.let { b.kv("model_id", str(it)) }
b.section("router.l3")
b.kv("backend", str(cfg.router.l3.backend))
cfg.router.l3.persistPath?.let { b.kv("persist_path", str(it)) }
b.kv("python_executable", str(cfg.router.l3.pythonExecutable))
cfg.router.l3.scriptPath?.let { b.kv("script_path", str(it)) }
b.kv("dim", cfg.router.l3.dim)
b.kv("bit_width", cfg.router.l3.bitWidth)
b.section("router.generation")
b.kv("temperature", cfg.router.generation.temperature)
b.kv("top_p", cfg.router.generation.topP)
b.kv("max_tokens", cfg.router.generation.maxTokens)
b.section("router.narration")
b.kv("temperature", cfg.router.narration.temperature)
b.kv("top_p", cfg.router.narration.topP)
b.kv("max_tokens", cfg.router.narration.maxTokens)
b.kv("max_per_run", cfg.router.narration.maxPerRun)
b.section("orchestration")
b.kv("stage_timeout_ms", cfg.orchestration.stageTimeoutMs)
b.kv("journal_compaction_token_threshold", cfg.orchestration.journalCompactionTokenThreshold)
b.section("personalization")
b.kv("enabled", cfg.personalization.enabled)
b.kv("learn", cfg.personalization.learn)
b.section("project")
b.kv("enabled", cfg.project.enabled)
b.kv("root", str(cfg.project.root))
b.kv("memory_k", cfg.project.memoryK)
b.kv("max_depth", cfg.project.maxDepth)
b.kv("inject_top_k", cfg.project.injectTopK)
b.kv("ignore_globs", list(cfg.project.ignoreGlobs))
b.section("models")
cfg.modelsSettings.defaultModel?.let { b.kv("default_model", str(it)) }
b.kv("llama_server_bin", str(cfg.modelsSettings.llamaServerBin))
b.kv("host", str(cfg.modelsSettings.host))
b.kv("port", cfg.modelsSettings.port)
cfg.providers.forEach { p ->
b.arrayTable("providers")
b.kv("id", str(p.id))
b.kv("type", str(p.type))
b.kv("model_id", str(p.modelId))
b.kv("model_path", str(p.modelPath))
b.kv("url", str(p.url))
if (p.capabilities.isNotEmpty()) b.kv("capabilities", caps(p.capabilities))
}
cfg.models.forEach { m ->
b.arrayTable("models")
b.kv("id", str(m.id))
b.kv("model_path", str(m.modelPath))
b.kv("context_size", m.contextSize)
if (m.params.isNotEmpty()) b.kv("params", strMap(m.params))
if (m.capabilities.isNotEmpty()) b.kv("capabilities", caps(m.capabilities))
}
cfg.artifacts.forEach { a ->
b.arrayTable("artifacts")
b.kv("id", str(a.id))
b.kv("schema_path", str(a.schemaPath))
b.kv("llm_emitted", a.llmEmitted)
}
return b.toString()
}
private fun StringBuilder.section(name: String) {
if (isNotEmpty()) append('\n')
append('[').append(name).append("]\n")
}
private fun StringBuilder.arrayTable(name: String) {
if (isNotEmpty()) append('\n')
append("[[").append(name).append("]]\n")
}
private fun StringBuilder.kv(key: String, value: Any) {
append(key).append(" = ").append(value.toString()).append('\n')
}
private fun str(v: String): String = "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
private fun list(v: List<String>): String = "[" + v.joinToString(", ") { str(it) } + "]"
private fun caps(m: Map<String, Double>): String =
"{ " + m.entries.joinToString(", ") { "${it.key} = ${it.value}" } + " }"
private fun strMap(m: Map<String, String>): String =
"{ " + m.entries.joinToString(", ") { "${it.key} = ${str(it.value)}" } + " }"
}
@@ -0,0 +1,233 @@
// This file is a config-field registry: many one-line pure helpers (per-section copy shims and
// per-type value parsers) by design. The function-count heuristic is a false positive here.
@file:Suppress("TooManyFunctions")
package com.correx.core.config
/** Raised when a config patch references an unknown/non-editable key or a malformed value. */
class ConfigEditError(message: String) : Exception(message)
enum class ConfigFieldType { BOOL, INT, LONG, DOUBLE, STRING, ENUM }
/**
* One operator-editable config field. [getString] renders the current value as a string for the
* snapshot; [withString] returns a new [CorrexConfig] with the parsed value, throwing
* [ConfigEditError] on bad input. [options] is populated only for [ConfigFieldType.ENUM].
*/
class EditableField(
val key: String,
val type: ConfigFieldType,
val options: List<String> = emptyList(),
val getString: (CorrexConfig) -> String,
val withString: (CorrexConfig, String) -> CorrexConfig,
)
/**
* The allowlist of config fields the TUI may edit, plus the patch applier. Security-sensitive
* fields (sandbox/workspace roots, shell-allowed executables, privileged locations, network
* allow/deny lists, provider/model wiring) are deliberately absent: [apply] rejects any key not
* in [fields], so they can never be patched. This registry is the single source of truth shared
* by the patch applier and the protocol snapshot.
*/
object EditableConfig {
private val THEME_OPTIONS = listOf("dark", "light", "soft-blue")
private val OUTPUT_OPTIONS = listOf("human", "json")
val fields: List<EditableField> = listOf(
EditableField(
"server.host", ConfigFieldType.STRING,
getString = { it.server.host },
withString = { c, v -> srv(c) { it.copy(host = v) } },
),
EditableField(
"server.port", ConfigFieldType.INT,
getString = { it.server.port.toString() },
withString = { c, v -> srv(c) { it.copy(port = int("server.port", v)) } },
),
EditableField(
"tui.theme", ConfigFieldType.ENUM, options = THEME_OPTIONS,
getString = { it.tui.theme },
withString = { c, v -> tui(c) { it.copy(theme = enum("tui.theme", v, THEME_OPTIONS)) } },
),
EditableField(
"tui.session_list_limit", ConfigFieldType.INT,
getString = { it.tui.sessionListLimit.toString() },
withString = { c, v -> tui(c) { it.copy(sessionListLimit = int("tui.session_list_limit", v)) } },
),
EditableField(
"cli.default_output", ConfigFieldType.ENUM, options = OUTPUT_OPTIONS,
getString = { it.cli.defaultOutput },
withString = { c, v -> cli(c) { it.copy(defaultOutput = enum("cli.default_output", v, OUTPUT_OPTIONS)) } },
),
EditableField(
"tools.shell_enabled", ConfigFieldType.BOOL,
getString = { it.tools.shellEnabled.toString() },
withString = { c, v -> tls(c) { it.copy(shellEnabled = bool("tools.shell_enabled", v)) } },
),
EditableField(
"tools.file_read_enabled", ConfigFieldType.BOOL,
getString = { it.tools.fileReadEnabled.toString() },
withString = { c, v -> tls(c) { it.copy(fileReadEnabled = bool("tools.file_read_enabled", v)) } },
),
EditableField(
"tools.file_write_enabled", ConfigFieldType.BOOL,
getString = { it.tools.fileWriteEnabled.toString() },
withString = { c, v -> tls(c) { it.copy(fileWriteEnabled = bool("tools.file_write_enabled", v)) } },
),
EditableField(
"tools.file_edit_enabled", ConfigFieldType.BOOL,
getString = { it.tools.fileEditEnabled.toString() },
withString = { c, v -> tls(c) { it.copy(fileEditEnabled = bool("tools.file_edit_enabled", v)) } },
),
EditableField(
"tools.default_system_prompt_path", ConfigFieldType.STRING,
getString = { it.tools.defaultSystemPromptPath },
withString = { c, v -> tls(c) { it.copy(defaultSystemPromptPath = v) } },
),
EditableField(
"router.conversation_keep_last", ConfigFieldType.INT,
getString = { it.router.conversationKeepLast.toString() },
withString = { c, v -> rtr(c) { it.copy(conversationKeepLast = int("router.conversation_keep_last", v)) } },
),
EditableField(
"router.retrieval_k", ConfigFieldType.INT,
getString = { it.router.retrievalK.toString() },
withString = { c, v -> rtr(c) { it.copy(retrievalK = int("router.retrieval_k", v)) } },
),
EditableField(
"router.token_budget", ConfigFieldType.INT,
getString = { it.router.tokenBudget.toString() },
withString = { c, v -> rtr(c) { it.copy(tokenBudget = int("router.token_budget", v)) } },
),
EditableField(
"router.generation.temperature", ConfigFieldType.DOUBLE,
getString = { it.router.generation.temperature.toString() },
withString = { c, v -> gen(c) { it.copy(temperature = dbl("router.generation.temperature", v)) } },
),
EditableField(
"router.generation.top_p", ConfigFieldType.DOUBLE,
getString = { it.router.generation.topP.toString() },
withString = { c, v -> gen(c) { it.copy(topP = dbl("router.generation.top_p", v)) } },
),
EditableField(
"router.generation.max_tokens", ConfigFieldType.INT,
getString = { it.router.generation.maxTokens.toString() },
withString = { c, v -> gen(c) { it.copy(maxTokens = int("router.generation.max_tokens", v)) } },
),
EditableField(
"router.narration.temperature", ConfigFieldType.DOUBLE,
getString = { it.router.narration.temperature.toString() },
withString = { c, v -> nar(c) { it.copy(temperature = dbl("router.narration.temperature", v)) } },
),
EditableField(
"router.narration.top_p", ConfigFieldType.DOUBLE,
getString = { it.router.narration.topP.toString() },
withString = { c, v -> nar(c) { it.copy(topP = dbl("router.narration.top_p", v)) } },
),
EditableField(
"router.narration.max_tokens", ConfigFieldType.INT,
getString = { it.router.narration.maxTokens.toString() },
withString = { c, v -> nar(c) { it.copy(maxTokens = int("router.narration.max_tokens", v)) } },
),
EditableField(
"router.narration.max_per_run", ConfigFieldType.INT,
getString = { it.router.narration.maxPerRun.toString() },
withString = { c, v -> nar(c) { it.copy(maxPerRun = int("router.narration.max_per_run", v)) } },
),
EditableField(
"personalization.enabled", ConfigFieldType.BOOL,
getString = { it.personalization.enabled.toString() },
withString = { c, v -> per(c) { it.copy(enabled = bool("personalization.enabled", v)) } },
),
EditableField(
"personalization.learn", ConfigFieldType.BOOL,
getString = { it.personalization.learn.toString() },
withString = { c, v -> per(c) { it.copy(learn = bool("personalization.learn", v)) } },
),
EditableField(
"project.enabled", ConfigFieldType.BOOL,
getString = { it.project.enabled.toString() },
withString = { c, v -> prj(c) { it.copy(enabled = bool("project.enabled", v)) } },
),
EditableField(
"project.memory_k", ConfigFieldType.INT,
getString = { it.project.memoryK.toString() },
withString = { c, v -> prj(c) { it.copy(memoryK = int("project.memory_k", v)) } },
),
EditableField(
"project.max_depth", ConfigFieldType.INT,
getString = { it.project.maxDepth.toString() },
withString = { c, v -> prj(c) { it.copy(maxDepth = int("project.max_depth", v)) } },
),
EditableField(
"project.inject_top_k", ConfigFieldType.INT,
getString = { it.project.injectTopK.toString() },
withString = { c, v -> prj(c) { it.copy(injectTopK = int("project.inject_top_k", v)) } },
),
EditableField(
"orchestration.stage_timeout_ms", ConfigFieldType.LONG,
getString = { it.orchestration.stageTimeoutMs.toString() },
withString = { c, v -> orc(c) { it.copy(stageTimeoutMs = lng("orchestration.stage_timeout_ms", v)) } },
),
EditableField(
"orchestration.journal_compaction_token_threshold", ConfigFieldType.INT,
getString = { it.orchestration.journalCompactionTokenThreshold.toString() },
withString = { c, v -> orc(c) { it.copy(journalCompactionTokenThreshold = int(JCT_KEY, v)) } },
),
)
private const val JCT_KEY = "orchestration.journal_compaction_token_threshold"
/** Keys that are persisted and editable but only take effect after a server restart. */
val restartRequiredKeys: Set<String> = setOf("server.host", "server.port")
private val byKey: Map<String, EditableField> = fields.associateBy { it.key }
/**
* Folds [patch] (key → string value) onto [cfg]. Returns the updated config, or a failure
* carrying a [ConfigEditError] when a key is unknown/non-editable or a value is malformed.
*/
fun apply(cfg: CorrexConfig, patch: Map<String, String>): Result<CorrexConfig> = runCatching {
patch.entries.fold(cfg) { acc, (key, value) ->
val field = byKey[key] ?: throw ConfigEditError("unknown or non-editable key: $key")
field.withString(acc, value)
}
}
}
// --- file-private helpers (kept out of the object so it stays a thin registry) ---
// Section-scoped copy helpers keep each field's withString a single readable line.
private fun srv(c: CorrexConfig, f: (ServerConfig) -> ServerConfig) = c.copy(server = f(c.server))
private fun tui(c: CorrexConfig, f: (TuiConfig) -> TuiConfig) = c.copy(tui = f(c.tui))
private fun cli(c: CorrexConfig, f: (CliConfig) -> CliConfig) = c.copy(cli = f(c.cli))
private fun tls(c: CorrexConfig, f: (ToolsConfig) -> ToolsConfig) = c.copy(tools = f(c.tools))
private fun rtr(c: CorrexConfig, f: (RouterConfig) -> RouterConfig) = c.copy(router = f(c.router))
private fun gen(c: CorrexConfig, f: (GenerationSettings) -> GenerationSettings) =
rtr(c) { it.copy(generation = f(it.generation)) }
private fun nar(c: CorrexConfig, f: (NarrationSettings) -> NarrationSettings) =
rtr(c) { it.copy(narration = f(it.narration)) }
private fun per(c: CorrexConfig, f: (PersonalizationConfig) -> PersonalizationConfig) =
c.copy(personalization = f(c.personalization))
private fun prj(c: CorrexConfig, f: (ProjectConfig) -> ProjectConfig) = c.copy(project = f(c.project))
private fun orc(c: CorrexConfig, f: (OrchestrationKnobs) -> OrchestrationKnobs) =
c.copy(orchestration = f(c.orchestration))
private fun int(key: String, v: String): Int =
v.trim().toIntOrNull() ?: throw ConfigEditError("$key must be an integer, got '$v'")
private fun lng(key: String, v: String): Long =
v.trim().toLongOrNull() ?: throw ConfigEditError("$key must be an integer, got '$v'")
private fun dbl(key: String, v: String): Double =
v.trim().toDoubleOrNull() ?: throw ConfigEditError("$key must be a number, got '$v'")
private fun bool(key: String, v: String): Boolean = when (v.trim().lowercase()) {
"true" -> true
"false" -> false
else -> throw ConfigEditError("$key must be true or false, got '$v'")
}
private fun enum(key: String, v: String, options: List<String>): String =
if (v in options) v else throw ConfigEditError("$key must be one of ${options.joinToString("|")}, got '$v'")
@@ -495,7 +495,28 @@ class ConfigLoaderTest {
assertEquals(6, result.router.conversationKeepLast)
assertEquals(512, result.router.generation.maxTokens)
assertEquals(1024, result.router.narration.maxTokens)
assertEquals(4096, result.router.narration.maxTokens)
assertEquals(100, result.router.narration.maxPerRun)
}
@Test
fun `parseToml reads orchestration knobs`() {
val toml = """
[orchestration]
stage_timeout_ms = 90000
journal_compaction_token_threshold = 12000
""".trimIndent()
val result = ConfigLoader.parseTomlForTest(toml)
assertEquals(90_000L, result.orchestration.stageTimeoutMs)
assertEquals(12_000, result.orchestration.journalCompactionTokenThreshold)
}
@Test
fun `parseToml orchestration defaults when absent`() {
val result = ConfigLoader.parseTomlForTest("[server]\nport = 8080")
assertEquals(180_000L, result.orchestration.stageTimeoutMs)
assertEquals(2_000, result.orchestration.journalCompactionTokenThreshold)
}
}
@@ -0,0 +1,57 @@
package com.correx.core.config
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class CorrexConfigWriterTest {
@Test
fun `round-trips defaults`() {
val cfg = CorrexConfig()
assertEquals(cfg, ConfigLoader.parseTomlForTest(CorrexConfigWriter.write(cfg)))
}
@Test
fun `round-trips a populated config`() {
val cfg = CorrexConfig(
server = ServerConfig(host = "0.0.0.0", port = 9090),
tui = TuiConfig(theme = "soft-blue", sessionListLimit = 9),
cli = CliConfig(defaultOutput = "json"),
tools = ToolsConfig(
sandboxRoot = "/tmp/sb",
workingDir = "/work",
workspaceRoot = "/ws",
shellEnabled = false,
fileEditEnabled = false,
shellAllowedExecutables = listOf("git", "ls"),
networkAllowedHosts = listOf("example.com"),
),
router = RouterConfig(
conversationKeepLast = 7,
retrievalK = 4,
tokenBudget = 5000,
embedder = EmbedderConfig(backend = "remote", dimension = 768, url = "http://e", modelId = "emb"),
l3 = L3Config(backend = "turbovec", persistPath = "/l3", dim = 768, bitWidth = 8),
generation = GenerationSettings(temperature = 0.3, topP = 0.8, maxTokens = 1024),
narration = NarrationSettings(temperature = 0.1, topP = 0.95, maxTokens = 256, maxPerRun = 3),
),
personalization = PersonalizationConfig(enabled = true, learn = true),
project = ProjectConfig(enabled = true, root = "/repo", memoryK = 8, maxDepth = 6, injectTopK = 40),
modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001),
orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000),
providers = listOf(
ProviderConfig(
id = "p1", type = "llamacpp", modelId = "x", url = "http://127.0.0.1:10000",
capabilities = mapOf("code" to 0.9),
),
),
models = listOf(
ModelConfig(id = "m1", modelPath = "/m1.gguf", contextSize = 4096, capabilities = mapOf("chat" to 0.8)),
),
artifacts = listOf(
ArtifactKindConfig(id = "review", schemaPath = "schemas/review.json", llmEmitted = true),
),
)
assertEquals(cfg, ConfigLoader.parseTomlForTest(CorrexConfigWriter.write(cfg)))
}
}
@@ -0,0 +1,73 @@
package com.correx.core.config
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class EditableConfigTest {
@Test
fun `applies an int patch`() {
val out = EditableConfig.apply(CorrexConfig(), mapOf("tui.session_list_limit" to "12")).getOrThrow()
assertEquals(12, out.tui.sessionListLimit)
}
@Test
fun `applies a bool patch`() {
val out = EditableConfig.apply(CorrexConfig(), mapOf("project.enabled" to "true")).getOrThrow()
assertTrue(out.project.enabled)
}
@Test
fun `applies a double and long patch`() {
val out = EditableConfig.apply(
CorrexConfig(),
mapOf("router.generation.temperature" to "0.25", "orchestration.stage_timeout_ms" to "90000"),
).getOrThrow()
assertEquals(0.25, out.router.generation.temperature)
assertEquals(90_000L, out.orchestration.stageTimeoutMs)
}
@Test
fun `applies an enum patch and rejects an out-of-range enum`() {
val out = EditableConfig.apply(CorrexConfig(), mapOf("tui.theme" to "light")).getOrThrow()
assertEquals("light", out.tui.theme)
assertTrue(EditableConfig.apply(CorrexConfig(), mapOf("tui.theme" to "neon")).isFailure)
}
@Test
fun `rejects unknown key`() {
assertTrue(EditableConfig.apply(CorrexConfig(), mapOf("nope.field" to "1")).isFailure)
}
@Test
fun `rejects security key and excludes it from the registry`() {
assertTrue(EditableConfig.apply(CorrexConfig(), mapOf("tools.sandbox_root" to "/etc")).isFailure)
assertTrue(EditableConfig.fields.none { it.key == "tools.sandbox_root" })
assertTrue(EditableConfig.fields.none { it.key == "tools.shell_allowed_executables" })
}
@Test
fun `rejects non-numeric int`() {
assertTrue(EditableConfig.apply(CorrexConfig(), mapOf("tui.session_list_limit" to "abc")).isFailure)
}
@Test
fun `marks server fields restart-required`() {
assertTrue("server.port" in EditableConfig.restartRequiredKeys)
assertTrue("server.host" in EditableConfig.restartRequiredKeys)
assertFalse("tui.theme" in EditableConfig.restartRequiredKeys)
}
@Test
fun `getString reflects current value for every field`() {
val cfg = CorrexConfig()
// Every field renders without throwing and a patch with that rendered value is a no-op.
EditableConfig.fields.forEach { f ->
val rendered = f.getString(cfg)
val out = EditableConfig.apply(cfg, mapOf(f.key to rendered)).getOrThrow()
assertEquals(rendered, f.getString(out), "round-trip via getString failed for ${f.key}")
}
}
}