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
+2
View File
@@ -16,8 +16,10 @@ dependencies {
testImplementation(project(":core:risk"))
testImplementation(project(":infrastructure:persistence"))
testImplementation(project(":core:artifacts"))
testImplementation(project(":core:artifacts-store"))
testImplementation(project(":testing:fixtures"))
testImplementation(project(":testing:kernel"))
testImplementation(project(":testing:contracts"))
testImplementation(testFixtures(project(":testing:contracts")))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
@@ -43,6 +43,12 @@ import com.correx.testing.fixtures.context.ContextFixtures
import com.correx.testing.fixtures.cyclePolicyMissingValidator
import com.correx.testing.fixtures.inference.MockInferenceProvider
import com.correx.testing.fixtures.transitions.TransitionFixtures
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.utils.TypeId
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.fixtures.transitions.TransitionFixtures.threeStageGraph
import com.correx.testing.kernel.MockSessionEventReplayer
import kotlinx.coroutines.launch
@@ -78,6 +84,7 @@ class SessionOrchestratorIntegrationTest {
},
)
private val riskAssessor = DefaultRiskAssessor()
private val artifactStore = NoopArtifactStore()
private val repositories = OrchestratorRepositories(
eventStore = eventStore,
@@ -100,6 +107,7 @@ class SessionOrchestratorIntegrationTest {
repositories = repositories,
engines = engines,
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
@Test
@@ -112,7 +120,6 @@ class SessionOrchestratorIntegrationTest {
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
assertEquals(2, events.size)
val workflowStarted = events.find { it.payload is WorkflowStartedEvent }
assertNotNull(workflowStarted)
@@ -147,6 +154,7 @@ class SessionOrchestratorIntegrationTest {
},
),
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
failingOrchestrator.run(sessionId, graph, config)
@@ -173,6 +181,7 @@ class SessionOrchestratorIntegrationTest {
repositories = repositories,
engines = engines.copy(validationPipeline = approvingPipeline),
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
val runJob = launch { orchestrator.run(sessionId, graph, config) }
@@ -197,7 +206,20 @@ class SessionOrchestratorIntegrationTest {
fun `workflow resumes after approval`(): Unit = runBlocking {
val sessionId = SessionId("s4")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0))
val graph = threeStageGraph()
// Single-stage graph: enterStage(A) triggers exactly one approval; after resume,
// the always-true transition moves to the terminal "done" and the workflow completes.
val graph = WorkflowGraph(
stages = mapOf(StageId("A") to StageConfig()),
transitions = setOf(
com.correx.core.transitions.graph.TransitionEdge(
id = com.correx.core.events.types.TransitionId("t1"),
from = StageId("A"),
to = StageId("done"),
condition = { true },
),
),
start = StageId("A"),
)
val approvingPipeline = ValidationPipeline(
validators = listOf(cyclePolicyMissingValidator()),
approvalTrigger = ApprovalTrigger(),
@@ -206,6 +228,7 @@ class SessionOrchestratorIntegrationTest {
repositories = repositories,
engines = engines.copy(validationPipeline = approvingPipeline),
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
val runJob = launch { approvalOrchestrator.run(sessionId, graph, config) }
@@ -257,6 +280,7 @@ class SessionOrchestratorIntegrationTest {
repositories = repositories,
engines = engines,
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
val sessionId = SessionId("s6")
@@ -269,6 +293,30 @@ class SessionOrchestratorIntegrationTest {
assertNotNull(failed.reason)
}
@Test
fun `artifactStore put is called for prompt and response and ids appear on inference events`(): Unit = runBlocking {
val recordingStore = RecordingArtifactStore()
val sessionId = SessionId("s-artifact")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
val recordingOrchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = retryCoordinator,
artifactStore = recordingStore,
)
recordingOrchestrator.run(sessionId, graph, config)
assertTrue(recordingStore.puts.size >= 2, "Expected at least 2 puts, got ${recordingStore.puts.size}")
val events = eventStore.read(sessionId)
val started = events.mapNotNull { it.payload as? InferenceStartedEvent }.first()
val completed = events.mapNotNull { it.payload as? InferenceCompletedEvent }.first()
assertEquals(recordingStore.idForIndex(0), started.promptArtifactId)
assertEquals(recordingStore.idForIndex(1), completed.responseArtifactId)
}
@Test
fun `run throws IllegalArgumentException when transition targets undeclared stage`(): Unit = runBlocking {
val a = StageId("A")
@@ -327,3 +375,21 @@ class SessionOrchestratorIntegrationTest {
}
}
private class RecordingArtifactStore : ArtifactStore {
val puts: MutableList<ByteArray> = mutableListOf()
private val byId: MutableMap<ArtifactId, ByteArray> = mutableMapOf()
fun idForIndex(i: Int): ArtifactId = TypeId("0".repeat(63) + i.toString(16))
override suspend fun put(bytes: ByteArray): ArtifactId {
val id = idForIndex(puts.size)
puts.add(bytes)
byId[id] = bytes
return id
}
override suspend fun get(id: ArtifactId): ByteArray? = byId[id]
override suspend fun flushBefore(commit: suspend () -> Unit) { commit() }
}