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:
@@ -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() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user