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:
@@ -16,10 +16,15 @@ import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
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
|
||||
* 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)
|
||||
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. */
|
||||
@@ -89,6 +100,36 @@ class StreamQueries(private val module: ServerModule) {
|
||||
}
|
||||
|
||||
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
|
||||
* CAS read — so it is unit-testable. [ArtifactContentStoredEvent] is emitted at inference
|
||||
|
||||
+34
@@ -10,7 +10,9 @@ 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.coroutines.runBlocking
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
@@ -55,4 +57,36 @@ class StreamQueriesPlanArtifactsTest {
|
||||
assertEquals("VALIDATED", plan.phase)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user