fix(server): artifact viewer falls back to raw stage output when no content hash

Artifacts with no ArtifactContentStored mapping (older sessions, or stages where the
slot->hash link was lost) showed a blank pane. listArtifacts now falls back to the
stage's last InferenceCompleted output so the operator can still see what the stage
produced. Empty-string content is normalised to null so the viewer shows a clear
'(no content stored)' instead of a blank pane.

Verified: role_pipeline sessions already resolve content from CAS (index + segments
intact, store rooted at ~/.config/correx/artifacts); the previously-blank sessions are
healthcheck/degenerate runs whose inference output was genuinely empty.
This commit is contained in:
2026-06-10 12:35:07 +04:00
parent 3c5c2999d3
commit 90ba7244d5
@@ -10,6 +10,7 @@ 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.ArtifactValidatingEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.Dispatchers
@@ -31,6 +32,11 @@ class StreamQueries(private val module: ServerModule) {
suspend fun listArtifacts(sessionId: SessionId): ServerMessage.ArtifactList {
val events = withContext(Dispatchers.IO) { module.eventStore.read(sessionId) }
val accs = LinkedHashMap<String, ArtifactAcc>()
// Per-stage raw inference output (last response), used as a content fallback for artifacts
// 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 {
@@ -40,19 +46,21 @@ class StreamQueries(private val module: ServerModule) {
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 content = acc.contentHash?.let { hash ->
withContext(Dispatchers.IO) { module.artifactStore.get(hash) }?.toString(Charsets.UTF_8)
val hash = acc.contentHash ?: lastInferenceByStage[acc.stageId]
val content = hash?.let {
withContext(Dispatchers.IO) { module.artifactStore.get(it) }?.toString(Charsets.UTF_8)
}
ArtifactSummaryDto(
artifactId = id,
stageId = acc.stageId,
schemaVersion = acc.schemaVersion,
phase = acc.phase,
content = content,
content = content?.takeIf { it.isNotBlank() },
)
}
return ServerMessage.ArtifactList(sessionId = sessionId, artifacts = artifacts)