feat(cas): content-addressed artifact store (steps 1–8)

New core:artifacts-store interface + infrastructure/artifacts-cas
implementation: segment files + SQLite index, Blake3 hashing,
group-commit fsync via flushBefore, recovery tail-scan, manual
compactor, and oldest-first disk-cap evictor.

Inference events now carry promptArtifactId / responseArtifactId;
orchestrators put bytes before emitting. SqliteEventStore wraps
its txn in artifactStore.flushBefore so segment data is fsynced
before the event commit, making the crash window non-corrupting
(TailScanner re-indexes orphan tail records on reopen).

Compactor and evictor are mutually exclusive via maintenanceMutex.
Step 9 (cloud sync) deferred to a later epic.

See docs/reviews/2026-05-18-cas-steps-1-8-review.md for the final
review.
This commit is contained in:
2026-05-18 12:22:38 +04:00
parent bbff73108e
commit 219e2c762e
60 changed files with 2042 additions and 165 deletions
+1
View File
@@ -19,6 +19,7 @@ dependencies {
implementation project(':core:validation')
implementation project(':core:risk')
implementation project(':core:artifacts')
implementation project(':core:artifacts-store')
implementation project(':infrastructure')
implementation project(':infrastructure:workflow')
implementation project(':infrastructure:persistence')
@@ -31,7 +31,8 @@ import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
fun main() {
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore())
val artifactStore = InfrastructureModule.createArtifactStore()
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore))
val llamaProvider = InfrastructureModule.createLlamaCppProvider(
modelId = System.getenv("CORREX_MODEL_ID") ?: "default",
@@ -79,6 +80,7 @@ fun main() {
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
)
val workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader())
@@ -11,7 +11,7 @@ private val log = LoggerFactory.getLogger(LoggingEventStore::class.java)
class LoggingEventStore(private val delegate: EventStore) : EventStore {
override fun append(event: NewEvent): StoredEvent =
override suspend fun append(event: NewEvent): StoredEvent =
runCatching { delegate.append(event) }
.onSuccess { stored ->
log.info(
@@ -31,7 +31,7 @@ class LoggingEventStore(private val delegate: EventStore) : EventStore {
}
.getOrThrow()
override fun appendAll(events: List<NewEvent>): List<StoredEvent> =
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> =
runCatching { delegate.appendAll(events) }
.onSuccess { stored ->
val breakdown = events.groupingBy { it.payload::class.simpleName }.eachCount()
@@ -58,4 +58,6 @@ class LoggingEventStore(private val delegate: EventStore) : EventStore {
override fun lastSequence(sessionId: SessionId): Long? = delegate.lastSequence(sessionId)
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = delegate.subscribe(sessionId)
override fun allEvents(): Sequence<StoredEvent> = delegate.allEvents()
}
@@ -34,8 +34,12 @@ sealed class ServerMessage {
data class InferenceStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
@Serializable
data class InferenceCompleted(val sessionId: SessionId, val stageId: StageId, val outputSummary: String) :
ServerMessage()
data class InferenceCompleted(
val sessionId: SessionId,
val stageId: StageId,
val outputSummary: String,
val responseText: String = "",
) : ServerMessage()
@Serializable
data class InferenceTimedOut(val sessionId: SessionId, val stageId: StageId, val elapsedMs: Long) :
@@ -12,7 +12,7 @@ private val log = LoggerFactory.getLogger(FileSystemWorkflowRegistry::class.java
class FileSystemWorkflowRegistry(
private val loader: WorkflowLoader,
private val workflowsDir: Path = Path.of(
System.getProperty("user.home"), ".config", "correx", "workflows"
System.getProperty("user.home"), ".config", "correx", "workflows",
),
) : WorkflowRegistry {
@@ -32,8 +32,8 @@ class FileSystemWorkflowRegistry(
.getOrNull()
}
.associateBy { graph ->
graph.id.ifBlank { graph.let { it } .let { file -> file.start.value } }
}
graph.id.ifBlank { graph.start.value }
}.also { log.info("cache init done, cache keys: {}", it.keys) }
}
private fun logWarning(file: Path, ex: Throwable) {
@@ -48,7 +48,11 @@ class GlobalStreamHandler(private val module: ServerModule) {
runCatching { ProtocolSerializer.decodeClientMessage(text) }
.onSuccess { msg ->
log.debug("recv: {}", msg::class.simpleName)
handleClientMessage(session, msg)
runCatching {
handleClientMessage(session, msg)
}.onFailure {
log.error("handle failed", it)
}
}
.onFailure {
log.warn("decode error: {}", it.message)
@@ -90,6 +94,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.StartSession -> {
val graph = module.workflowRegistry.find(msg.workflowId)
log.info("find returned: {}", graph)
if (graph == null) {
val error = ServerMessage.ProtocolError("Unknown workflowId: ${msg.workflowId}")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))