feat(server): idea board list + discard
ListIdeas -> idea.list (StreamQueries folds the board from the log, replay-neutral) and DiscardIdea -> append IdeaDiscardedEvent landed in the capturing session, then reply with the refreshed board. IdeaDto on the wire.
This commit is contained in:
@@ -48,6 +48,14 @@ sealed class ClientMessage {
|
||||
@Serializable
|
||||
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
||||
|
||||
/** Operator request for the cross-session idea board (replied to with an IdeaList). */
|
||||
@Serializable
|
||||
data object ListIdeas : ClientMessage()
|
||||
|
||||
/** Operator request to remove an idea from the board (tombstones it; reply is a fresh IdeaList). */
|
||||
@Serializable
|
||||
data class DiscardIdea(val ideaId: String) : ClientMessage()
|
||||
|
||||
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
|
||||
@Serializable
|
||||
data object GetConfig : ClientMessage()
|
||||
|
||||
@@ -113,6 +113,14 @@ data class ArtifactSummaryDto(
|
||||
val content: String?,
|
||||
)
|
||||
|
||||
/** One idea on the cross-session board (a captured, not-yet-discarded note). */
|
||||
@Serializable
|
||||
data class IdeaDto(
|
||||
val ideaId: String,
|
||||
val text: String,
|
||||
val capturedAtMs: Long,
|
||||
)
|
||||
|
||||
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
|
||||
level = level.name,
|
||||
factors = signals.map { signal ->
|
||||
|
||||
@@ -464,6 +464,18 @@ sealed interface ServerMessage {
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
/**
|
||||
* The cross-session idea board (reply to ListIdeas / DiscardIdea). Not event-derived (a
|
||||
* snapshot read folded from the whole event log), so cursors are null.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("idea.list")
|
||||
data class IdeaList(
|
||||
val ideas: List<IdeaDto>,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
/**
|
||||
* A session's derived metrics, returned in response to a GetSessionStats request. Not
|
||||
* event-derived (it is a projection-replay snapshot), so cursors are null. [stats] is the same
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.correx.core.approvals.Tier
|
||||
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.InitialIntentEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||
@@ -29,6 +30,7 @@ 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.router.IdeaReader
|
||||
import com.correx.core.utils.TypeId
|
||||
import com.correx.infrastructure.inference.commons.ResourceProbe
|
||||
import com.correx.infrastructure.inference.commons.ResourceSnapshot
|
||||
@@ -229,6 +231,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
||||
}
|
||||
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
||||
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
|
||||
is ClientMessage.DiscardIdea -> handleDiscardIdea(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))
|
||||
@@ -304,6 +308,34 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tombstone an idea, landing the discard in the session that captured it (so all of one idea's
|
||||
// events stay co-located), then reply with the refreshed board. A missing idea is a no-op.
|
||||
private suspend fun handleDiscardIdea(
|
||||
msg: ClientMessage.DiscardIdea,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
runCatching {
|
||||
IdeaReader(module.eventStore).sessionOf(msg.ideaId)?.let { sessionId ->
|
||||
module.eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = IdeaDiscardedEvent(ideaId = msg.ideaId, sessionId = sessionId),
|
||||
),
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
log.error("discardIdea failed for ideaId={}: {}", msg.ideaId, it.message, it)
|
||||
}
|
||||
sendFrame(queries.listIdeas())
|
||||
}
|
||||
|
||||
private fun errorResponse(message: String) = ServerMessage.ProtocolError(message)
|
||||
|
||||
private fun encodeError(message: String): Frame.Text =
|
||||
|
||||
@@ -5,7 +5,9 @@ import com.correx.apps.server.config.ConfigUpdateResult
|
||||
import com.correx.apps.server.metrics.MetricsInspectionService
|
||||
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
||||
import com.correx.apps.server.protocol.IdeaDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.router.IdeaReader
|
||||
import com.correx.core.config.EditableConfig
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
@@ -91,6 +93,17 @@ class StreamQueries(private val module: ServerModule) {
|
||||
} ?: rawString
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross-session idea board as a snapshot frame, folded from the whole event log via
|
||||
* [IdeaReader]. Pure read — no events appended, so it is replay-neutral (invariant #1/#8).
|
||||
*/
|
||||
suspend fun listIdeas(): ServerMessage.IdeaList {
|
||||
val ideas = withContext(Dispatchers.IO) { IdeaReader(module.eventStore).activeIdeas() }
|
||||
return ServerMessage.IdeaList(
|
||||
ideas = ideas.map { IdeaDto(ideaId = it.id, text = it.text, capturedAtMs = it.capturedAtMs) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Current editable config as a snapshot frame. [error] / [restartRequired] are set by updates. */
|
||||
fun configSnapshot(error: String?, restartRequired: List<String>): ServerMessage.ConfigSnapshot {
|
||||
val fields = module.configService.snapshot().map { (field, value) ->
|
||||
|
||||
+25
@@ -242,6 +242,31 @@ class ServerMessageSerializationTest {
|
||||
assertEquals("", decoded.candidates[1].reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IdeaList encodes type and ideas`() {
|
||||
val msg = ServerMessage.IdeaList(
|
||||
ideas = listOf(
|
||||
IdeaDto(ideaId = "i1", text = "cache the repo map", capturedAtMs = 1000),
|
||||
IdeaDto(ideaId = "i2", text = "add --dry-run", capturedAtMs = 2000),
|
||||
),
|
||||
)
|
||||
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
|
||||
assert(jsonStr.contains("\"type\":\"idea.list\"")) { "expected type=idea.list" }
|
||||
|
||||
val decoded = json.decodeFromString<ServerMessage.IdeaList>(jsonStr)
|
||||
assertEquals(2, decoded.ideas.size)
|
||||
assertEquals("cache the repo map", decoded.ideas.first().text)
|
||||
assertEquals(2000, decoded.ideas[1].capturedAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DiscardIdea decodes from the client wire format`() {
|
||||
val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.DiscardIdea","ideaId":"i1"}"""
|
||||
val decoded = ProtocolSerializer.decodeClientMessage(wire)
|
||||
assert(decoded is ClientMessage.DiscardIdea) { "expected DiscardIdea, got $decoded" }
|
||||
assertEquals("i1", (decoded as ClientMessage.DiscardIdea).ideaId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClarificationResponse decodes from the client wire format`() {
|
||||
val wire = """
|
||||
|
||||
Reference in New Issue
Block a user