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:
@@ -9,6 +9,8 @@ import com.correx.core.artifacts.kind.ArtifactKind
|
||||
import com.correx.core.artifacts.kind.ConfigArtifactKind
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.apps.server.config.ConfigService
|
||||
import com.correx.core.config.ConfigHolder
|
||||
import com.correx.core.config.ConfigLoader
|
||||
import com.correx.core.config.CorrexConfig
|
||||
import com.correx.core.config.OperatorProfile
|
||||
@@ -38,6 +40,7 @@ import com.correx.core.kernel.orchestration.WorkspaceTools
|
||||
import com.correx.core.kernel.orchestration.WorkspaceToolRegistryProvider
|
||||
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||
import com.correx.core.risk.DefaultRiskAssessor
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.router.l3.L3MetadataRehydrator
|
||||
import com.correx.core.router.model.RouterConfig
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
@@ -100,6 +103,10 @@ fun main() {
|
||||
logStoresInfo(eventStore, artifactStore)
|
||||
|
||||
val correxConfig = ConfigLoader.load()
|
||||
// Live, swappable config. Hot-tunable fields (orchestration timeout, router/compaction knobs,
|
||||
// personalization/project toggles) are read from this holder at use-time; ConfigService swaps
|
||||
// it and rebuilds the config-derived services on a TUI edit.
|
||||
val configHolder = ConfigHolder(correxConfig)
|
||||
val operatorProfile: OperatorProfile? = if (correxConfig.personalization.enabled) {
|
||||
ProfileLoader.load()
|
||||
} else null
|
||||
@@ -247,15 +254,17 @@ fun main() {
|
||||
val defaultOrchestrationConfig = OrchestrationConfig(
|
||||
sandboxRoot = sandboxRoot,
|
||||
defaultSystemPromptPath = toolsConfig.defaultSystemPromptPath,
|
||||
// Local models are slow on large implementer/reviewer turns (plan + design + file
|
||||
// contents); 60s was timing out ~mid-run and burning every retry. 3 min gives the
|
||||
// turn room to complete. TODO: surface this as a config knob instead of a hardcode.
|
||||
stageTimeoutMs = 180_000L,
|
||||
// Stage timeout is now an operator-tunable knob ([orchestration] stage_timeout_ms, default
|
||||
// 3 min — local models are slow on large turns). ServerModule.orchestrationConfig() reads
|
||||
// the live value from the holder per session, so this base value is just the boot default.
|
||||
stageTimeoutMs = correxConfig.orchestration.stageTimeoutMs,
|
||||
journalCompactionTokenThreshold = correxConfig.orchestration.journalCompactionTokenThreshold,
|
||||
)
|
||||
val journalCompactionService = JournalCompactionService(
|
||||
artifactStore = artifactStore,
|
||||
summarize = { prompt -> summarizeWithInference(prompt, SessionId("system"), inferenceRouter) },
|
||||
tokenThreshold = defaultOrchestrationConfig.journalCompactionTokenThreshold,
|
||||
// Supplier reads the live threshold each compaction check (no orchestrator rebuild needed).
|
||||
tokenThreshold = { configHolder.get().orchestration.journalCompactionTokenThreshold },
|
||||
)
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
@@ -266,30 +275,33 @@ fun main() {
|
||||
decisionJournalRepository = decisionJournalRepository,
|
||||
compactionService = journalCompactionService,
|
||||
)
|
||||
val routerConfig = correxConfig.router
|
||||
val embedder = InfrastructureModule.createEmbedderFromConfig(routerConfig.embedder)
|
||||
val l3MemoryStore = InfrastructureModule.createL3MemoryStoreFromConfig(routerConfig.l3)
|
||||
val embedder = InfrastructureModule.createEmbedderFromConfig(correxConfig.router.embedder)
|
||||
val l3MemoryStore = InfrastructureModule.createL3MemoryStoreFromConfig(correxConfig.router.l3)
|
||||
// Rebuild the L3 metadata map from the ChatTurnEvent log before serving queries:
|
||||
// persisted vectors survive restart but the metadata map does not (no-op for
|
||||
// non-rehydratable backends like in_memory).
|
||||
runBlocking { L3MetadataRehydrator(eventStore).rehydrate(l3MemoryStore) }
|
||||
// Map the config-file [router] block onto the domain RouterConfig the facade uses.
|
||||
// Builds the router facade from a config snapshot, mapping the [router] block onto the domain
|
||||
// RouterConfig. Reused by ConfigService's rebuild hook so router knob edits apply live (the
|
||||
// embedder and L3 store are construction-time and reused; the facade is what gets swapped).
|
||||
fun buildRouterFacade(cfg: CorrexConfig): RouterFacade {
|
||||
val rc = cfg.router
|
||||
val domainRouterConfig = RouterConfig(
|
||||
conversationKeepLast = routerConfig.conversationKeepLast,
|
||||
retrievalK = routerConfig.retrievalK,
|
||||
tokenBudget = TokenBudget(limit = routerConfig.tokenBudget),
|
||||
conversationKeepLast = rc.conversationKeepLast,
|
||||
retrievalK = rc.retrievalK,
|
||||
tokenBudget = TokenBudget(limit = rc.tokenBudget),
|
||||
generationConfig = GenerationConfig(
|
||||
temperature = routerConfig.generation.temperature,
|
||||
topP = routerConfig.generation.topP,
|
||||
maxTokens = routerConfig.generation.maxTokens,
|
||||
temperature = rc.generation.temperature,
|
||||
topP = rc.generation.topP,
|
||||
maxTokens = rc.generation.maxTokens,
|
||||
),
|
||||
narrationGenerationConfig = GenerationConfig(
|
||||
temperature = routerConfig.narration.temperature,
|
||||
topP = routerConfig.narration.topP,
|
||||
maxTokens = routerConfig.narration.maxTokens,
|
||||
temperature = rc.narration.temperature,
|
||||
topP = rc.narration.topP,
|
||||
maxTokens = rc.narration.maxTokens,
|
||||
),
|
||||
)
|
||||
val routerFacade = InfrastructureModule.createRouterFacade(
|
||||
return InfrastructureModule.createRouterFacade(
|
||||
eventStore = eventStore,
|
||||
inferenceRouter = inferenceRouter,
|
||||
config = domainRouterConfig,
|
||||
@@ -297,6 +309,8 @@ fun main() {
|
||||
embedder = embedder,
|
||||
l3MemoryStore = l3MemoryStore,
|
||||
)
|
||||
}
|
||||
val routerFacade = buildRouterFacade(correxConfig)
|
||||
val sessionUndoService = com.correx.apps.server.undo.SessionUndoService(
|
||||
eventStore = eventStore,
|
||||
artifactStore = artifactStore,
|
||||
@@ -314,28 +328,35 @@ fun main() {
|
||||
privilegedLocations = privilegedLocations,
|
||||
allowedWorkspaceRoots = allowedWorkspaceRoots,
|
||||
)
|
||||
val projectMemory = if (correxConfig.project.enabled) {
|
||||
// Built from a config snapshot and reused by ConfigService's rebuild hook so toggling
|
||||
// project.enabled / personalization.* applies live to the next session.
|
||||
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
|
||||
if (cfg.project.enabled) {
|
||||
com.correx.apps.server.memory.ProjectMemoryService(
|
||||
config = correxConfig.project,
|
||||
config = cfg.project,
|
||||
embedder = embedder,
|
||||
l3MemoryStore = l3MemoryStore,
|
||||
journalRepository = decisionJournalRepository,
|
||||
eventStore = eventStore,
|
||||
indexer = com.correx.apps.server.memory.RepoMapIndexer(
|
||||
maxDepth = correxConfig.project.maxDepth,
|
||||
ignoreGlobs = correxConfig.project.ignoreGlobs,
|
||||
maxDepth = cfg.project.maxDepth,
|
||||
ignoreGlobs = cfg.project.ignoreGlobs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val profileAdaptationService: ProfileAdaptationService? =
|
||||
if (correxConfig.personalization.enabled && correxConfig.personalization.learn) {
|
||||
fun buildProfileAdaptation(cfg: CorrexConfig): ProfileAdaptationService? =
|
||||
if (cfg.personalization.enabled && cfg.personalization.learn) {
|
||||
ProfileAdaptationService(
|
||||
decisionJournalRepository = decisionJournalRepository,
|
||||
summarize = { prompt -> summarizeWithInference(prompt, SessionId("system"), inferenceRouter) },
|
||||
)
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val projectMemory = buildProjectMemory(correxConfig)
|
||||
val profileAdaptationService: ProfileAdaptationService? = buildProfileAdaptation(correxConfig)
|
||||
|
||||
val configArtifactKinds = loadConfigArtifactKinds(correxConfig)
|
||||
val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg ->
|
||||
@@ -366,12 +387,22 @@ fun main() {
|
||||
modelSwapper = modelSwapper,
|
||||
resourceProbe = resourceProbe,
|
||||
workspaceResolver = workspaceResolver,
|
||||
narrationMaxPerRun = routerConfig.narration.maxPerRun,
|
||||
narrationMaxPerRun = correxConfig.router.narration.maxPerRun,
|
||||
projectMemory = projectMemory,
|
||||
configHolder = configHolder,
|
||||
freestyleDriver = freestyleDriver,
|
||||
operatorProfile = operatorProfile,
|
||||
profileAdaptationService = profileAdaptationService,
|
||||
)
|
||||
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
|
||||
// services. Built after the module so the rebuild hook can swap them in place.
|
||||
module.configService = ConfigService(configHolder, ConfigLoader.configPath()) { cfg ->
|
||||
module.applyRebuiltServices(
|
||||
newRouterFacade = buildRouterFacade(cfg),
|
||||
newProjectMemory = buildProjectMemory(cfg),
|
||||
newProfileAdaptationService = buildProfileAdaptation(cfg),
|
||||
)
|
||||
}
|
||||
module.start()
|
||||
log.info("==============================")
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ class ServerModule(
|
||||
val workflowRegistry: WorkflowRegistry,
|
||||
val providerRegistry: ProviderRegistry,
|
||||
val defaultOrchestrationConfig: OrchestrationConfig,
|
||||
val routerFacade: RouterFacade,
|
||||
// Rebuilt by ConfigService on a live config edit (router knobs); read at use-time by
|
||||
// GlobalStreamHandler.onUserInput so the next chat turn picks up the new facade.
|
||||
routerFacade: RouterFacade,
|
||||
val orchestrationRepository: OrchestrationRepository,
|
||||
val approvalRepository: DefaultApprovalRepository,
|
||||
val toolRegistry: ToolRegistry,
|
||||
@@ -73,19 +75,62 @@ class ServerModule(
|
||||
approvalCoordinator: ApprovalCoordinator? = null,
|
||||
val narrationMaxPerRun: Int = 100,
|
||||
// Cross-session, repo-scoped memory. Null disables the hooks entirely (tests / static path).
|
||||
val projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null,
|
||||
// Rebuilt by ConfigService when project.enabled is toggled live.
|
||||
projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null,
|
||||
// Live, swappable config. Null only in tests that don't exercise config editing; defaults to a
|
||||
// holder seeded from defaults so callers always have a value to read.
|
||||
val configHolder: com.correx.core.config.ConfigHolder =
|
||||
com.correx.core.config.ConfigHolder(com.correx.core.config.CorrexConfig()),
|
||||
// Driver for the two-phase freestyle workflow: locks the plan and runs phase 2.
|
||||
// Null on non-freestyle paths; injected by Main when freestyle is configured.
|
||||
private val freestyleDriver: FreestyleDriver? = null,
|
||||
// Operator profile snapshot loaded at server start. Null when personalization is disabled.
|
||||
private val operatorProfile: OperatorProfile? = null,
|
||||
// Propose learned profile updates at session end. Null when learn=false or no profile.
|
||||
private val profileAdaptationService: com.correx.apps.server.personalization.ProfileAdaptationService? = null,
|
||||
// Rebuilt by ConfigService when personalization.enabled/learn is toggled live.
|
||||
profileAdaptationService: com.correx.apps.server.personalization.ProfileAdaptationService? = null,
|
||||
) {
|
||||
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||
orchestrator = orchestrator,
|
||||
)
|
||||
|
||||
// Config-derived services that ConfigService swaps on a live edit. Reads happen at use-time
|
||||
// (next session / next chat turn), so in-flight sessions keep the config they started with.
|
||||
@Volatile
|
||||
var routerFacade: RouterFacade = routerFacade
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = projectMemory
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var profileAdaptationService: com.correx.apps.server.personalization.ProfileAdaptationService? =
|
||||
profileAdaptationService
|
||||
private set
|
||||
|
||||
// Set by Main after construction (it needs a reference to the module to build the rebuild hook).
|
||||
lateinit var configService: com.correx.apps.server.config.ConfigService
|
||||
|
||||
/**
|
||||
* Swaps the config-derived services after a live config edit. Called by [ConfigService].
|
||||
* In-flight sessions are untouched; the next session/turn reads the new instances.
|
||||
*/
|
||||
fun applyRebuiltServices(
|
||||
newRouterFacade: RouterFacade,
|
||||
newProjectMemory: com.correx.apps.server.memory.ProjectMemoryService?,
|
||||
newProfileAdaptationService: com.correx.apps.server.personalization.ProfileAdaptationService?,
|
||||
) {
|
||||
this.routerFacade = newRouterFacade
|
||||
this.projectMemory = newProjectMemory
|
||||
this.profileAdaptationService = newProfileAdaptationService
|
||||
}
|
||||
|
||||
/** Orchestration config for a new session, with live-tunable knobs read from [configHolder]. */
|
||||
fun orchestrationConfig(): OrchestrationConfig = defaultOrchestrationConfig.copy(
|
||||
stageTimeoutMs = configHolder.get().orchestration.stageTimeoutMs,
|
||||
)
|
||||
|
||||
private var subscriptionJob: Job? = null
|
||||
private val activeSessionJobs = ConcurrentHashMap<SessionId, Job>()
|
||||
|
||||
@@ -137,7 +182,7 @@ class ServerModule(
|
||||
fun launchSessionRun(
|
||||
sessionId: SessionId,
|
||||
graph: com.correx.core.transitions.graph.WorkflowGraph,
|
||||
sessionConfig: OrchestrationConfig = defaultOrchestrationConfig,
|
||||
sessionConfig: OrchestrationConfig = orchestrationConfig(),
|
||||
) {
|
||||
activeSessionJobs.computeIfAbsent(sessionId) {
|
||||
moduleScope.launch {
|
||||
@@ -217,7 +262,7 @@ class ServerModule(
|
||||
activeSessionJobs.computeIfAbsent(sessionId) {
|
||||
moduleScope.launch {
|
||||
runCatching {
|
||||
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
|
||||
orchestrator.resume(sessionId, graph, orchestrationConfig())
|
||||
}.onFailure { e ->
|
||||
log.error("resumeSession: session={} failed", sessionId.value, e)
|
||||
}
|
||||
@@ -236,7 +281,7 @@ class ServerModule(
|
||||
moduleScope.launch {
|
||||
runCatching {
|
||||
orchestrator.rehydrate(sessionId)
|
||||
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
|
||||
orchestrator.resume(sessionId, graph, orchestrationConfig())
|
||||
}.onFailure { e ->
|
||||
log.error("resumeSession (rehydrate): session={} failed", sessionId.value, e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.correx.apps.server.config
|
||||
|
||||
import com.correx.core.config.ConfigHolder
|
||||
import com.correx.core.config.CorrexConfig
|
||||
import com.correx.core.config.CorrexConfigWriter
|
||||
import com.correx.core.config.EditableConfig
|
||||
import com.correx.core.config.EditableField
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/** Outcome of a config update. [Ok.restartRequired] lists changed keys that need a restart. */
|
||||
sealed interface ConfigUpdateResult {
|
||||
data class Ok(val restartRequired: Set<String>) : ConfigUpdateResult
|
||||
data class Error(val message: String) : ConfigUpdateResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates, persists, and live-applies config edits. The flow on [apply] is: validate the patch
|
||||
* against [EditableConfig] (security-sensitive keys are rejected because they are absent from the
|
||||
* registry), atomically rewrite the TOML file, swap the [holder], then invoke [rebuild] so
|
||||
* config-derived services are reconstructed. Validation/persist failures are reported without
|
||||
* mutating the holder or calling [rebuild].
|
||||
*
|
||||
* Config is not event-sourced — it lives in TOML — so this service deliberately stays outside the
|
||||
* event log.
|
||||
*/
|
||||
class ConfigService(
|
||||
private val holder: ConfigHolder,
|
||||
private val configPath: Path,
|
||||
private val rebuild: (CorrexConfig) -> Unit,
|
||||
) {
|
||||
|
||||
/** Current editable fields paired with their rendered string values, for the protocol snapshot. */
|
||||
fun snapshot(): List<Pair<EditableField, String>> =
|
||||
EditableConfig.fields.map { it to it.getString(holder.get()) }
|
||||
|
||||
fun apply(patch: Map<String, String>): ConfigUpdateResult {
|
||||
val newCfg = EditableConfig.apply(holder.get(), patch)
|
||||
.getOrElse { return ConfigUpdateResult.Error(it.message ?: "invalid config update") }
|
||||
|
||||
return runCatching { persist(newCfg) }.fold(
|
||||
onSuccess = {
|
||||
holder.set(newCfg)
|
||||
rebuild(newCfg)
|
||||
ConfigUpdateResult.Ok(patch.keys intersect EditableConfig.restartRequiredKeys)
|
||||
},
|
||||
onFailure = { ConfigUpdateResult.Error("failed to persist config: ${it.message}") },
|
||||
)
|
||||
}
|
||||
|
||||
/** Writes via a temp file + atomic move so a crash mid-write cannot leave a truncated config. */
|
||||
private fun persist(cfg: CorrexConfig) {
|
||||
val parent = configPath.toAbsolutePath().parent
|
||||
Files.createDirectories(parent)
|
||||
val tmp = Files.createTempFile(parent, "config", ".toml.tmp")
|
||||
Files.writeString(tmp, CorrexConfigWriter.write(cfg))
|
||||
runCatching {
|
||||
Files.move(tmp, configPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
|
||||
}.getOrElse {
|
||||
// Some filesystems reject ATOMIC_MOVE across boundaries; fall back to a plain replace.
|
||||
Files.move(tmp, configPath, StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,18 @@ sealed class ClientMessage {
|
||||
@Serializable
|
||||
data class CancelSession(val sessionId: SessionId) : ClientMessage()
|
||||
|
||||
/** Operator request for the artifacts produced by a session, with their content. */
|
||||
@Serializable
|
||||
data class ListArtifacts(val sessionId: SessionId) : ClientMessage()
|
||||
|
||||
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
|
||||
@Serializable
|
||||
data object GetConfig : ClientMessage()
|
||||
|
||||
/** Operator request to apply a config patch (key → string value) and persist it. */
|
||||
@Serializable
|
||||
data class UpdateConfig(val patch: Map<String, String>) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class ApprovalResponse(
|
||||
val requestId: ApprovalRequestId,
|
||||
|
||||
@@ -86,6 +86,33 @@ data class WorkflowDto(
|
||||
val description: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* One editable config field for the TUI editor. [type] is an [com.correx.core.config.ConfigFieldType]
|
||||
* name; [value] is the current rendered value; [options] is non-empty only for ENUM fields;
|
||||
* [restartRequired] flags fields that persist now but only take effect after a server restart.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConfigFieldDto(
|
||||
val key: String,
|
||||
val type: String,
|
||||
val value: String,
|
||||
val options: List<String> = emptyList(),
|
||||
val restartRequired: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* A single artifact in a session listing. [content] is the UTF-8 decoding of the stored
|
||||
* bytes, or null when no content was stored / the bytes are not valid UTF-8 (binary).
|
||||
*/
|
||||
@Serializable
|
||||
data class ArtifactSummaryDto(
|
||||
val artifactId: String,
|
||||
val stageId: String,
|
||||
val schemaVersion: Int,
|
||||
val phase: String,
|
||||
val content: String?,
|
||||
)
|
||||
|
||||
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
|
||||
level = level.name,
|
||||
factors = signals.map { signal ->
|
||||
|
||||
@@ -360,6 +360,34 @@ sealed interface ServerMessage {
|
||||
override val sessionSequence: Long,
|
||||
) : ServerMessage, SessionMessage
|
||||
|
||||
/**
|
||||
* Full artifact listing for a session, returned in response to a ListArtifacts request.
|
||||
* Not event-derived (it is a snapshot read), so cursors are null.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("artifact.list")
|
||||
data class ArtifactList(
|
||||
val sessionId: SessionId,
|
||||
val artifacts: List<ArtifactSummaryDto>,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
/**
|
||||
* The current editable config (reply to GetConfig / UpdateConfig). [restartRequired] lists the
|
||||
* keys in a just-applied patch that need a server restart to take effect; [error] is set (and
|
||||
* fields hold the unchanged current values) when an update was rejected.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("config.snapshot")
|
||||
data class ConfigSnapshot(
|
||||
val fields: List<ConfigFieldDto>,
|
||||
val restartRequired: List<String> = emptyList(),
|
||||
val error: String? = null,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
// -- Router --
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -65,6 +65,8 @@ private fun ResourceSnapshot.toResourceStatus(): ServerMessage.ResourceStatus =
|
||||
|
||||
class GlobalStreamHandler(private val module: ServerModule) {
|
||||
|
||||
private val queries = StreamQueries(module)
|
||||
|
||||
suspend fun handle(session: DefaultWebSocketServerSession) = coroutineScope {
|
||||
log.info("client connected")
|
||||
|
||||
@@ -191,6 +193,10 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
}
|
||||
}
|
||||
|
||||
// Flat dispatch table: an exhaustive `when` over the sealed ClientMessage. Its cyclomatic
|
||||
// complexity scales with the number of message types, which is the false-positive case the
|
||||
// metric flags — each branch is a one-line delegation, not real branching logic.
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private suspend fun handleClientMessage(
|
||||
session: DefaultWebSocketServerSession,
|
||||
msg: ClientMessage,
|
||||
@@ -219,6 +225,9 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
runCatching { module.orchestrator.cancel(msg.sessionId) }
|
||||
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
||||
}
|
||||
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
||||
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
||||
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
||||
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
||||
@@ -465,7 +474,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
|
||||
// Thread the resolved workspace into the OrchestrationConfig so the live run uses
|
||||
// the bound workspace root for tool path containment and per-session tool registry.
|
||||
val sessionConfig = module.defaultOrchestrationConfig.copy(workspace = resolvedWorkspace)
|
||||
val sessionConfig = module.orchestrationConfig().copy(workspace = resolvedWorkspace)
|
||||
|
||||
// Seed the freeform request (if any) before the run so it lands in the decision
|
||||
// journal and is pinned into every stage's context (the intent input channel).
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.correx.apps.server.ws
|
||||
|
||||
import com.correx.apps.server.ServerModule
|
||||
import com.correx.apps.server.config.ConfigUpdateResult
|
||||
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.config.EditableConfig
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Read-only / snapshot queries served over the global stream: artifact listings and config
|
||||
* get/update. Kept separate from [GlobalStreamHandler] (which owns the connection lifecycle and
|
||||
* session-mutating commands) so each stays focused. Every method here is replay-neutral — it
|
||||
* reads recorded state or edits TOML config, never appends events.
|
||||
*/
|
||||
class StreamQueries(private val module: ServerModule) {
|
||||
|
||||
/**
|
||||
* Reads a session's events to assemble its artifact listing (creation order preserved),
|
||||
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
|
||||
* no events appended, so it is replay-neutral (invariant #1/#8).
|
||||
*/
|
||||
suspend fun listArtifacts(sessionId: SessionId): ServerMessage.ArtifactList {
|
||||
val events = withContext(Dispatchers.IO) { module.eventStore.read(sessionId) }
|
||||
val accs = LinkedHashMap<String, ArtifactAcc>()
|
||||
for (event in events) {
|
||||
when (val p = event.payload) {
|
||||
is ArtifactCreatedEvent -> accs.getOrPut(p.artifactId.value) { ArtifactAcc() }.apply {
|
||||
stageId = p.stageId.value
|
||||
schemaVersion = p.schemaVersion
|
||||
}
|
||||
is ArtifactValidatingEvent -> accs[p.artifactId.value]?.let { it.phase = "VALIDATING" }
|
||||
is ArtifactValidatedEvent -> accs[p.artifactId.value]?.let { it.phase = "VALIDATED" }
|
||||
is ArtifactContentStoredEvent -> accs[p.artifactId.value]?.let { it.contentHash = p.contentHash }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
val artifacts = accs.map { (id, acc) ->
|
||||
val content = acc.contentHash?.let { hash ->
|
||||
withContext(Dispatchers.IO) { module.artifactStore.get(hash) }?.toString(Charsets.UTF_8)
|
||||
}
|
||||
ArtifactSummaryDto(
|
||||
artifactId = id,
|
||||
stageId = acc.stageId,
|
||||
schemaVersion = acc.schemaVersion,
|
||||
phase = acc.phase,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
return ServerMessage.ArtifactList(sessionId = sessionId, artifacts = artifacts)
|
||||
}
|
||||
|
||||
/** Current editable config as a snapshot frame. [error] / [restartRequired] are set by updates. */
|
||||
fun configSnapshot(error: String?, restartRequired: List<String>): ServerMessage.ConfigSnapshot {
|
||||
val fields = module.configService.snapshot().map { (field, value) ->
|
||||
ConfigFieldDto(
|
||||
key = field.key,
|
||||
type = field.type.name,
|
||||
value = value,
|
||||
options = field.options,
|
||||
restartRequired = field.key in EditableConfig.restartRequiredKeys,
|
||||
)
|
||||
}
|
||||
return ServerMessage.ConfigSnapshot(fields = fields, restartRequired = restartRequired, error = error)
|
||||
}
|
||||
|
||||
/** Applies a config patch and returns the resulting snapshot (with an error set on failure). */
|
||||
fun updateConfig(patch: Map<String, String>): ServerMessage.ConfigSnapshot =
|
||||
when (val result = module.configService.apply(patch)) {
|
||||
is ConfigUpdateResult.Ok -> configSnapshot(error = null, restartRequired = result.restartRequired.toList())
|
||||
is ConfigUpdateResult.Error -> configSnapshot(error = result.message, restartRequired = emptyList())
|
||||
}
|
||||
|
||||
/** Mutable accumulator for folding a session's artifact events into one summary per artifact. */
|
||||
private class ArtifactAcc {
|
||||
var stageId: String = ""
|
||||
var schemaVersion: Int = 0
|
||||
var phase: String = "CREATED"
|
||||
var contentHash: ArtifactId? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.correx.apps.server.config
|
||||
|
||||
import com.correx.core.config.ConfigHolder
|
||||
import com.correx.core.config.ConfigLoader
|
||||
import com.correx.core.config.CorrexConfig
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConfigServiceTest {
|
||||
|
||||
@Test
|
||||
fun `apply persists, swaps the holder, and rebuilds`(@TempDir dir: Path) {
|
||||
val path = dir.resolve("config.toml")
|
||||
val holder = ConfigHolder(CorrexConfig())
|
||||
var rebuilt: CorrexConfig? = null
|
||||
val svc = ConfigService(holder, path) { rebuilt = it }
|
||||
|
||||
val res = svc.apply(mapOf("project.enabled" to "true", "server.port" to "9000"))
|
||||
|
||||
assertTrue(res is ConfigUpdateResult.Ok)
|
||||
assertTrue(holder.get().project.enabled)
|
||||
assertEquals(9000, holder.get().server.port)
|
||||
assertEquals(holder.get(), rebuilt)
|
||||
// Persisted and reloadable to the same config.
|
||||
assertEquals(holder.get(), ConfigLoader.loadFrom(path))
|
||||
assertTrue("server.port" in (res as ConfigUpdateResult.Ok).restartRequired)
|
||||
assertFalse("project.enabled" in res.restartRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid patch does not persist or rebuild`(@TempDir dir: Path) {
|
||||
val path = dir.resolve("config.toml")
|
||||
val holder = ConfigHolder(CorrexConfig())
|
||||
var rebuilt = false
|
||||
val svc = ConfigService(holder, path) { rebuilt = true }
|
||||
|
||||
val res = svc.apply(mapOf("tui.session_list_limit" to "abc"))
|
||||
|
||||
assertTrue(res is ConfigUpdateResult.Error)
|
||||
assertFalse(rebuilt)
|
||||
assertFalse(Files.exists(path))
|
||||
assertEquals(5, holder.get().tui.sessionListLimit) // unchanged default
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshot reflects the current held config`(@TempDir dir: Path) {
|
||||
val holder = ConfigHolder(CorrexConfig())
|
||||
val svc = ConfigService(holder, dir.resolve("config.toml")) {}
|
||||
svc.apply(mapOf("tui.session_list_limit" to "11"))
|
||||
val snap = svc.snapshot().associate { (f, v) -> f.key to v }
|
||||
assertEquals("11", snap["tui.session_list_limit"])
|
||||
}
|
||||
}
|
||||
+21
@@ -125,6 +125,27 @@ class ServerMessageSerializationTest {
|
||||
assert(jsonStr.contains("\"totalTokens\":120")) { "expected totalTokens=120" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConfigSnapshot encodes fields, restartRequired, and error`() {
|
||||
val msg = ServerMessage.ConfigSnapshot(
|
||||
fields = listOf(
|
||||
ConfigFieldDto("tui.theme", "ENUM", "dark", options = listOf("dark", "light"), restartRequired = false),
|
||||
ConfigFieldDto("server.port", "INT", "8080", restartRequired = true),
|
||||
),
|
||||
restartRequired = listOf("server.port"),
|
||||
error = null,
|
||||
)
|
||||
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
|
||||
assert(jsonStr.contains("\"type\":\"config.snapshot\"")) { "expected type=config.snapshot" }
|
||||
assert(jsonStr.contains("\"key\":\"tui.theme\"")) { "expected tui.theme field" }
|
||||
assert(jsonStr.contains("\"restartRequired\":[\"server.port\"]")) { "expected restartRequired list" }
|
||||
|
||||
val decoded = json.decodeFromString<ServerMessage.ConfigSnapshot>(jsonStr)
|
||||
assertEquals(2, decoded.fields.size)
|
||||
assertEquals("8080", decoded.fields[1].value)
|
||||
assertEquals(listOf("server.port"), decoded.restartRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionAnnounced round-trips through JSON`() {
|
||||
val original = ServerMessage.SessionAnnounced(
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/correx/tui-go/internal/protocol"
|
||||
)
|
||||
|
||||
// openConfig opens the config editor and requests the current config from the server.
|
||||
func (m *Model) openConfig() {
|
||||
m.overlay = OverlayConfig
|
||||
m.configIndex = 0
|
||||
m.configEditing = false
|
||||
m.configEditBuf = ""
|
||||
m.configError = ""
|
||||
if len(m.configFields) == 0 {
|
||||
m.configLoading = true
|
||||
}
|
||||
m.client.Send(protocol.GetConfig())
|
||||
}
|
||||
|
||||
// handleConfigKey owns every key while the config overlay is open. In edit mode it captures the
|
||||
// value being typed; otherwise it navigates fields, stages edits, and saves.
|
||||
func (m Model) handleConfigKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.configEditing {
|
||||
switch k.Type {
|
||||
case tea.KeyEsc:
|
||||
m.configEditing = false
|
||||
m.configEditBuf = ""
|
||||
case tea.KeyEnter:
|
||||
if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
|
||||
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
|
||||
}
|
||||
m.configEditing = false
|
||||
m.configEditBuf = ""
|
||||
case tea.KeyBackspace:
|
||||
if n := len(m.configEditBuf); n > 0 {
|
||||
m.configEditBuf = m.configEditBuf[:n-1]
|
||||
}
|
||||
case tea.KeyRunes, tea.KeySpace:
|
||||
m.configEditBuf += string(k.Runes)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case k.Type == tea.KeyEsc || runeIs(k, "g"):
|
||||
m.overlay = OverlayNone
|
||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||
if m.configIndex > 0 {
|
||||
m.configIndex--
|
||||
}
|
||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
||||
if m.configIndex < len(m.configFields)-1 {
|
||||
m.configIndex++
|
||||
}
|
||||
case k.Type == tea.KeyEnter || k.Type == tea.KeySpace:
|
||||
m = m.actOnConfigField()
|
||||
case runeIs(k, "s"):
|
||||
if len(m.configStaged) > 0 {
|
||||
m.client.Send(protocol.UpdateConfig(m.configStaged))
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// actOnConfigField applies the type-appropriate action to the selected field: toggle a bool,
|
||||
// cycle an enum, or begin inline editing for numeric/string fields.
|
||||
func (m Model) actOnConfigField() Model {
|
||||
if m.configIndex < 0 || m.configIndex >= len(m.configFields) {
|
||||
return m
|
||||
}
|
||||
f := m.configFields[m.configIndex]
|
||||
switch f.Type {
|
||||
case "BOOL":
|
||||
if m.currentConfigValue(f) == "true" {
|
||||
m.configStaged[f.Key] = "false"
|
||||
} else {
|
||||
m.configStaged[f.Key] = "true"
|
||||
}
|
||||
case "ENUM":
|
||||
m.configStaged[f.Key] = cycleEnum(f.Options, m.currentConfigValue(f))
|
||||
default: // INT, LONG, DOUBLE, STRING
|
||||
m.configEditing = true
|
||||
m.configEditBuf = m.currentConfigValue(f)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// currentConfigValue returns the staged edit for a field if present, else the server value.
|
||||
func (m Model) currentConfigValue(f protocol.ConfigFieldDto) string {
|
||||
if v, ok := m.configStaged[f.Key]; ok {
|
||||
return v
|
||||
}
|
||||
return f.Value
|
||||
}
|
||||
|
||||
// cycleEnum returns the option after cur (wrapping); falls back to the first option.
|
||||
func cycleEnum(options []string, cur string) string {
|
||||
if len(options) == 0 {
|
||||
return cur
|
||||
}
|
||||
for i, o := range options {
|
||||
if o == cur {
|
||||
return options[(i+1)%len(options)]
|
||||
}
|
||||
}
|
||||
return options[0]
|
||||
}
|
||||
|
||||
func (m Model) configModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("config"))
|
||||
if n := len(m.configStaged); n > 0 {
|
||||
b.WriteString(mbg(t, " ("+itoa(n)+" unsaved)", t.P.Warn))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.configLoading && len(m.configFields) == 0 {
|
||||
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
if m.configError != "" {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.configError, w-8)) + "\n\n")
|
||||
}
|
||||
if len(m.configRestart) > 0 {
|
||||
b.WriteString(mbg(t, " saved · restart required: "+strings.Join(m.configRestart, ", "), t.P.Warn) + "\n\n")
|
||||
}
|
||||
|
||||
// Windowed field list, keeping the selected row in view.
|
||||
bodyH := m.height*70/100 - 6
|
||||
if bodyH < 4 {
|
||||
bodyH = 4
|
||||
}
|
||||
off := 0
|
||||
if len(m.configFields) > bodyH {
|
||||
off = m.configIndex - bodyH/2
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
if off > len(m.configFields)-bodyH {
|
||||
off = len(m.configFields) - bodyH
|
||||
}
|
||||
}
|
||||
end := off + bodyH
|
||||
if end > len(m.configFields) {
|
||||
end = len(m.configFields)
|
||||
}
|
||||
for i := off; i < end; i++ {
|
||||
b.WriteString(m.configRow(i) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{
|
||||
{"↑↓", "select"}, {"enter", "edit/toggle"}, {"s", "save"}, {"g/esc", "close"},
|
||||
}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// configRow renders one field line: marker, key, current value (with edit caret / staged mark).
|
||||
func (m Model) configRow(i int) string {
|
||||
t := m.theme
|
||||
f := m.configFields[i]
|
||||
_, staged := m.configStaged[f.Key]
|
||||
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
keyFg := t.P.Fg
|
||||
if i == m.configIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||
keyFg = t.P.FgStrong
|
||||
}
|
||||
|
||||
var valStr string
|
||||
if m.configEditing && i == m.configIndex {
|
||||
caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
|
||||
valStr = lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(m.configEditBuf) + caret
|
||||
} else {
|
||||
valFg := t.P.Accent2
|
||||
if staged {
|
||||
valFg = t.P.Warn
|
||||
}
|
||||
val := m.currentConfigValue(f)
|
||||
if staged {
|
||||
val = "*" + val
|
||||
}
|
||||
valStr = lipgloss.NewStyle().Foreground(valFg).Background(t.P.BgPanel).Render(val)
|
||||
}
|
||||
|
||||
row := marker +
|
||||
lipgloss.NewStyle().Foreground(keyFg).Background(t.P.BgPanel).Render(padRaw(f.Key, 42)) + " " + valStr
|
||||
if f.RestartRequired {
|
||||
row += lipgloss.NewStyle().Foreground(t.P.Faint).Background(t.P.BgPanel).Render(" (restart)")
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/correx/tui-go/internal/protocol"
|
||||
)
|
||||
|
||||
func configModel() Model {
|
||||
m := NewModel(nil)
|
||||
m.width, m.height = 120, 40
|
||||
m.theme = NewTheme(SoftBlue)
|
||||
m.overlay = OverlayConfig
|
||||
m.configFields = []protocol.ConfigFieldDto{
|
||||
{Key: "project.enabled", Type: "BOOL", Value: "false"},
|
||||
{Key: "tui.theme", Type: "ENUM", Value: "dark", Options: []string{"dark", "light", "soft-blue"}},
|
||||
{Key: "router.generation.max_tokens", Type: "INT", Value: "512"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
|
||||
m := configModel()
|
||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = updated.(Model)
|
||||
if m.configStaged["project.enabled"] != "true" {
|
||||
t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"])
|
||||
}
|
||||
out := m.configModal()
|
||||
if !strings.Contains(out, "project.enabled") {
|
||||
t.Fatalf("modal missing field key:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "*true") {
|
||||
t.Fatalf("modal missing staged marker for flipped value:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigEnumCycles(t *testing.T) {
|
||||
m := configModel()
|
||||
m.configIndex = 1 // tui.theme
|
||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = updated.(Model)
|
||||
if m.configStaged["tui.theme"] != "light" {
|
||||
t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIntEditCommitsBuffer(t *testing.T) {
|
||||
m := configModel()
|
||||
m.configIndex = 2 // router.generation.max_tokens
|
||||
// Enter edit mode.
|
||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = updated.(Model)
|
||||
if !m.configEditing {
|
||||
t.Fatalf("expected edit mode after enter on INT field")
|
||||
}
|
||||
// Clear the seeded buffer and type a new value.
|
||||
m.configEditBuf = ""
|
||||
for _, r := range "1024" {
|
||||
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
m = updated.(Model)
|
||||
}
|
||||
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = updated.(Model)
|
||||
if m.configEditing {
|
||||
t.Fatalf("expected edit mode to end after commit")
|
||||
}
|
||||
if m.configStaged["router.generation.max_tokens"] != "1024" {
|
||||
t.Fatalf("want staged max_tokens=1024, got %q", m.configStaged["router.generation.max_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSnapshotClearsStagedOnCleanReply(t *testing.T) {
|
||||
m := configModel()
|
||||
m.configStaged["project.enabled"] = "true"
|
||||
m.applyServer(protocol.ServerMessage{
|
||||
Type: protocol.TypeConfigSnapshot,
|
||||
ConfigFields: []protocol.ConfigFieldDto{{Key: "project.enabled", Type: "BOOL", Value: "true"}},
|
||||
})
|
||||
if len(m.configStaged) != 0 {
|
||||
t.Fatalf("expected staged edits cleared on clean snapshot, got %v", m.configStaged)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ const (
|
||||
OverlayDiff
|
||||
OverlayToolPalette
|
||||
OverlayModels
|
||||
OverlayArtifacts
|
||||
OverlayConfig
|
||||
)
|
||||
|
||||
// RouterEntry is one line in a session's conversation transcript.
|
||||
@@ -188,6 +190,23 @@ type Model struct {
|
||||
diffScrollOffset int
|
||||
eventStripShown bool
|
||||
|
||||
// artifact viewer (OverlayArtifacts) — populated by the artifact.list response
|
||||
artifacts []protocol.ArtifactDto
|
||||
artifactsFor string // sessionId the current listing belongs to
|
||||
artifactsIndex int
|
||||
artifactScroll int
|
||||
artifactsLoading bool
|
||||
|
||||
// config editor (OverlayConfig) — populated by the config.snapshot response
|
||||
configFields []protocol.ConfigFieldDto
|
||||
configIndex int
|
||||
configStaged map[string]string // key -> edited value, pending save
|
||||
configEditing bool // true while typing a value into configEditBuf
|
||||
configEditBuf string
|
||||
configError string
|
||||
configRestart []string // keys from the last save that need a restart
|
||||
configLoading bool
|
||||
|
||||
// command palette
|
||||
paletteFilter string
|
||||
paletteIndex int
|
||||
@@ -214,6 +233,7 @@ func NewModel(client *ws.Client) Model {
|
||||
history: map[string][]string{},
|
||||
historyIndex: -1,
|
||||
routerMessages: map[string][]RouterEntry{},
|
||||
configStaged: map[string]string{},
|
||||
chatMode: ChatModeChat,
|
||||
providerType: "LOCAL",
|
||||
snapshotPhase: true,
|
||||
|
||||
@@ -39,6 +39,10 @@ func (m Model) renderOverlay(base string) string {
|
||||
return m.center(m.toolPaletteModal())
|
||||
case OverlayModels:
|
||||
return m.center(m.modelsModal())
|
||||
case OverlayArtifacts:
|
||||
return m.center(m.artifactsModal())
|
||||
case OverlayConfig:
|
||||
return m.center(m.configModal())
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -414,6 +418,137 @@ func (m Model) modelsModal() string {
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
// artifactListRows is the number of artifact entries shown in the list portion of
|
||||
// the viewer (the rest of the modal height goes to the selected artifact's content).
|
||||
func (m Model) artifactListRows() int {
|
||||
n := len(m.artifacts)
|
||||
if n > 6 {
|
||||
n = 6
|
||||
}
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// artifactContentBodyH is the number of content lines visible for the selected artifact.
|
||||
func (m Model) artifactContentBodyH() int {
|
||||
h := m.height*70/100 - m.artifactListRows() - 7 // title, blanks, content header, hints
|
||||
if h < 3 {
|
||||
h = 3
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// artifactContentLines splits the selected artifact's content into display lines.
|
||||
func (m Model) artifactContentLines() []string {
|
||||
if m.artifactsIndex < 0 || m.artifactsIndex >= len(m.artifacts) {
|
||||
return nil
|
||||
}
|
||||
c := m.artifacts[m.artifactsIndex].Content
|
||||
if c == nil {
|
||||
return []string{"(no content stored)"}
|
||||
}
|
||||
return strings.Split(strings.ReplaceAll(*c, "\r\n", "\n"), "\n")
|
||||
}
|
||||
|
||||
// artifactContentMaxScroll keeps the last page of content anchored to the body bottom.
|
||||
func (m Model) artifactContentMaxScroll() int {
|
||||
max := len(m.artifactContentLines()) - m.artifactContentBodyH()
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func (m Model) artifactsModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("artifacts"))
|
||||
if len(m.artifacts) > 0 {
|
||||
b.WriteString(mbg(t, " ("+itoa(m.artifactsIndex+1)+"/"+itoa(len(m.artifacts))+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.artifactsLoading {
|
||||
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
if len(m.artifacts) == 0 {
|
||||
b.WriteString(mbg(t, " no artifacts for this session", t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// Windowed artifact list, keeping the selected row in view.
|
||||
rows := m.artifactListRows()
|
||||
off := 0
|
||||
if len(m.artifacts) > rows {
|
||||
off = m.artifactsIndex - rows/2
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
if off > len(m.artifacts)-rows {
|
||||
off = len(m.artifacts) - rows
|
||||
}
|
||||
}
|
||||
end := off + rows
|
||||
if end > len(m.artifacts) {
|
||||
end = len(m.artifacts)
|
||||
}
|
||||
for i := off; i < end; i++ {
|
||||
a := m.artifacts[i]
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
idFg := t.P.Fg
|
||||
if i == m.artifactsIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||
idFg = t.P.FgStrong
|
||||
}
|
||||
row := marker +
|
||||
lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortID(a.ArtifactID), 14)) + " " +
|
||||
mbg(t, padRaw(a.StageID, 18), t.P.Accent2) + " " +
|
||||
mbg(t, a.Phase, t.P.Faint)
|
||||
b.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
// Selected artifact content, scrollable with PgUp/PgDn.
|
||||
lines := m.artifactContentLines()
|
||||
bodyH := m.artifactContentBodyH()
|
||||
coff := m.artifactScroll
|
||||
if coff > m.artifactContentMaxScroll() {
|
||||
coff = m.artifactContentMaxScroll()
|
||||
}
|
||||
if coff < 0 {
|
||||
coff = 0
|
||||
}
|
||||
b.WriteString(mbg(t, " ── content", t.P.Faint))
|
||||
if len(lines) > bodyH {
|
||||
b.WriteString(mbg(t, " ("+itoa(coff+1)+"-"+itoa(min(coff+bodyH, len(lines)))+"/"+itoa(len(lines))+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
cend := coff + bodyH
|
||||
if cend > len(lines) {
|
||||
cend = len(lines)
|
||||
}
|
||||
for i := coff; i < cend; i++ {
|
||||
b.WriteString(mbg(t, " "+truncate(lines[i], w-6), t.P.Fg) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// shortID trims a long artifact id for the list column while staying identifiable.
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 14 {
|
||||
return id
|
||||
}
|
||||
return id[:13] + "…"
|
||||
}
|
||||
|
||||
func tierColor(t Theme, tier int) lipgloss.Color {
|
||||
switch {
|
||||
case tier >= 3:
|
||||
|
||||
@@ -137,9 +137,6 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Active = false
|
||||
}
|
||||
if msg.SessionID == m.selectedID {
|
||||
m.selectedID = ""
|
||||
}
|
||||
case protocol.TypeStageStarted:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.CurrentStage = msg.StageID
|
||||
@@ -277,6 +274,28 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
m.gpuTotalMB = msg.GpuMemoryTotalMb
|
||||
m.gpuUtil = msg.GpuUtilizationPct
|
||||
m.ramMB = msg.ProcessRssMb
|
||||
case protocol.TypeArtifactList:
|
||||
m.artifacts = msg.Artifacts
|
||||
m.artifactsFor = msg.SessionID
|
||||
m.artifactsLoading = false
|
||||
m.artifactScroll = 0
|
||||
if m.artifactsIndex >= len(m.artifacts) {
|
||||
m.artifactsIndex = 0
|
||||
}
|
||||
case protocol.TypeConfigSnapshot:
|
||||
m.configFields = msg.ConfigFields
|
||||
m.configRestart = msg.ConfigRestartRequired
|
||||
m.configLoading = false
|
||||
if msg.ConfigError != nil {
|
||||
m.configError = *msg.ConfigError
|
||||
} else {
|
||||
// A clean snapshot means the staged edits were accepted (or this is a fresh fetch).
|
||||
m.configError = ""
|
||||
m.configStaged = map[string]string{}
|
||||
}
|
||||
if m.configIndex >= len(m.configFields) {
|
||||
m.configIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Background-update badge for non-selected sessions.
|
||||
@@ -290,7 +309,8 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
||||
case protocol.TypeStageManifest, protocol.TypeSnapshotComplete,
|
||||
protocol.TypeProtocolError, protocol.TypeProviderStatus,
|
||||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus:
|
||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot:
|
||||
return ""
|
||||
default:
|
||||
return msg.SessionID
|
||||
|
||||
@@ -167,6 +167,10 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.overlayEventIdx = 0
|
||||
case "t":
|
||||
m.overlay = OverlayToolPalette
|
||||
case "v":
|
||||
m.openArtifacts()
|
||||
case "g":
|
||||
m.openConfig()
|
||||
case "m":
|
||||
m.openModelsOverlay()
|
||||
case "w":
|
||||
@@ -340,6 +344,10 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Config overlay owns all its keys (esc cancels an in-progress edit rather than closing).
|
||||
if m.overlay == OverlayConfig {
|
||||
return m.handleConfigKey(k)
|
||||
}
|
||||
if k.Type == tea.KeyEsc {
|
||||
m.overlay = OverlayNone
|
||||
return m, nil
|
||||
@@ -376,6 +384,29 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if runeIs(k, "t") {
|
||||
m.overlay = OverlayNone
|
||||
}
|
||||
case OverlayArtifacts:
|
||||
switch {
|
||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||
if m.artifactsIndex > 0 {
|
||||
m.artifactsIndex--
|
||||
m.artifactScroll = 0
|
||||
}
|
||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
||||
if m.artifactsIndex < len(m.artifacts)-1 {
|
||||
m.artifactsIndex++
|
||||
m.artifactScroll = 0
|
||||
}
|
||||
case k.Type == tea.KeyPgUp:
|
||||
if m.artifactScroll > 0 {
|
||||
m.artifactScroll--
|
||||
}
|
||||
case k.Type == tea.KeyPgDown:
|
||||
if m.artifactScroll < m.artifactContentMaxScroll() {
|
||||
m.artifactScroll++
|
||||
}
|
||||
case runeIs(k, "v"):
|
||||
m.overlay = OverlayNone
|
||||
}
|
||||
case OverlayModels:
|
||||
switch {
|
||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||
@@ -401,6 +432,23 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// openArtifacts opens the artifact viewer for the selected session and requests its
|
||||
// artifacts from the server. No-op when no session is selected.
|
||||
func (m *Model) openArtifacts() {
|
||||
if m.selectedID == "" {
|
||||
return
|
||||
}
|
||||
m.overlay = OverlayArtifacts
|
||||
m.artifactsIndex = 0
|
||||
m.artifactScroll = 0
|
||||
// Reuse a cached listing only if it's for this session; otherwise show a loading state.
|
||||
if m.artifactsFor != m.selectedID {
|
||||
m.artifacts = nil
|
||||
m.artifactsLoading = true
|
||||
}
|
||||
m.client.Send(protocol.ListArtifacts(m.selectedID))
|
||||
}
|
||||
|
||||
// openModelsOverlay opens the model picker, pre-selecting the resident model.
|
||||
func (m *Model) openModelsOverlay() {
|
||||
m.overlay = OverlayModels
|
||||
@@ -452,6 +500,8 @@ func paletteCommands() []paletteCmd {
|
||||
{"tools", "tool palette", "tools for the current stage"},
|
||||
{"models", "swap model", "pick / pin the local model"},
|
||||
{"events", "event inspector", "browse the event stream"},
|
||||
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
||||
{"config", "edit config", "view / change correx settings"},
|
||||
{"mode", "toggle mode", "switch chat / steering"},
|
||||
{"cancel", "cancel session", "stop the selected session"},
|
||||
{"back", "back to list", "leave the current session"},
|
||||
@@ -486,6 +536,10 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
||||
case "events":
|
||||
m.overlay = OverlayEventInspector
|
||||
m.overlayEventIdx = 0
|
||||
case "artifacts":
|
||||
m.openArtifacts()
|
||||
case "config":
|
||||
m.openConfig()
|
||||
case "mode":
|
||||
m.cycleChatMode()
|
||||
case "cancel":
|
||||
@@ -683,6 +737,13 @@ func (m *Model) listNav(dir int) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// No current selection (e.g. nothing entered yet): start at the first
|
||||
// session rather than wrapping off the end into an arbitrary entry.
|
||||
if idx < 0 {
|
||||
m.selectedID = list[0].ID
|
||||
m.wfIndex = -1
|
||||
return
|
||||
}
|
||||
n := idx + dir
|
||||
if n < 0 {
|
||||
n = len(list) - 1
|
||||
|
||||
@@ -45,6 +45,8 @@ const (
|
||||
TypeModelList = "model.list"
|
||||
TypeResourceStatus = "resource.status"
|
||||
TypeRouterNarration = "router.narration"
|
||||
TypeArtifactList = "artifact.list"
|
||||
TypeConfigSnapshot = "config.snapshot"
|
||||
)
|
||||
|
||||
// ServerMessage is a flat decode of every server->client variant. Field names
|
||||
@@ -107,6 +109,28 @@ type ServerMessage struct {
|
||||
Stages []StageToolDecl `json:"stages"`
|
||||
Workflows []WorkflowDto `json:"workflows"`
|
||||
AssessedIssues []AssessedIssueDto `json:"issues"`
|
||||
Artifacts []ArtifactDto `json:"artifacts"`
|
||||
|
||||
// config.snapshot
|
||||
ConfigFields []ConfigFieldDto `json:"fields"`
|
||||
ConfigRestartRequired []string `json:"restartRequired"`
|
||||
ConfigError *string `json:"error"`
|
||||
}
|
||||
|
||||
type ConfigFieldDto struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Options []string `json:"options"`
|
||||
RestartRequired bool `json:"restartRequired"`
|
||||
}
|
||||
|
||||
type ArtifactDto struct {
|
||||
ArtifactID string `json:"artifactId"`
|
||||
StageID string `json:"stageId"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Phase string `json:"phase"`
|
||||
Content *string `json:"content"`
|
||||
}
|
||||
|
||||
type SessionStateDto struct {
|
||||
@@ -228,6 +252,21 @@ func CancelSession(sessionID string) []byte {
|
||||
return encode("CancelSession", map[string]any{"sessionId": sessionID})
|
||||
}
|
||||
|
||||
// ListArtifacts asks the server for the artifacts produced by a session, with content.
|
||||
func ListArtifacts(sessionID string) []byte {
|
||||
return encode("ListArtifacts", map[string]any{"sessionId": sessionID})
|
||||
}
|
||||
|
||||
// GetConfig requests the current editable config (replied to with a config.snapshot).
|
||||
func GetConfig() []byte {
|
||||
return encode("GetConfig", map[string]any{})
|
||||
}
|
||||
|
||||
// UpdateConfig asks the server to apply and persist a config patch (key -> string value).
|
||||
func UpdateConfig(patch map[string]string) []byte {
|
||||
return encode("UpdateConfig", map[string]any{"patch": patch})
|
||||
}
|
||||
|
||||
// ApprovalResponse answers an approval gate. decision is "APPROVE" or "REJECT".
|
||||
func ApprovalResponse(requestID, decision string, steeringNote *string) []byte {
|
||||
return encode("ApprovalResponse", map[string]any{
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -10,15 +10,21 @@ import com.correx.core.journal.model.Salience
|
||||
class JournalCompactionService(
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val summarize: suspend (String) -> String,
|
||||
val tokenThreshold: Int = 2000,
|
||||
// Supplier (not a constant) so the threshold can track live config edits without rebuilding
|
||||
// the orchestrator: it is read fresh on each compaction check.
|
||||
private val tokenThreshold: () -> Int = { DEFAULT_TOKEN_THRESHOLD },
|
||||
) {
|
||||
companion object {
|
||||
private const val DEFAULT_TOKEN_THRESHOLD = 2000
|
||||
}
|
||||
|
||||
suspend fun compactIfNeeded(
|
||||
sessionId: SessionId,
|
||||
state: DecisionJournalState,
|
||||
renderedTokenEstimate: Int,
|
||||
emit: suspend (EventPayload) -> Unit,
|
||||
): Boolean {
|
||||
if (renderedTokenEstimate < tokenThreshold || state.records.isEmpty()) return false
|
||||
if (renderedTokenEstimate < tokenThreshold() || state.records.isEmpty()) return false
|
||||
|
||||
val throughSequence = state.records.maxOf { it.sequence }
|
||||
val highRecords = state.records.filter { it.kind.salience() == Salience.HIGH }
|
||||
|
||||
+4
-4
@@ -32,7 +32,7 @@ class JournalCompactionServiceTest {
|
||||
|
||||
@Test
|
||||
fun `returns false when token estimate is below threshold`(): Unit = runBlocking {
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = 2000)
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = { 2000 })
|
||||
val state = stateWithRecords(makeRecord(1, DecisionKind.INTENT))
|
||||
val emitted = mutableListOf<EventPayload>()
|
||||
val result = svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 500) { emitted += it }
|
||||
@@ -42,7 +42,7 @@ class JournalCompactionServiceTest {
|
||||
|
||||
@Test
|
||||
fun `returns false when records is empty even if estimate is high`(): Unit = runBlocking {
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = 2000)
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { it }, tokenThreshold = { 2000 })
|
||||
val state = DecisionJournalState(records = emptyList())
|
||||
val emitted = mutableListOf<EventPayload>()
|
||||
val result = svc.compactIfNeeded(SessionId("s1"), state, renderedTokenEstimate = 9999) { emitted += it }
|
||||
@@ -52,7 +52,7 @@ class JournalCompactionServiceTest {
|
||||
|
||||
@Test
|
||||
fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`(): Unit = runBlocking {
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = 100)
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = { 100 })
|
||||
val state = stateWithRecords(
|
||||
makeRecord(1, DecisionKind.INTENT), // HIGH
|
||||
makeRecord(2, DecisionKind.TRANSITION), // LOW
|
||||
@@ -75,7 +75,7 @@ class JournalCompactionServiceTest {
|
||||
val svc = JournalCompactionService(
|
||||
artifactStore = fakeArtifactStore(),
|
||||
summarize = { prompt -> capturedPrompts += prompt; "ok" },
|
||||
tokenThreshold = 100,
|
||||
tokenThreshold = { 100 },
|
||||
)
|
||||
val state = stateWithRecords(
|
||||
makeRecord(1, DecisionKind.INTENT), // HIGH
|
||||
|
||||
Reference in New Issue
Block a user