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:
@@ -56,6 +56,14 @@ sealed class ClientMessage {
|
||||
@Serializable
|
||||
data class DiscardIdea(val ideaId: String) : ClientMessage()
|
||||
|
||||
/**
|
||||
* Operator request to promote an idea into the bound session's curated project profile (added as
|
||||
* a `conventions` entry in `<workspaceRoot>/.correx/project.toml`). Tombstones the idea like a
|
||||
* discard; reply is a fresh IdeaList.
|
||||
*/
|
||||
@Serializable
|
||||
data class PromoteIdea(val ideaId: String) : ClientMessage()
|
||||
|
||||
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
|
||||
@Serializable
|
||||
data object GetConfig : ClientMessage()
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.IdeaDiscardedEvent
|
||||
import com.correx.core.events.events.IdeaPromotedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||
@@ -30,6 +31,9 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.kernel.orchestration.WorkspaceContext
|
||||
import com.correx.core.config.ProjectProfileLoader
|
||||
import com.correx.core.config.ProjectProfileWriter
|
||||
import com.correx.core.config.withConvention
|
||||
import com.correx.core.router.IdeaReader
|
||||
import com.correx.core.utils.TypeId
|
||||
import com.correx.infrastructure.inference.commons.ResourceProbe
|
||||
@@ -233,6 +237,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
||||
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
|
||||
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
|
||||
is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
|
||||
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
|
||||
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
||||
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
||||
@@ -336,6 +341,57 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
sendFrame(queries.listIdeas())
|
||||
}
|
||||
|
||||
// Promote an idea into the bound session's curated project profile (a `conventions` entry in
|
||||
// <workspaceRoot>/.correx/project.toml), then tombstone it like a discard so it leaves the board.
|
||||
// No-ops (just refresh the board) when the idea is missing or its capturing session has no bound
|
||||
// workspace — promotion needs a workspace to write the profile into.
|
||||
private suspend fun handlePromoteIdea(
|
||||
msg: ClientMessage.PromoteIdea,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
runCatching {
|
||||
val reader = IdeaReader(module.eventStore)
|
||||
val sessionId = reader.sessionOf(msg.ideaId) ?: return@runCatching
|
||||
val text = reader.textOf(msg.ideaId) ?: return@runCatching
|
||||
val workspaceRoot = workspaceRootOf(sessionId) ?: return@runCatching
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val updated = ProjectProfileLoader.load(workspaceRoot).withConvention(text)
|
||||
ProjectProfileWriter.write(workspaceRoot, updated)
|
||||
}
|
||||
|
||||
module.eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = IdeaPromotedEvent(
|
||||
ideaId = msg.ideaId,
|
||||
sessionId = sessionId,
|
||||
text = text,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}.onFailure {
|
||||
log.error("promoteIdea failed for ideaId={}: {}", msg.ideaId, it.message, it)
|
||||
}
|
||||
sendFrame(queries.listIdeas())
|
||||
}
|
||||
|
||||
// The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent (invariant #9),
|
||||
// or null if the session never bound one.
|
||||
private fun workspaceRootOf(sessionId: SessionId): String? =
|
||||
module.eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? SessionWorkspaceBoundEvent }
|
||||
.lastOrNull()
|
||||
?.workspaceRoot
|
||||
|
||||
private fun errorResponse(message: String) = ServerMessage.ProtocolError(message)
|
||||
|
||||
private fun encodeError(message: String): Frame.Text =
|
||||
|
||||
+8
@@ -267,6 +267,14 @@ class ServerMessageSerializationTest {
|
||||
assertEquals("i1", (decoded as ClientMessage.DiscardIdea).ideaId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PromoteIdea decodes from the client wire format`() {
|
||||
val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.PromoteIdea","ideaId":"i1"}"""
|
||||
val decoded = ProtocolSerializer.decodeClientMessage(wire)
|
||||
assert(decoded is ClientMessage.PromoteIdea) { "expected PromoteIdea, got $decoded" }
|
||||
assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClarificationResponse decodes from the client wire format`() {
|
||||
val wire = """
|
||||
|
||||
Reference in New Issue
Block a user