diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index c4654dfa..1952aab6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -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()) } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt index 255968e2..04b31372 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt @@ -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 diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt index cc61d15d..dcfae1b1 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt @@ -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 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 = mapOf( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt new file mode 100644 index 00000000..0ff8e2e9 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt @@ -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:"; for a dirty + * worktree it is "fp:"; for a non-git directory it is "fp:". + * 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? { + 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) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceObservationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceObservationTest.kt new file mode 100644 index 00000000..8f07aa8a --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceObservationTest.kt @@ -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") + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/WorkspaceStateProbeTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/WorkspaceStateProbeTest.kt new file mode 100644 index 00000000..4b8df0c8 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/WorkspaceStateProbeTest.kt @@ -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) + } +}