feature(event-sourcing): baseline for the architecture review fixes.

- fixed the tool overlay in tui.
- fixed tool calling - added schemas.
- getting all sessionIds via event store.
- instead of sending all events on the replay - there's a new way for replaying.
- session snapshot is now a thing getting sent for previously created sessions.
- added logging to important parts.
- revert workflowId from WorkflowCompletedEvent.
This commit is contained in:
2026-05-24 18:50:00 +04:00
parent f827685ed0
commit fc7b879891
43 changed files with 525 additions and 211 deletions
@@ -63,6 +63,8 @@ class InMemoryEventStore : EventStore {
.flatMap { stream -> synchronized(stream) { stream.toList() } }
.asSequence()
override fun allSessionIds(): Set<SessionId> = sequences.keys
private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent {
val seq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) }
.incrementAndGet()
@@ -42,7 +42,7 @@ class SqliteEventStore(
correlation_id TEXT,
payload TEXT NOT NULL
);
""".trimIndent()
""".trimIndent(),
)
}
}
@@ -60,7 +60,7 @@ class SqliteEventStore(
val s = StoredEvent(
metadata = event.metadata,
sequence = seq,
payload = event.payload
payload = event.payload,
)
insert(s)
@@ -91,7 +91,7 @@ class SqliteEventStore(
val s = StoredEvent(
metadata = event.metadata,
sequence = seq,
payload = event.payload
payload = event.payload,
)
insert(s)
@@ -111,7 +111,7 @@ class SqliteEventStore(
SELECT * FROM events
WHERE session_id = ?
ORDER BY sequence ASC
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, sessionId.value)
@@ -131,7 +131,7 @@ class SqliteEventStore(
WHERE session_id = ?
AND sequence > ?
ORDER BY sequence ASC
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, sessionId.value)
ps.setLong(2, fromSequence)
@@ -151,7 +151,7 @@ class SqliteEventStore(
SELECT MAX(sequence)
FROM events
WHERE session_id = ?
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, sessionId.value)
@@ -169,7 +169,7 @@ class SqliteEventStore(
"""
SELECT * FROM events
ORDER BY session_id ASC, sequence ASC
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.executeQuery().use { rs ->
buildList {
@@ -180,6 +180,15 @@ class SqliteEventStore(
}
}.asSequence()
override fun allSessionIds(): Set<SessionId> =
mutableSetOf<SessionId>().apply {
connection.prepareStatement("SELECT DISTINCT session_id FROM events").use { ps ->
ps.executeQuery().use { rs ->
while (rs.next()) add(SessionId(rs.getString("session_id")))
}
}
}
// ---------- helpers ----------
private fun nextSequence(sessionId: SessionId): Long =
@@ -188,7 +197,7 @@ class SqliteEventStore(
SELECT COALESCE(MAX(sequence), 0)
FROM events
WHERE session_id = ?
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, sessionId.value)
ps.executeQuery().use { rs ->
@@ -211,7 +220,7 @@ class SqliteEventStore(
correlation_id,
payload
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, event.metadata.eventId.value)
ps.setString(2, event.metadata.sessionId.value)
@@ -231,7 +240,7 @@ class SqliteEventStore(
"""
SELECT * FROM events
WHERE event_id = ?
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, eventId.value)
@@ -250,8 +259,8 @@ private fun ResultSet.toStoredEvent(jsonSerializer: JsonEventSerializer): Stored
timestamp = Instant.parse(getString("timestamp")),
schemaVersion = getInt("schema_version"),
causationId = getString("causation_id")?.let { CausationId(it) },
correlationId = getString("correlation_id")?.let { CorrelationId(it) }
correlationId = getString("correlation_id")?.let { CorrelationId(it) },
),
sequence = getLong("sequence"),
payload = jsonSerializer.deserialize(getString("payload"))
payload = jsonSerializer.deserialize(getString("payload")),
)
@@ -1,15 +1,15 @@
package com.correx.infrastructure
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.artifacts.repository.ArtifactRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ProviderRegistry
import com.correx.core.inference.RoutingStrategy
import com.correx.core.router.DefaultRouterContextBuilder
import com.correx.core.router.DefaultRouterFacade
import com.correx.core.router.DefaultRouterReducer
@@ -24,11 +24,8 @@ import com.correx.infrastructure.artifactscas.CasArtifactStore
import com.correx.infrastructure.artifactscas.config.CasConfig
import com.correx.infrastructure.artifactscas.index.SqliteArtifactIndex
import com.correx.infrastructure.inference.DefaultProviderRegistry
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
import com.correx.infrastructure.inference.commons.ModelDescriptor
import com.correx.infrastructure.inference.commons.ModelManager
import com.correx.infrastructure.inference.commons.ResidencyMode
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import com.correx.infrastructure.persistence.SqliteEventStore
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
@@ -41,7 +38,6 @@ import com.correx.infrastructure.workflow.FileSystemPromptLoader
import com.correx.infrastructure.workflow.PromptLoader
import com.correx.infrastructure.workflow.TomlWorkflowLoader
import com.correx.infrastructure.workflow.WorkflowLoader
import io.ktor.client.HttpClient
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
@@ -71,25 +67,9 @@ object InfrastructureModule {
return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) }
}
fun createModelManager(
eventStore: EventStore,
httpClient: HttpClient,
eventDispatcher: EventDispatcher,
): ModelManager = DefaultModelManager(
eventStore = eventStore,
httpClient = httpClient,
eventDispatcher = eventDispatcher,
)
fun createToolRegistry(config: ToolConfig): ToolRegistry =
DefaultToolRegistry.build(config.buildTools())
fun createInferenceRouter(
providers: List<InferenceProvider> = emptyList(),
registry: ProviderRegistry = DefaultProviderRegistry(providers),
strategy: RoutingStrategy = FirstAvailableRoutingStrategy(),
): InferenceRouter = DefaultInferenceRouter(registry, strategy)
fun createLlamaCppProvider(
modelId: String,
modelPath: String,
@@ -111,6 +91,16 @@ object InfrastructureModule {
fun createArtifactRepository(eventStore: EventStore): ArtifactRepository =
LiveArtifactRepository(eventStore, DefaultArtifactReducer())
fun createApprovalRepository(eventStore: EventStore): DefaultApprovalRepository =
DefaultApprovalRepository(
DefaultEventReplayer(
eventStore,
ApprovalProjector(
DefaultApprovalReducer(),
),
),
)
fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader()
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
@@ -13,7 +13,11 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
@@ -39,7 +43,35 @@ class FileEditTool(
override val name: String = "file_edit"
override val description: String = "Edit content in a file at the specified path"
override val parametersSchema: JsonObject = buildJsonObject {}
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("path") {
put("type", "string")
put("description", "Relative path to the file to edit")
}
putJsonObject("operation") {
put("type", "string")
put("description", "Either 'append', 'replace', or 'patch'")
}
putJsonObject("content") {
put("type", "string")
put("description", "Content to write. Used for 'append' and 'replace'.")
}
putJsonObject("old_str") {
put("type", "string")
put("description", "Exact string to find. Required for 'patch'.")
}
putJsonObject("new_str") {
put("type", "string")
put("description", "Replacement string. Required for 'patch'.")
}
}
put("required", buildJsonArray {
add(JsonPrimitive("path"))
add(JsonPrimitive("operation"))
})
}
override val tier: Tier = Tier.T3
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
@@ -7,11 +7,14 @@ import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
@@ -24,76 +27,92 @@ class FileReadTool(
override val name: String = "file_read"
override val description: String = "Read content from a file at the specified path"
override val parametersSchema: JsonObject = buildJsonObject {}
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("path") {
put("type", "string")
put("description", "Relative path to the file to read")
}
putJsonObject("start_line") {
put("type", "integer")
put("description", "First line to read, 1-indexed inclusive. Omit to read from beginning.")
}
putJsonObject("end_line") {
put("type", "integer")
put("description", "Last line to read, 1-indexed inclusive. Omit to read to end of file.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("path")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override fun validateRequest(request: ToolRequest): ValidationResult =
(request.parameters["path"] as? String)?.let { pathString ->
runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
if (allowedPaths.none { path.startsWith(it.normalize().toAbsolutePath()) }) {
return@runCatching ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
}
override fun validateRequest(request: ToolRequest): ValidationResult {
val pathString = request.parameters["path"] as? String
?: return ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
if (startLine != null && startLine < 1)
return ValidationResult.Invalid("'start_line' must be >= 1.")
if (endLine != null && endLine < 1)
return ValidationResult.Invalid("'end_line' must be >= 1.")
if (startLine != null && endLine != null && endLine < startLine)
return ValidationResult.Invalid("'end_line' must be >= 'start_line'.")
return runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
if (allowedPaths.isNotEmpty() && allowedPaths.none { path.startsWith(it.normalize().toAbsolutePath()) })
ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
else
ValidationResult.Valid
}.getOrElse {
when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
else -> ValidationResult.Invalid(it.message ?: "Unknown error occured")
}
}.getOrElse {
when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
else -> ValidationResult.Invalid(it.message ?: "Unknown error occurred")
}
} ?: ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
}
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run {
takeIf { it is ValidationResult.Valid }?.let {
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
if (Files.exists(path)) {
readStringCatching(request, path, pathString)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
)
}
} ?: ToolResult.Failure(
val validation = validateRequest(request)
if (validation is ValidationResult.Invalid)
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
reason = validation.reason,
recoverable = false,
)
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
if (!Files.exists(path))
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
)
runCatching {
val lines = Files.readAllLines(path)
val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
val end = (endLine ?: lines.size).coerceAtMost(lines.size)
val content = lines.subList(start, end).joinToString("\n")
ToolResult.Success(
invocationId = request.invocationId,
output = content,
)
}.getOrElse {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Failed to read file: ${it.message}",
recoverable = false,
)
}
}
private fun readStringCatching(request: ToolRequest, path: Path, pathString: String): ToolResult =
runCatching {
ToolResult.Success(
invocationId = request.invocationId,
output = Files.readString(path),
)
}.getOrElse {
when (it) {
is CancellationException -> throw it
is SecurityException -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Access denied: $pathString, ${it.message}",
recoverable = false,
)
is IOException -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "IO error: ${it.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred while reading file",
recoverable = false,
)
}
}
}
@@ -12,8 +12,13 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.nio.file.Path
class ShellTool(
@@ -23,18 +28,36 @@ class ShellTool(
) : Tool, ToolExecutor {
override val name: String = "shell"
override val description: String = "Execute a shell command"
override val parametersSchema: JsonObject = buildJsonObject {}
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("argv") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Command and arguments as a list. Example: [\"bash\", \"script.sh\"]")
}
}
put("required", buildJsonArray { add(JsonPrimitive("argv")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> =
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
override fun validateRequest(request: ToolRequest): ValidationResult {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
val argv = when (val raw = request.parameters["argv"]) {
is List<*> -> raw.filterIsInstance<String>()
is String -> runCatching {
Json.decodeFromString<List<String>>(raw)
}.getOrElse { emptyList() }
else -> emptyList()
}
return when {
argv.isNullOrEmpty() ->
argv.isEmpty() ->
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
allowedExecutables.isNotEmpty() && argv[0] !in allowedExecutables ->
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
else -> ValidationResult.Valid
}
}
@@ -42,7 +65,13 @@ class ShellTool(
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run {
takeIf { this is ValidationResult.Valid }?.let {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
val argv = when (val raw = request.parameters["argv"]) {
is List<*> -> raw.filterIsInstance<String>()
is String -> runCatching {
Json.decodeFromString<List<String>>(raw)
}.getOrElse { emptyList() }
else -> emptyList()
}
val process = ProcessBuilder(argv).apply {
workingDir?.let { directory(it.toFile()) }
}.start()