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.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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