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 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
|
||||||
|
|||||||
+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.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user