feat(memory): WorkspaceStateProbe (git or fingerprint) + observeAndRecord at session start
This commit is contained in:
@@ -192,6 +192,7 @@ class ServerModule(
|
||||
// see both in context.
|
||||
projectMemory?.let { pm ->
|
||||
runCatching {
|
||||
pm.observeAndRecord(sessionId, pm.repoRoot())
|
||||
pm.indexAndRecord(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.RepoMapComputedEvent
|
||||
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.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
@@ -41,6 +42,7 @@ class ProjectMemoryService(
|
||||
private val eventStore: EventStore,
|
||||
private val distiller: ProjectMemoryDistiller = ProjectMemoryDistiller(),
|
||||
private val indexer: RepoMapIndexer = RepoMapIndexer(),
|
||||
private val workspaceStateProbe: WorkspaceStateProbe = RealWorkspaceStateProbe(),
|
||||
) {
|
||||
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. */
|
||||
suspend fun persist(sessionId: SessionId, repoRoot: String) {
|
||||
if (!config.enabled) return
|
||||
|
||||
@@ -63,8 +63,11 @@ class RepoMapIndexer(
|
||||
.toList()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_SYMBOLS_PER_FILE = 40
|
||||
companion object {
|
||||
/** 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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
+89
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user