feat(router): idea-board -> project-profile promotion (E)
Promote an auto-captured idea from the cross-session board into the curated per-repo profile at <workspaceRoot>/.correx/project.toml as a `conventions` entry. New IdeaPromotedEvent tombstones the idea off the board (IdeaReader treats it like a discard) and records the promotion as a fact; the server PromoteIdea handler loads the profile, appends the idea text (deduped via ProjectProfile.withConvention), and writes it back. Adds ProjectProfileWriter (escape-aware TOML serialize/write) and makes SimpleToml's reader unescape \\ and \" symmetrically with the writers (fixes a pre-existing read/write asymmetry surfaced by the round-trip test). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package com.correx.core.router
|
||||
|
||||
import com.correx.core.events.events.IdeaCapturedEvent
|
||||
import com.correx.core.events.events.IdeaDiscardedEvent
|
||||
import com.correx.core.events.events.IdeaPromotedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.router.model.Idea
|
||||
@@ -10,28 +11,36 @@ import com.correx.core.router.model.Idea
|
||||
* Rebuilds the operator's idea board from the event log. Cross-session by design: it folds over
|
||||
* [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session
|
||||
* shows on the board (and feeds the router) in every session. A captured idea is dropped once a
|
||||
* matching [IdeaDiscardedEvent] tombstones it (invariant #1 — the capture stays in the log).
|
||||
* matching [IdeaDiscardedEvent] (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture
|
||||
* stays in the log).
|
||||
*/
|
||||
class IdeaReader(private val eventStore: EventStore) {
|
||||
|
||||
/** Active ideas (captured, not later discarded) across all sessions, newest first. */
|
||||
/** Active ideas (captured, not later discarded or promoted) across all sessions, newest first. */
|
||||
fun activeIdeas(): List<Idea> {
|
||||
val discarded = mutableSetOf<String>()
|
||||
val tombstoned = mutableSetOf<String>()
|
||||
val captured = mutableListOf<Idea>()
|
||||
eventStore.allEvents().forEach { stored ->
|
||||
when (val payload = stored.payload) {
|
||||
is IdeaDiscardedEvent -> discarded += payload.ideaId
|
||||
is IdeaDiscardedEvent -> tombstoned += payload.ideaId
|
||||
is IdeaPromotedEvent -> tombstoned += payload.ideaId
|
||||
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return captured.filterNot { it.id in discarded }.sortedByDescending { it.capturedAtMs }
|
||||
return captured.filterNot { it.id in tombstoned }.sortedByDescending { it.capturedAtMs }
|
||||
}
|
||||
|
||||
/** The session that captured [ideaId], so a discard tombstone lands alongside its capture. */
|
||||
/** The session that captured [ideaId], so a tombstone lands alongside its capture. */
|
||||
fun sessionOf(ideaId: String): SessionId? =
|
||||
capturedOf(ideaId)?.sessionId
|
||||
|
||||
/** The captured text of [ideaId] (so a promotion can write it into the project profile), or null. */
|
||||
fun textOf(ideaId: String): String? =
|
||||
capturedOf(ideaId)?.text
|
||||
|
||||
private fun capturedOf(ideaId: String): IdeaCapturedEvent? =
|
||||
eventStore.allEvents()
|
||||
.mapNotNull { it.payload as? IdeaCapturedEvent }
|
||||
.firstOrNull { it.ideaId == ideaId }
|
||||
?.sessionId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.correx.core.router
|
||||
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.IdeaCapturedEvent
|
||||
import com.correx.core.events.events.IdeaDiscardedEvent
|
||||
import com.correx.core.events.events.IdeaPromotedEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IdeaReaderTest {
|
||||
|
||||
private val session = SessionId("s-1")
|
||||
|
||||
@Test
|
||||
fun `a promoted idea is filtered out of the board like a discard`() {
|
||||
val store = fakeStore(
|
||||
IdeaCapturedEvent("keep", session, "cache the repo map", timestampMs = 1),
|
||||
IdeaCapturedEvent("promoted", session, "no bare try-catch", timestampMs = 2),
|
||||
IdeaCapturedEvent("discarded", session, "old note", timestampMs = 3),
|
||||
IdeaPromotedEvent("promoted", session, "no bare try-catch", timestampMs = 99),
|
||||
IdeaDiscardedEvent("discarded", session),
|
||||
)
|
||||
|
||||
val active = IdeaReader(store).activeIdeas()
|
||||
|
||||
assertEquals(listOf("keep"), active.map { it.id })
|
||||
assertFalse(active.any { it.id == "promoted" }, "promoted idea must leave the board")
|
||||
assertFalse(active.any { it.id == "discarded" }, "discarded idea must leave the board")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `textOf returns the captured text and null for an unknown idea`() {
|
||||
val store = fakeStore(
|
||||
IdeaCapturedEvent("i1", session, "events are the only source of truth", timestampMs = 1),
|
||||
)
|
||||
val reader = IdeaReader(store)
|
||||
|
||||
assertEquals("events are the only source of truth", reader.textOf("i1"))
|
||||
assertNull(reader.textOf("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `textOf still resolves the text after the idea is promoted`() {
|
||||
val store = fakeStore(
|
||||
IdeaCapturedEvent("i1", session, "prefer data classes", timestampMs = 1),
|
||||
IdeaPromotedEvent("i1", session, "prefer data classes", timestampMs = 5),
|
||||
)
|
||||
val reader = IdeaReader(store)
|
||||
|
||||
// The capture stays in the log (invariant #1), so the text remains readable.
|
||||
assertEquals("prefer data classes", reader.textOf("i1"))
|
||||
assertTrue(reader.activeIdeas().isEmpty())
|
||||
}
|
||||
|
||||
private fun fakeStore(vararg payloads: EventPayload): EventStore = FakeEventStore(payloads.toList())
|
||||
|
||||
/** Minimal read-only [EventStore]: only [allEvents] is exercised by [IdeaReader]. */
|
||||
private class FakeEventStore(payloads: List<EventPayload>) : EventStore {
|
||||
private val stored: List<StoredEvent> = payloads.mapIndexed { index, payload ->
|
||||
StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e-$index"),
|
||||
sessionId = SessionId("s-1"),
|
||||
timestamp = Instant.fromEpochMilliseconds(index.toLong()),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = index.toLong(),
|
||||
sessionSequence = index.toLong(),
|
||||
payload = payload,
|
||||
)
|
||||
}
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> = stored.asSequence()
|
||||
|
||||
override suspend fun append(event: NewEvent): StoredEvent = throw NotImplementedError()
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = throw NotImplementedError()
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> = throw NotImplementedError()
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
throw NotImplementedError()
|
||||
override fun lastSequence(sessionId: SessionId): Long? = throw NotImplementedError()
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||
override suspend fun lastGlobalSequence(): Long = throw NotImplementedError()
|
||||
override fun allSessionIds(): Set<SessionId> = throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user