fix(server): artifact viewer dropped content hash due to event ordering
Root cause of empty artifact content (even for role_pipeline sessions with intact CAS
data): ArtifactContentStored is emitted at inference time, BEFORE ArtifactCreated (which
fires at validation). listArtifacts only applied the content hash via accs[id]?.let{} —
a no-op because the artifact wasn't in the map yet — so the hash was silently dropped and
every artifact showed blank.
Verified against the live store (session 4679ed1e): all 5 artifacts had hash=none before,
now resolve to their validated content (analysis 867B, design 1074B, impl_plan 966B,
patch 574B, review_report 369B).
- ArtifactContentStored now seeds the accumulator (getOrPut), order-independent
- fold extracted to pure StreamQueries.planArtifacts for unit testing
- regression test: content hash captured when ContentStored precedes Created
- inference-output fallback retained for genuinely content-less stages
This commit is contained in:
@@ -11,6 +11,7 @@ import com.correx.core.events.events.ArtifactCreatedEvent
|
|||||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||||
import com.correx.core.events.events.InferenceCompletedEvent
|
import com.correx.core.events.events.InferenceCompletedEvent
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -31,35 +32,15 @@ class StreamQueries(private val module: ServerModule) {
|
|||||||
*/
|
*/
|
||||||
suspend fun listArtifacts(sessionId: SessionId): ServerMessage.ArtifactList {
|
suspend fun listArtifacts(sessionId: SessionId): ServerMessage.ArtifactList {
|
||||||
val events = withContext(Dispatchers.IO) { module.eventStore.read(sessionId) }
|
val events = withContext(Dispatchers.IO) { module.eventStore.read(sessionId) }
|
||||||
val accs = LinkedHashMap<String, ArtifactAcc>()
|
val artifacts = planArtifacts(events).map { plan ->
|
||||||
// Per-stage raw inference output (last response), used as a content fallback for artifacts
|
val content = plan.contentHash?.let {
|
||||||
// that never recorded a slot->hash mapping (ArtifactContentStoredEvent) — e.g. older sessions
|
|
||||||
// or stages where the validated-artifact link was lost. Lets the operator still see what the
|
|
||||||
// stage actually produced instead of a blank pane.
|
|
||||||
val lastInferenceByStage = HashMap<String, ArtifactId>()
|
|
||||||
for (event in events) {
|
|
||||||
when (val p = event.payload) {
|
|
||||||
is ArtifactCreatedEvent -> accs.getOrPut(p.artifactId.value) { ArtifactAcc() }.apply {
|
|
||||||
stageId = p.stageId.value
|
|
||||||
schemaVersion = p.schemaVersion
|
|
||||||
}
|
|
||||||
is ArtifactValidatingEvent -> accs[p.artifactId.value]?.let { it.phase = "VALIDATING" }
|
|
||||||
is ArtifactValidatedEvent -> accs[p.artifactId.value]?.let { it.phase = "VALIDATED" }
|
|
||||||
is ArtifactContentStoredEvent -> accs[p.artifactId.value]?.let { it.contentHash = p.contentHash }
|
|
||||||
is InferenceCompletedEvent -> lastInferenceByStage[p.stageId.value] = p.responseArtifactId
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val artifacts = accs.map { (id, acc) ->
|
|
||||||
val hash = acc.contentHash ?: lastInferenceByStage[acc.stageId]
|
|
||||||
val content = hash?.let {
|
|
||||||
withContext(Dispatchers.IO) { module.artifactStore.get(it) }?.toString(Charsets.UTF_8)
|
withContext(Dispatchers.IO) { module.artifactStore.get(it) }?.toString(Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
ArtifactSummaryDto(
|
ArtifactSummaryDto(
|
||||||
artifactId = id,
|
artifactId = plan.artifactId,
|
||||||
stageId = acc.stageId,
|
stageId = plan.stageId,
|
||||||
schemaVersion = acc.schemaVersion,
|
schemaVersion = plan.schemaVersion,
|
||||||
phase = acc.phase,
|
phase = plan.phase,
|
||||||
content = content?.takeIf { it.isNotBlank() },
|
content = content?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,7 +68,54 @@ class StreamQueries(private val module: ServerModule) {
|
|||||||
is ConfigUpdateResult.Error -> configSnapshot(error = result.message, restartRequired = emptyList())
|
is ConfigUpdateResult.Error -> configSnapshot(error = result.message, restartRequired = emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mutable accumulator for folding a session's artifact events into one summary per artifact. */
|
companion object {
|
||||||
|
/**
|
||||||
|
* Folds a session's events into one plan per artifact (creation order preserved). Pure — no
|
||||||
|
* CAS read — so it is unit-testable. [ArtifactContentStoredEvent] is emitted at inference
|
||||||
|
* time, BEFORE the [ArtifactCreatedEvent] that fires at validation, so BOTH seed the
|
||||||
|
* accumulator (`getOrPut`); folding only on creation silently dropped the content hash and
|
||||||
|
* blanked every artifact. As a fallback, an artifact with no stored hash uses its stage's
|
||||||
|
* last inference output so the operator still sees what the stage produced.
|
||||||
|
*/
|
||||||
|
internal fun planArtifacts(events: List<StoredEvent>): List<ArtifactPlan> {
|
||||||
|
val accs = LinkedHashMap<String, ArtifactAcc>()
|
||||||
|
val lastInferenceByStage = HashMap<String, ArtifactId>()
|
||||||
|
for (event in events) {
|
||||||
|
when (val p = event.payload) {
|
||||||
|
is ArtifactCreatedEvent -> accs.getOrPut(p.artifactId.value) { ArtifactAcc() }.apply {
|
||||||
|
stageId = p.stageId.value
|
||||||
|
schemaVersion = p.schemaVersion
|
||||||
|
}
|
||||||
|
is ArtifactValidatingEvent -> accs[p.artifactId.value]?.let { it.phase = "VALIDATING" }
|
||||||
|
is ArtifactValidatedEvent -> accs[p.artifactId.value]?.let { it.phase = "VALIDATED" }
|
||||||
|
is ArtifactContentStoredEvent ->
|
||||||
|
accs.getOrPut(p.artifactId.value) { ArtifactAcc() }.contentHash = p.contentHash
|
||||||
|
is InferenceCompletedEvent -> lastInferenceByStage[p.stageId.value] = p.responseArtifactId
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accs.map { (id, acc) ->
|
||||||
|
ArtifactPlan(
|
||||||
|
artifactId = id,
|
||||||
|
stageId = acc.stageId,
|
||||||
|
schemaVersion = acc.schemaVersion,
|
||||||
|
phase = acc.phase,
|
||||||
|
contentHash = acc.contentHash ?: lastInferenceByStage[acc.stageId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folded artifact: identity/metadata plus the CAS hash to resolve its content from (if any). */
|
||||||
|
internal data class ArtifactPlan(
|
||||||
|
val artifactId: String,
|
||||||
|
val stageId: String,
|
||||||
|
val schemaVersion: Int,
|
||||||
|
val phase: String,
|
||||||
|
val contentHash: ArtifactId?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Mutable accumulator for folding a session's artifact events into one plan per artifact. */
|
||||||
private class ArtifactAcc {
|
private class ArtifactAcc {
|
||||||
var stageId: String = ""
|
var stageId: String = ""
|
||||||
var schemaVersion: Int = 0
|
var schemaVersion: Int = 0
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package com.correx.apps.server.ws
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||||
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class StreamQueriesPlanArtifactsTest {
|
||||||
|
|
||||||
|
private val sessionId = SessionId("s1")
|
||||||
|
private var seq = 0L
|
||||||
|
|
||||||
|
private fun event(payload: EventPayload): StoredEvent = StoredEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
sequence = seq++,
|
||||||
|
sessionSequence = seq,
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Regression: ArtifactContentStored is emitted at inference time, BEFORE ArtifactCreated (which
|
||||||
|
// fires at validation). The fold must still capture the content hash — folding only on creation
|
||||||
|
// dropped it and blanked every artifact in the viewer.
|
||||||
|
@Test
|
||||||
|
fun `captures content hash even when ArtifactContentStored precedes ArtifactCreated`() {
|
||||||
|
val hash = ArtifactId("83ef7566f2d82b5e")
|
||||||
|
val events = listOf(
|
||||||
|
event(ArtifactContentStoredEvent(ArtifactId("analysis"), hash, sessionId, StageId("analyst"))),
|
||||||
|
event(ArtifactCreatedEvent(ArtifactId("analysis"), sessionId, StageId("analyst"), schemaVersion = 1)),
|
||||||
|
event(ArtifactValidatedEvent(ArtifactId("analysis"), sessionId, StageId("analyst"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
val plans = StreamQueries.planArtifacts(events)
|
||||||
|
|
||||||
|
assertEquals(1, plans.size)
|
||||||
|
val plan = plans.single()
|
||||||
|
assertEquals("analysis", plan.artifactId)
|
||||||
|
assertEquals(hash, plan.contentHash)
|
||||||
|
assertEquals("VALIDATED", plan.phase)
|
||||||
|
assertEquals("analyst", plan.stageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user