feat(memory): WorkspaceStateProbe (git or fingerprint) + observeAndRecord at session start

This commit is contained in:
2026-06-11 13:22:50 +04:00
parent f1fc43cc85
commit 4dbe637f4a
6 changed files with 314 additions and 2 deletions
@@ -192,6 +192,7 @@ class ServerModule(
// see both in context. // see both in context.
projectMemory?.let { pm -> projectMemory?.let { pm ->
runCatching { runCatching {
pm.observeAndRecord(sessionId, pm.repoRoot())
pm.indexAndRecord(sessionId, pm.repoRoot()) pm.indexAndRecord(sessionId, pm.repoRoot())
pm.retrieveAndSeed(sessionId, pm.repoRoot()) pm.retrieveAndSeed(sessionId, pm.repoRoot())
} }
@@ -5,6 +5,7 @@ import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.RepoMapComputedEvent import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
@@ -41,6 +42,7 @@ class ProjectMemoryService(
private val eventStore: EventStore, private val eventStore: EventStore,
private val distiller: ProjectMemoryDistiller = ProjectMemoryDistiller(), private val distiller: ProjectMemoryDistiller = ProjectMemoryDistiller(),
private val indexer: RepoMapIndexer = RepoMapIndexer(), private val indexer: RepoMapIndexer = RepoMapIndexer(),
private val workspaceStateProbe: WorkspaceStateProbe = RealWorkspaceStateProbe(),
) { ) {
private fun tag(repoRoot: String) = "project:$repoRoot" private fun tag(repoRoot: String) = "project:$repoRoot"
@@ -81,6 +83,43 @@ class ProjectMemoryService(
} }
} }
/**
* Observe the workspace content state and record it as a [WorkspaceStateObservedEvent]
* (invariant #9: environment observations are recorded as events, never re-observed).
*/
suspend fun observeAndRecord(sessionId: SessionId, repoRoot: String) {
if (!config.enabled) return
val state = workspaceStateProbe.observe(repoRoot) ?: run {
log.debug("workspace state unavailable for {} — skipping observation", repoRoot)
return
}
runCatching {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = WorkspaceStateObservedEvent(
sessionId = sessionId,
workspaceRoot = repoRoot,
stateKey = state.stateKey,
source = state.source,
branch = state.branch,
dirty = state.dirty,
),
),
)
}.exceptionOrNull()?.let { e ->
if (e is CancellationException) throw e
log.warn("workspace state observation failed for {}: {}", repoRoot, e.message)
}
}
/** Distil the session's decision journal into durable, repo-tagged L3 entries. */ /** Distil the session's decision journal into durable, repo-tagged L3 entries. */
suspend fun persist(sessionId: SessionId, repoRoot: String) { suspend fun persist(sessionId: SessionId, repoRoot: String) {
if (!config.enabled) return if (!config.enabled) return
@@ -63,8 +63,11 @@ class RepoMapIndexer(
.toList() .toList()
} }
private companion object { companion object {
const val MAX_SYMBOLS_PER_FILE = 40 /** File extensions considered for indexing — reused by [WorkspaceStateProbe] for fingerprinting. */
val INDEXED_EXTENSIONS: Set<String> get() = SYMBOL_PATTERNS.keys
private const val MAX_SYMBOLS_PER_FILE = 40
// Top-level declarations only — best-effort per language. Bodies/locals are never matched. // Top-level declarations only — best-effort per language. Bodies/locals are never matched.
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf( val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
@@ -0,0 +1,115 @@
package com.correx.apps.server.memory
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.security.MessageDigest
import kotlin.io.path.extension
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.relativeTo
private val log = LoggerFactory.getLogger(RealWorkspaceStateProbe::class.java)
private const val FINGERPRINT_PREFIX = "fp:"
private const val GIT_PREFIX = "git:"
private const val SOURCE_GIT = "git"
private const val SOURCE_FINGERPRINT = "fingerprint"
private const val FINGERPRINT_HEX_LENGTH = 16
private const val MAX_DEPTH = 4
/** Immutable snapshot of a workspace content state. */
data class WorkspaceState(
val stateKey: String,
val source: String,
val branch: String?,
val dirty: Boolean,
)
/** Produces a [WorkspaceState] for any workspace (git or plain directory). */
interface WorkspaceStateProbe {
fun observe(workspaceRoot: String): WorkspaceState?
}
/** Test double returning a fixed result. */
class FakeWorkspaceStateProbe(private val result: WorkspaceState?) : WorkspaceStateProbe {
override fun observe(workspaceRoot: String): WorkspaceState? = result
}
/**
* Production probe. For a clean git worktree the stateKey is "git:<HEAD>"; for a dirty
* worktree it is "fp:<fingerprint>"; for a non-git directory it is "fp:<fingerprint>".
* Everything is wrapped in runCatching — any failure returns null.
*/
class RealWorkspaceStateProbe : WorkspaceStateProbe {
override fun observe(workspaceRoot: String): WorkspaceState? =
runCatching { doObserve(workspaceRoot) }
.onFailure { log.debug("workspace state probe failed for {}: {}", workspaceRoot, it.message) }
.getOrNull()
private fun doObserve(workspaceRoot: String): WorkspaceState? {
val root = Path.of(workspaceRoot)
if (!root.isDirectory()) return null
val gitInfo = tryGit(root)
return if (gitInfo != null) {
val (head, branch, dirty) = gitInfo
if (dirty) {
val fp = fingerprint(root)
WorkspaceState("$FINGERPRINT_PREFIX$fp", SOURCE_FINGERPRINT, branch, dirty = true)
} else {
WorkspaceState("$GIT_PREFIX$head", SOURCE_GIT, branch, dirty = false)
}
} else {
val fp = fingerprint(root)
WorkspaceState("$FINGERPRINT_PREFIX$fp", SOURCE_FINGERPRINT, branch = null, dirty = false)
}
}
/**
* Returns (head, branch, dirty) or null if the directory is not a git repo or any
* subprocess call fails.
*/
private fun tryGit(root: Path): Triple<String, String?, Boolean>? {
val head = runProcess(root, "git", "rev-parse", "HEAD") ?: return null
val branch = runProcess(root, "git", "rev-parse", "--abbrev-ref", "HEAD")
?.takeIf { it.isNotBlank() && it != "HEAD" }
val statusOutput = runProcess(root, "git", "status", "--porcelain") ?: return null
val dirty = statusOutput.isNotBlank()
return Triple(head, branch, dirty)
}
/** Runs a process in [dir]; returns trimmed stdout or null on non-zero exit / error. */
private fun runProcess(dir: Path, vararg command: String): String? =
runCatching {
val result = ProcessBuilder(*command)
.directory(dir.toFile())
.redirectErrorStream(true)
.start()
val output = result.inputStream.bufferedReader().readText().trim()
if (result.waitFor() != 0) null else output
}.getOrNull()
/**
* SHA-256 over sorted "relativePath|mtimeMs|size" lines, using the same depth and
* file-extension filter as [RepoMapIndexer].
*/
private fun fingerprint(root: Path): String {
val indexedExtensions = RepoMapIndexer.INDEXED_EXTENSIONS
val lines = Files.walk(root, MAX_DEPTH).use { stream ->
stream.filter { it.isRegularFile() && it.extension in indexedExtensions }
.map { path ->
val rel = path.relativeTo(root).toString()
val mtime = runCatching { Files.getLastModifiedTime(path).toMillis() }.getOrDefault(0L)
val size = runCatching { Files.size(path) }.getOrDefault(0L)
"$rel|$mtime|$size"
}
.sorted()
.toList()
}
val digest = MessageDigest.getInstance("SHA-256")
lines.forEach { digest.update(it.toByteArray()) }
return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH)
}
}
@@ -0,0 +1,89 @@
package com.correx.apps.server.memory
import com.correx.core.config.ProjectConfig
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.inference.Embedder
import com.correx.core.journal.DecisionJournalProjector
import com.correx.core.journal.DefaultDecisionJournalReducer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.router.l3.InMemoryL3MemoryStore
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.UUID
private class ConstantEmbedder(override val dimension: Int = 8) : Embedder {
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f }
}
class ProjectMemoryServiceObservationTest {
private fun service(
config: ProjectConfig,
es: InMemoryEventStore,
probeResult: WorkspaceState?,
) = ProjectMemoryService(
config = config,
embedder = ConstantEmbedder(),
l3MemoryStore = InMemoryL3MemoryStore(),
journalRepository = DefaultDecisionJournalRepository(
DefaultEventReplayer(es, DecisionJournalProjector(DefaultDecisionJournalReducer())),
),
eventStore = es,
workspaceStateProbe = FakeWorkspaceStateProbe(probeResult),
)
@Test
fun `probe success emits WorkspaceStateObservedEvent`(): Unit = runBlocking {
val es = InMemoryEventStore()
val config = ProjectConfig(enabled = true, root = "/repo")
val sessionId = SessionId("session-obs-1")
service(config, es, WorkspaceState("git:abc123", "git", "main", false))
.observeAndRecord(sessionId, "/repo")
val events = es.read(sessionId)
.mapNotNull { it.payload as? WorkspaceStateObservedEvent }
assertEquals(1, events.size, "expected exactly one WorkspaceStateObservedEvent")
val evt = events.single()
assertEquals("git:abc123", evt.stateKey)
assertEquals("git", evt.source)
assertEquals("main", evt.branch)
assertEquals(false, evt.dirty)
}
@Test
fun `probe null emits no event`(): Unit = runBlocking {
val es = InMemoryEventStore()
val config = ProjectConfig(enabled = true, root = "/repo")
val sessionId = SessionId("session-obs-2")
service(config, es, null).observeAndRecord(sessionId, "/repo")
val events = es.read(sessionId)
.mapNotNull { it.payload as? WorkspaceStateObservedEvent }
assertTrue(events.isEmpty(), "expected no events when probe returns null")
}
@Test
fun `disabled config skips observation`(): Unit = runBlocking {
val es = InMemoryEventStore()
val config = ProjectConfig(enabled = false, root = "/repo")
val sessionId = SessionId("session-obs-3")
service(config, es, WorkspaceState("git:abc123", "git", "main", false))
.observeAndRecord(sessionId, "/repo")
val events = es.read(sessionId)
.mapNotNull { it.payload as? WorkspaceStateObservedEvent }
assertTrue(events.isEmpty(), "expected no events when config is disabled")
}
}
@@ -0,0 +1,65 @@
package com.correx.apps.server.memory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.FileTime
class WorkspaceStateProbeTest {
private val probe = RealWorkspaceStateProbe()
@Test
fun `non-git dir with kt files produces fingerprint state`(@TempDir dir: Path) {
Files.writeString(dir.resolve("Main.kt"), "fun main() {}")
Files.writeString(dir.resolve("Foo.kt"), "class Foo")
val state = probe.observe(dir.toString())
assertNotNull(state)
assertTrue(state!!.stateKey.startsWith("fp:"), "stateKey should start with fp: but was ${state.stateKey}")
assertEquals("fingerprint", state.source)
assertNull(state.branch)
assertEquals(false, state.dirty)
}
@Test
fun `fingerprint is deterministic across two calls`(@TempDir dir: Path) {
Files.writeString(dir.resolve("Main.kt"), "fun main() {}")
val s1 = probe.observe(dir.toString())
val s2 = probe.observe(dir.toString())
assertNotNull(s1)
assertNotNull(s2)
assertEquals(s1!!.stateKey, s2!!.stateKey)
}
@Test
fun `changing a file mtime changes the fingerprint`(@TempDir dir: Path) {
val file = dir.resolve("Main.kt")
Files.writeString(file, "fun main() {}")
val s1 = probe.observe(dir.toString())
assertNotNull(s1)
Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis() + 5_000L))
val s2 = probe.observe(dir.toString())
assertNotNull(s2)
assertNotEquals(s1!!.stateKey, s2!!.stateKey, "fingerprint should change when mtime changes")
}
@Test
fun `nonexistent root returns null`() {
val result = probe.observe("/no/such/path/correx-probe-test")
assertNull(result)
}
}