diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 3b4707c4..ce5c8d11 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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,37 +275,42 @@ 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. - val domainRouterConfig = RouterConfig( - conversationKeepLast = routerConfig.conversationKeepLast, - retrievalK = routerConfig.retrievalK, - tokenBudget = TokenBudget(limit = routerConfig.tokenBudget), - generationConfig = GenerationConfig( - temperature = routerConfig.generation.temperature, - topP = routerConfig.generation.topP, - maxTokens = routerConfig.generation.maxTokens, - ), - narrationGenerationConfig = GenerationConfig( - temperature = routerConfig.narration.temperature, - topP = routerConfig.narration.topP, - maxTokens = routerConfig.narration.maxTokens, - ), - ) - val routerFacade = InfrastructureModule.createRouterFacade( - eventStore = eventStore, - inferenceRouter = inferenceRouter, - config = domainRouterConfig, - tokenizer = firstProvider.tokenizer, - embedder = embedder, - l3MemoryStore = l3MemoryStore, - ) + // 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 = rc.conversationKeepLast, + retrievalK = rc.retrievalK, + tokenBudget = TokenBudget(limit = rc.tokenBudget), + generationConfig = GenerationConfig( + temperature = rc.generation.temperature, + topP = rc.generation.topP, + maxTokens = rc.generation.maxTokens, + ), + narrationGenerationConfig = GenerationConfig( + temperature = rc.narration.temperature, + topP = rc.narration.topP, + maxTokens = rc.narration.maxTokens, + ), + ) + return InfrastructureModule.createRouterFacade( + eventStore = eventStore, + inferenceRouter = inferenceRouter, + config = domainRouterConfig, + tokenizer = firstProvider.tokenizer, + 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) { - com.correx.apps.server.memory.ProjectMemoryService( - config = correxConfig.project, - embedder = embedder, - l3MemoryStore = l3MemoryStore, - journalRepository = decisionJournalRepository, - eventStore = eventStore, - indexer = com.correx.apps.server.memory.RepoMapIndexer( - maxDepth = correxConfig.project.maxDepth, - ignoreGlobs = correxConfig.project.ignoreGlobs, - ), - ) - } else { - null - } - val profileAdaptationService: ProfileAdaptationService? = - if (correxConfig.personalization.enabled && correxConfig.personalization.learn) { + // 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 = cfg.project, + embedder = embedder, + l3MemoryStore = l3MemoryStore, + journalRepository = decisionJournalRepository, + eventStore = eventStore, + indexer = com.correx.apps.server.memory.RepoMapIndexer( + maxDepth = cfg.project.maxDepth, + ignoreGlobs = cfg.project.ignoreGlobs, + ), + ) + } else { + null + } + 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("==============================") diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 53298bd4..f4197f4b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -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() @@ -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) } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/config/ConfigService.kt b/apps/server/src/main/kotlin/com/correx/apps/server/config/ConfigService.kt new file mode 100644 index 00000000..c968e5e8 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/config/ConfigService.kt @@ -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) : 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> = + EditableConfig.fields.map { it to it.getString(holder.get()) } + + fun apply(patch: Map): 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) + } + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index 02f89727..b6270c81 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -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) : ClientMessage() + @Serializable data class ApprovalResponse( val requestId: ApprovalRequestId, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt index 1a6810f6..1811a83e 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt @@ -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 = 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 -> diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index d72241a7..dc481f35 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -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, + 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, + val restartRequired: List = emptyList(), + val error: String? = null, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + // -- Router -- @Serializable diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index e82ccf24..ae0ce09f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -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). diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt new file mode 100644 index 00000000..33064fb6 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt @@ -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() + 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): 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): 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 + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/config/ConfigServiceTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/config/ConfigServiceTest.kt new file mode 100644 index 00000000..592f103b --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/config/ConfigServiceTest.kt @@ -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"]) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index b1b66c26..07def03f 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -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(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( diff --git a/apps/tui-go/internal/app/config_overlay.go b/apps/tui-go/internal/app/config_overlay.go new file mode 100644 index 00000000..a33326e8 --- /dev/null +++ b/apps/tui-go/internal/app/config_overlay.go @@ -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 +} diff --git a/apps/tui-go/internal/app/config_overlay_test.go b/apps/tui-go/internal/app/config_overlay_test.go new file mode 100644 index 00000000..1984dcb9 --- /dev/null +++ b/apps/tui-go/internal/app/config_overlay_test.go @@ -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) + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 763e3d8f..8eff9aaa 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -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, diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index d482a028..1ca71185 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -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: diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 540f602a..0bb982ba 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -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 diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index f83dadc1..2ac91e89 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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 diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index ed73c62f..8db59bed 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -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{ diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigHolder.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigHolder.kt new file mode 100644 index 00000000..bc55712c --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigHolder.kt @@ -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) + } +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index 480986c9..4ac60003 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -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 diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index 5c2bdbc4..03a3279f 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -15,6 +15,19 @@ data class CorrexConfig( val artifacts: List = 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, ) diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt new file mode 100644 index 00000000..6f36b59a --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt @@ -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 = "[" + v.joinToString(", ") { str(it) } + "]" + + private fun caps(m: Map): String = + "{ " + m.entries.joinToString(", ") { "${it.key} = ${it.value}" } + " }" + + private fun strMap(m: Map): String = + "{ " + m.entries.joinToString(", ") { "${it.key} = ${str(it.value)}" } + " }" +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/EditableConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/EditableConfig.kt new file mode 100644 index 00000000..be59ff47 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/EditableConfig.kt @@ -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 = 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 = 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 = setOf("server.host", "server.port") + + private val byKey: Map = 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): Result = 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 = + if (v in options) v else throw ConfigEditError("$key must be one of ${options.joinToString("|")}, got '$v'") diff --git a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt index 4684c780..b1e840bd 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt @@ -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) + } } diff --git a/core/config/src/test/kotlin/com/correx/core/config/CorrexConfigWriterTest.kt b/core/config/src/test/kotlin/com/correx/core/config/CorrexConfigWriterTest.kt new file mode 100644 index 00000000..5bd23570 --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/CorrexConfigWriterTest.kt @@ -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))) + } +} diff --git a/core/config/src/test/kotlin/com/correx/core/config/EditableConfigTest.kt b/core/config/src/test/kotlin/com/correx/core/config/EditableConfigTest.kt new file mode 100644 index 00000000..11e6f1e8 --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/EditableConfigTest.kt @@ -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}") + } + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/JournalCompactionService.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/JournalCompactionService.kt index a05b1f51..d2d54179 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/JournalCompactionService.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/JournalCompactionService.kt @@ -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 } diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt index 624d25af..9f27e28d 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt @@ -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() 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() 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