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
|
@Serializable
|
||||||
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
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). */
|
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data object GetConfig : ClientMessage()
|
data object GetConfig : ClientMessage()
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ data class ArtifactSummaryDto(
|
|||||||
val content: String?,
|
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(
|
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
|
||||||
level = level.name,
|
level = level.name,
|
||||||
factors = signals.map { signal ->
|
factors = signals.map { signal ->
|
||||||
|
|||||||
@@ -464,6 +464,18 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long? = null,
|
override val sessionSequence: Long? = null,
|
||||||
) : ServerMessage, NonEventMessage
|
) : 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
|
* 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
|
* 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.ApprovalGrantCreatedEvent
|
||||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
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.InitialIntentEvent
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
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.events.types.StageId
|
||||||
import com.correx.core.inference.ProviderHealth
|
import com.correx.core.inference.ProviderHealth
|
||||||
import com.correx.core.kernel.orchestration.WorkspaceContext
|
import com.correx.core.kernel.orchestration.WorkspaceContext
|
||||||
|
import com.correx.core.router.IdeaReader
|
||||||
import com.correx.core.utils.TypeId
|
import com.correx.core.utils.TypeId
|
||||||
import com.correx.infrastructure.inference.commons.ResourceProbe
|
import com.correx.infrastructure.inference.commons.ResourceProbe
|
||||||
import com.correx.infrastructure.inference.commons.ResourceSnapshot
|
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) }
|
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
||||||
}
|
}
|
||||||
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
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.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
|
||||||
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
||||||
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
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 errorResponse(message: String) = ServerMessage.ProtocolError(message)
|
||||||
|
|
||||||
private fun encodeError(message: String): Frame.Text =
|
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.metrics.MetricsInspectionService
|
||||||
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
||||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
import com.correx.apps.server.protocol.ConfigFieldDto
|
||||||
|
import com.correx.apps.server.protocol.IdeaDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
|
import com.correx.core.router.IdeaReader
|
||||||
import com.correx.core.config.EditableConfig
|
import com.correx.core.config.EditableConfig
|
||||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
@@ -91,6 +93,17 @@ class StreamQueries(private val module: ServerModule) {
|
|||||||
} ?: rawString
|
} ?: 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. */
|
/** Current editable config as a snapshot frame. [error] / [restartRequired] are set by updates. */
|
||||||
fun configSnapshot(error: String?, restartRequired: List<String>): ServerMessage.ConfigSnapshot {
|
fun configSnapshot(error: String?, restartRequired: List<String>): ServerMessage.ConfigSnapshot {
|
||||||
val fields = module.configService.snapshot().map { (field, value) ->
|
val fields = module.configService.snapshot().map { (field, value) ->
|
||||||
|
|||||||
+25
@@ -242,6 +242,31 @@ class ServerMessageSerializationTest {
|
|||||||
assertEquals("", decoded.candidates[1].reason)
|
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
|
@Test
|
||||||
fun `ClarificationResponse decodes from the client wire format`() {
|
fun `ClarificationResponse decodes from the client wire format`() {
|
||||||
val wire = """
|
val wire = """
|
||||||
|
|||||||
Reference in New Issue
Block a user