feat(acr): ACR Store 1 — content-hash observation cache for repo-file descriptors (#305)

Adds ObservationStore (core:context) keyed on (repoRoot, path), an in-memory default and
a durable SqliteObservationStore (infrastructure:persistence), and wires it into the two
SessionOrchestratorArtifacts call sites that re-derive a SourceDescriptor from CAS bytes
on every call — a hit on matching content hash skips both the CAS read and the regex
extraction. First slice of docs/plans/2026-07-21-acr-knowledge-accretion.md (build order:
observations before fixes/plan-shapes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
This commit is contained in:
2026-07-21 17:37:25 +04:00
parent f08a784432
commit 5df35879eb
13 changed files with 251 additions and 6 deletions
@@ -0,0 +1,76 @@
package com.correx.infrastructure.persistence.observation
import com.correx.core.context.observation.Observation
import com.correx.core.context.observation.ObservationStore
import com.correx.infrastructure.persistence.util.JDBCHelper.transaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.sql.Connection
/**
* Durable backend for ACR Store 1 (docs/plans/2026-07-21-acr-knowledge-accretion.md), so
* observations survive a server restart the way the event log itself does. One row per
* (repo_root, path); a `put` overwrites the prior observation for that key outright — this store
* only ever holds the latest fact, staleness is resolved by the caller comparing content_hash.
*/
class SqliteObservationStore(private val connection: Connection) : ObservationStore {
init {
connection.createStatement().use { stmt ->
stmt.execute(
"""
CREATE TABLE IF NOT EXISTS observations (
repo_root TEXT NOT NULL,
path TEXT NOT NULL,
content_hash TEXT NOT NULL,
descriptor_render TEXT,
observed_at_ms INTEGER NOT NULL,
PRIMARY KEY (repo_root, path)
)
""".trimIndent(),
)
}
}
override suspend fun get(repoRoot: String, path: String): Observation? = withContext(Dispatchers.IO) {
connection.prepareStatement(
"SELECT content_hash, descriptor_render, observed_at_ms FROM observations " +
"WHERE repo_root = ? AND path = ?",
).use { stmt ->
stmt.setString(1, repoRoot)
stmt.setString(2, path)
stmt.executeQuery().use { rs ->
if (!rs.next()) return@withContext null
Observation(
repoRoot = repoRoot,
path = path,
contentHash = rs.getString("content_hash"),
descriptorRender = rs.getString("descriptor_render"),
observedAtMs = rs.getLong("observed_at_ms"),
)
}
}
}
@Suppress("MagicNumber") // JDBC positional parameter indices, not data
override suspend fun put(observation: Observation) = withContext(Dispatchers.IO) {
connection.transaction {
connection.prepareStatement(
"INSERT INTO observations (repo_root, path, content_hash, descriptor_render, observed_at_ms) " +
"VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(repo_root, path) DO UPDATE SET " +
"content_hash = excluded.content_hash, " +
"descriptor_render = excluded.descriptor_render, " +
"observed_at_ms = excluded.observed_at_ms",
).use { stmt ->
stmt.setString(1, observation.repoRoot)
stmt.setString(2, observation.path)
stmt.setString(3, observation.contentHash)
stmt.setString(4, observation.descriptorRender)
stmt.setLong(5, observation.observedAtMs)
stmt.executeUpdate()
}
}
Unit
}
}
@@ -0,0 +1,37 @@
package com.correx.infrastructure.persistence.observation
import com.correx.core.context.observation.Observation
import kotlinx.coroutines.runBlocking
import java.sql.DriverManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class SqliteObservationStoreTest {
private fun store() = SqliteObservationStore(DriverManager.getConnection("jdbc:sqlite::memory:"))
@Test
fun `put then get round-trips through sqlite`(): Unit = runBlocking {
val store = store()
store.put(Observation("repoA", "src/Foo.kt", "hash1", "module=foo", 100L))
val fetched = store.get("repoA", "src/Foo.kt")
assertEquals(Observation("repoA", "src/Foo.kt", "hash1", "module=foo", 100L), fetched)
}
@Test
fun `put overwrites the prior row for the same key`(): Unit = runBlocking {
val store = store()
store.put(Observation("repoA", "src/Foo.kt", "hash1", "old", 1L))
store.put(Observation("repoA", "src/Foo.kt", "hash2", "new", 2L))
assertEquals("hash2", store.get("repoA", "src/Foo.kt")?.contentHash)
}
@Test
fun `unknown key returns null`(): Unit = runBlocking {
assertNull(store().get("repoA", "nope"))
}
}