fix(server): artifact viewer showed FileWrittenArtifact envelope, not file content

file_written artifacts store a JSON envelope ({path, contentHash, size, mode})
whose contentHash points at the real bytes in CAS. The viewer rendered the
envelope itself. StreamQueries now detects the envelope shape (string "path" +
"contentHash") and dereferences the inner hash, falling back to the raw string
when parsing fails or the inner hash is missing (WARN logged — likely evicted).
This commit is contained in:
2026-06-10 20:54:29 +04:00
parent 883e23dec7
commit cba9dd4583
2 changed files with 76 additions and 1 deletions
@@ -16,10 +16,15 @@ 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
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger("StreamQueries") private val log = LoggerFactory.getLogger("StreamQueries")
private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true }
/** /**
* Read-only / snapshot queries served over the global stream: artifact listings and config * Read-only / snapshot queries served over the global stream: artifact listings and config
* get/update. Kept separate from [GlobalStreamHandler] (which owns the connection lifecycle and * get/update. Kept separate from [GlobalStreamHandler] (which owns the connection lifecycle and
@@ -64,7 +69,13 @@ class StreamQueries(private val module: ServerModule) {
log.warn("artifact content missing for referenced hash={} (evicted/compacted?)", hash.value) log.warn("artifact content missing for referenced hash={} (evicted/compacted?)", hash.value)
return null return null
} }
return bytes.toString(Charsets.UTF_8).takeIf { it.isNotBlank() } val rawString = bytes.toString(Charsets.UTF_8).takeIf { it.isNotBlank() } ?: return null
return dereferenceEnvelope(lenientJson, rawString) { innerHash ->
runCatching { withContext(Dispatchers.IO) { module.artifactStore.get(innerHash) } }
.getOrNull()
?.toString(Charsets.UTF_8)
?.takeIf { it.isNotBlank() }
} ?: rawString
} }
/** 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. */
@@ -89,6 +100,36 @@ class StreamQueries(private val module: ServerModule) {
} }
companion object { companion object {
/**
* If [rawContent] is a `FileWrittenArtifact` envelope (JSON object with both "path" and
* "contentHash" string fields), resolves the real content via [lookup] on the inner hash.
* Returns null when [rawContent] is not an envelope or [lookup] returns null (caller falls
* back to [rawContent]). Non-throwing — JSON parse failure silently returns null.
*/
internal suspend fun dereferenceEnvelope(
json: Json,
rawContent: String,
lookup: suspend (ArtifactId) -> String?,
): String? {
val obj = runCatching {
json.parseToJsonElement(rawContent).jsonObject
}.getOrNull() ?: return null
val innerHash = (obj["contentHash"] as? JsonPrimitive)?.takeIf { it.isString }?.content
?.takeIf { it.isNotBlank() } ?: return null
val path = (obj["path"] as? JsonPrimitive)?.takeIf { it.isString }?.content
if (path == null) return null
val resolved = lookup(ArtifactId(innerHash))
if (resolved == null) {
log.warn(
"FileWrittenArtifact envelope references missing inner hash={} (evicted?)",
innerHash,
)
}
return resolved
}
/** /**
* Folds a session's events into one plan per artifact (creation order preserved). Pure — no * 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 * CAS read — so it is unit-testable. [ArtifactContentStoredEvent] is emitted at inference
@@ -10,7 +10,9 @@ import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.util.UUID import java.util.UUID
@@ -55,4 +57,36 @@ class StreamQueriesPlanArtifactsTest {
assertEquals("VALIDATED", plan.phase) assertEquals("VALIDATED", plan.phase)
assertEquals("analyst", plan.stageId) assertEquals("analyst", plan.stageId)
} }
@Test
fun `dereferenceEnvelope returns inner content when envelope is valid`(): Unit = runBlocking {
val innerHash = ArtifactId("abc123")
val envelope = """{"path":"/src/Foo.kt","contentHash":"abc123","size":42,"mode":"0644"}"""
val innerContent = "fun foo() = 42"
val result = StreamQueries.dereferenceEnvelope(
json = Json { ignoreUnknownKeys = true; isLenient = true },
rawContent = envelope,
) { id -> if (id == innerHash) innerContent else null }
assertEquals(innerContent, result)
}
@Test
fun `dereferenceEnvelope returns null when inner hash is missing from CAS`(): Unit = runBlocking {
val envelope = """{"path":"/src/Foo.kt","contentHash":"missing","size":42,"mode":"0644"}"""
val result = StreamQueries.dereferenceEnvelope(
json = Json { ignoreUnknownKeys = true; isLenient = true },
rawContent = envelope,
) { null }
assertEquals(null, result)
}
@Test
fun `dereferenceEnvelope returns null when path is a nested object`(): Unit = runBlocking {
val envelope = """{"path":{"nested":"object"},"contentHash":"abc123"}"""
val result = StreamQueries.dereferenceEnvelope(
json = Json { ignoreUnknownKeys = true; isLenient = true },
rawContent = envelope,
) { id -> if (id.value == "abc123") "content" else null }
assertEquals(null, result)
}
} }