fabro/lib/components/fabro-workflow/src/artifact.rs
2026-07-23 17:59:34 -04:00

808 lines
27 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use fabro_agent::Sandbox;
use fabro_config::RunScratch;
use fabro_types::{RunBlobId, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref};
use futures::future::BoxFuture;
use serde_json::Value;
use tokio::fs;
use crate::context::{self, Context};
use crate::error::{Error, Result};
use crate::outcome::Outcome;
use crate::records::Checkpoint;
use crate::runtime_store::RunStoreHandle;
/// Threshold above which values are persisted as blobs (100KB).
const BLOB_OFFLOAD_THRESHOLD: usize = 100 * 1024;
/// Prefix used to identify artifact pointer strings in context values.
const ARTIFACT_POINTER_PREFIX: &str = "file://";
/// Offload context values exceeding the blob threshold into the blob store.
///
/// For each entry in `updates` whose serialized JSON exceeds
/// `BLOB_OFFLOAD_THRESHOLD`, the value is persisted as a blob in `run_store`
/// and replaced with a `"blob://sha256/{blob_id}"` reference.
/// Small values are left untouched.
///
/// # Errors
///
/// Returns an error if blob persistence fails.
pub async fn offload_large_values(
updates: &mut HashMap<String, Value>,
run_store: &RunStoreHandle,
) -> Result<()> {
for value in updates.values_mut() {
let bytes = serde_json::to_vec(&*value)
.map_err(|e| Error::engine_with_source("artifact serialize failed", e))?;
if bytes.len() > BLOB_OFFLOAD_THRESHOLD {
let blob_id = run_store
.write_blob(&bytes)
.await
.map_err(|e| Error::engine_with_anyhow("artifact blob write failed", e))?;
*value = Value::String(format_blob_ref(&blob_id));
}
}
Ok(())
}
/// Extract the file path from an artifact pointer value.
///
/// Returns `Some(path)` if the value is a string starting with `"file://"`,
/// `None` otherwise.
#[must_use]
pub fn artifact_path(value: &Value) -> Option<&str> {
value
.as_str()
.and_then(|s| s.strip_prefix(ARTIFACT_POINTER_PREFIX))
}
/// Returns `true` if `path` looks like an artifact pointer path (starts with `"file://"`).
#[must_use]
pub fn is_artifact_pointer(value: &Value) -> bool {
artifact_path(value).is_some()
}
/// Resolve an artifact pointer to the base name displayed in preamble
/// rendering.
///
/// Given `"file:///tmp/logs/runtime/blobs/response.plan.json"`, returns
/// `"See: /tmp/logs/runtime/blobs/response.plan.json"`.
#[must_use]
pub fn format_artifact_reference(path: &str) -> String {
format!("See: {path}")
}
pub fn durable_context_snapshot(context: &Context) -> HashMap<String, Value> {
let mut snapshot = context.snapshot();
strip_transient_keys(&mut snapshot);
normalize_durable_updates(&mut snapshot);
snapshot
}
/// Remove runtime-only keys that must never reach durable storage.
pub(crate) fn strip_transient_keys(values: &mut HashMap<String, Value>) {
for key in context::keys::TRANSIENT_CONTEXT_KEYS {
values.remove(*key);
}
}
pub fn normalize_durable_updates(updates: &mut HashMap<String, Value>) {
for value in updates.values_mut() {
normalize_durable_value(value);
}
}
pub fn normalize_durable_outcomes(node_outcomes: &mut HashMap<String, Outcome>) {
for outcome in node_outcomes.values_mut() {
normalize_durable_updates(&mut outcome.context_updates);
}
}
pub fn normalize_checkpoint_for_resume(checkpoint: &mut Checkpoint) {
strip_transient_keys(&mut checkpoint.context_values);
normalize_durable_updates(&mut checkpoint.context_values);
normalize_durable_outcomes(&mut checkpoint.node_outcomes);
}
pub async fn resolve_context_for_execution(
context: &Context,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
run_dir: &Path,
) -> Result<Context> {
let values = resolved_context_snapshot(context, run_store, env, run_dir).await?;
let resolved = Context::new();
for (key, value) in values {
resolved.set(key, value);
}
Ok(resolved)
}
pub async fn resolve_context_for_edge_selection(
context: &Context,
run_store: &RunStoreHandle,
) -> Result<Context> {
let mut values = context.snapshot();
for key in [context::keys::COMMAND_OUTPUT] {
if let Some(Value::String(current)) = values.get_mut(key) {
*current = resolve_text_or_blob_ref_str(current, run_store).await?;
}
}
Ok(Context::from_values(values))
}
pub async fn resolve_outcomes_for_execution(
node_outcomes: &HashMap<String, Outcome>,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
run_dir: &Path,
) -> Result<HashMap<String, Outcome>> {
let mut resolved = node_outcomes.clone();
for outcome in resolved.values_mut() {
resolve_execution_values(&mut outcome.context_updates, run_store, env, run_dir).await?;
}
Ok(resolved)
}
pub async fn resolved_context_snapshot(
context: &Context,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
run_dir: &Path,
) -> Result<HashMap<String, Value>> {
let mut values = context.snapshot();
resolve_execution_values(&mut values, run_store, env, run_dir).await?;
Ok(values)
}
pub async fn resolve_text_or_blob_ref(value: &Value, run_store: &RunStoreHandle) -> Result<String> {
match value.as_str() {
Some(current) => resolve_text_or_blob_ref_str(current, run_store).await,
None => Ok(value.to_string()),
}
}
pub async fn resolve_text_or_blob_ref_str(
current: &str,
run_store: &RunStoreHandle,
) -> Result<String> {
let Some(blob_id) = parse_blob_ref(current) else {
return Ok(current.to_string());
};
let bytes = run_store
.read_blob(&blob_id)
.await
.map_err(|e| Error::engine_with_anyhow("text blob read failed", e))?
.ok_or_else(|| Error::engine(format!("text blob missing: {blob_id}")))?;
serde_json::from_slice::<String>(&bytes)
.map_err(|e| Error::engine_with_source("text blob was not a JSON string", e))
}
/// Sync artifact files to a remote sandbox.
///
/// For each `file://` pointer in `updates`, checks whether the file is accessible
/// in `env`. If not, reads the local file and uploads it via `env.write_file`,
/// placing it at `{working_directory}/.fabro/artifacts/{filename}`. The pointer
/// is rewritten to reference the remote path.
///
/// # Errors
///
/// Returns an error if reading a local artifact or writing to the remote env
/// fails.
pub async fn sync_artifacts_to_env(
updates: &mut HashMap<String, Value>,
env: &dyn Sandbox,
) -> Result<()> {
for value in updates.values_mut() {
let local_path = match artifact_path(value) {
Some(p) => p.to_string(),
None => continue,
};
match env.file_exists(&local_path).await {
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
return Err(Error::engine_with_source(
"failed to check artifact existence",
e,
));
}
}
let content = fs::read_to_string(&local_path).await.map_err(|e| {
Error::engine_with_source(format!("failed to read local artifact {local_path}"), e)
})?;
let filename = std::path::Path::new(&local_path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("artifact.json");
let remote_path = format!("{}/.fabro/artifacts/{filename}", env.working_directory());
env.write_file(&remote_path, &content)
.await
.map_err(|e| Error::engine_with_source("failed to write artifact to remote env", e))?;
*value = Value::String(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}"));
}
Ok(())
}
fn normalize_durable_value(value: &mut Value) {
match value {
Value::String(current) => {
if let Some(blob_id) = parse_managed_blob_file_ref(current) {
*current = format_blob_ref(&blob_id);
}
}
Value::Array(items) => {
for item in items {
normalize_durable_value(item);
}
}
Value::Object(map) => {
for item in map.values_mut() {
normalize_durable_value(item);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) => {}
}
}
fn resolve_execution_values<'a>(
values: &'a mut HashMap<String, Value>,
run_store: &'a RunStoreHandle,
env: &'a dyn Sandbox,
run_dir: &'a Path,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
for (key, value) in values.iter_mut() {
resolve_execution_value(Some(key.as_str()), value, run_store, env, run_dir).await?;
}
Ok(())
})
}
fn resolve_execution_value<'a>(
key: Option<&'a str>,
value: &'a mut Value,
run_store: &'a RunStoreHandle,
env: &'a dyn Sandbox,
run_dir: &'a Path,
) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
match value {
Value::String(current) => {
if matches!(key, Some(context::keys::COMMAND_OUTPUT)) {
*current = resolve_text_or_blob_ref_str(current, run_store).await?;
} else if let Some(blob_id) = parse_blob_ref(current) {
*current = materialize_blob_ref(&blob_id, run_store, env, run_dir).await?;
} else if current.starts_with(ARTIFACT_POINTER_PREFIX)
&& parse_managed_blob_file_ref(current).is_none()
{
*current = resolve_explicit_file_ref(current, env).await?;
}
}
Value::Array(items) => {
for item in items {
resolve_execution_value(None, item, run_store, env, run_dir).await?;
}
}
Value::Object(map) => {
for item in map.values_mut() {
resolve_execution_value(None, item, run_store, env, run_dir).await?;
}
}
Value::Null | Value::Bool(_) | Value::Number(_) => {}
}
Ok(())
})
}
async fn materialize_blob_ref(
blob_id: &RunBlobId,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
run_dir: &Path,
) -> Result<String> {
let bytes = run_store
.read_blob(blob_id)
.await
.map_err(|e| Error::engine_with_anyhow("artifact blob read failed", e))?
.ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_id}")))?;
if is_local_execution(env, run_dir).await? {
let path = local_materialized_blob_path(run_dir, blob_id);
if !path.exists() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.map_err(|err| {
Error::Io(format!(
"creating artifact blob directory {}: {err}",
parent.display()
))
})?;
}
fs::write(&path, &bytes).await.map_err(|err| {
Error::Io(format!("writing artifact blob {}: {err}", path.display()))
})?;
}
return Ok(format!("{ARTIFACT_POINTER_PREFIX}{}", path.display()));
}
let remote_path = format!("{}/.fabro/blobs/{blob_id}.json", env.working_directory());
if !env
.file_exists(&remote_path)
.await
.map_err(|e| Error::engine_with_source("failed to check blob existence", e))?
{
let content = String::from_utf8(bytes.to_vec())
.map_err(|e| Error::engine_with_source("artifact blob was not valid UTF-8 JSON", e))?;
env.write_file(&remote_path, &content).await.map_err(|e| {
Error::engine_with_source("failed to write artifact blob to sandbox", e)
})?;
}
Ok(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}"))
}
async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result<String> {
let local_path = value
.strip_prefix(ARTIFACT_POINTER_PREFIX)
.ok_or_else(|| Error::engine(format!("invalid artifact pointer: {value}")))?;
if env
.file_exists(local_path)
.await
.map_err(|e| Error::engine_with_source("failed to check artifact existence", e))?
{
return Ok(value.to_string());
}
let content = fs::read_to_string(local_path).await.map_err(|e| {
Error::engine_with_source(format!("failed to read local artifact {local_path}"), e)
})?;
let filename = Path::new(local_path)
.file_name()
.and_then(|file| file.to_str())
.unwrap_or("artifact.json");
let remote_path = format!("{}/.fabro/artifacts/{filename}", env.working_directory());
if !env
.file_exists(&remote_path)
.await
.map_err(|e| Error::engine_with_source("failed to check artifact existence", e))?
{
env.write_file(&remote_path, &content)
.await
.map_err(|e| Error::engine_with_source("failed to write artifact to remote env", e))?;
}
Ok(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}"))
}
async fn is_local_execution(env: &dyn Sandbox, run_dir: &Path) -> Result<bool> {
env.file_exists(&run_dir.to_string_lossy())
.await
.map_err(|e| Error::engine_with_source("failed to inspect sandbox locality", e))
}
fn local_materialized_blob_path(run_dir: &Path, blob_id: &RunBlobId) -> PathBuf {
RunScratch::new(run_dir)
.runtime_dir()
.join("blobs")
.join(format!("{blob_id}.json"))
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "tests write artifact fixtures to disk"
)]
mod tests {
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use std::time::Duration;
use fabro_store::Database;
use object_store::memory::InMemory;
use ulid::Ulid;
use super::*;
fn test_run_id(label: &str) -> fabro_types::RunId {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
label.hash(&mut hasher);
fabro_types::RunId::from(Ulid(u128::from(hasher.finish())))
}
async fn make_run_store(label: &str) -> fabro_store::RunDatabase {
let object_store = Arc::new(InMemory::new());
let store = Database::new(object_store, "runs/", Duration::from_millis(1), None);
store.create_run(&test_run_id(label)).await.unwrap()
}
#[tokio::test]
async fn offload_replaces_large_values_with_blob_backed_pointer() {
let run_store = make_run_store("artifact-offload").await;
let large_string = "x".repeat(BLOB_OFFLOAD_THRESHOLD + 1);
let serialized = serde_json::to_vec(&serde_json::json!(large_string.clone())).unwrap();
let expected_blob_id = fabro_types::RunBlobId::new(&serialized);
let mut updates = HashMap::new();
updates.insert("response.plan".to_string(), serde_json::json!(large_string));
offload_large_values(&mut updates, &run_store.clone().into())
.await
.unwrap();
let pointer = updates.get("response.plan").unwrap();
assert_eq!(
pointer,
&serde_json::json!(fabro_types::format_blob_ref(&expected_blob_id))
);
let blob = run_store
.read_blob(&expected_blob_id)
.await
.unwrap()
.expect("blob should exist");
let blob_value: serde_json::Value = serde_json::from_slice(&blob).unwrap();
assert_eq!(blob_value, serde_json::json!(large_string));
}
#[tokio::test]
async fn offload_leaves_small_values_untouched() {
let run_store = make_run_store("artifact-small").await;
let small_value = serde_json::json!("hello world");
let mut updates = HashMap::new();
updates.insert("small_key".to_string(), small_value.clone());
offload_large_values(&mut updates, &run_store.clone().into())
.await
.unwrap();
assert_eq!(updates.get("small_key").unwrap(), &small_value);
}
#[test]
fn artifact_path_extracts_path_from_pointer() {
let value = serde_json::json!("file:///tmp/logs/runtime/blobs/response.plan.json");
assert_eq!(
artifact_path(&value),
Some("/tmp/logs/runtime/blobs/response.plan.json")
);
}
#[test]
fn artifact_path_returns_none_for_plain_string() {
let value = serde_json::json!("just a normal string");
assert_eq!(artifact_path(&value), None);
}
#[test]
fn artifact_path_returns_none_for_non_string() {
let value = serde_json::json!(42);
assert_eq!(artifact_path(&value), None);
}
#[test]
fn normalize_durable_updates_rewrites_managed_blob_file_refs_recursively() {
let blob_id = fabro_types::RunBlobId::new(b"hello");
let mut updates = HashMap::from([(
"nested".to_string(),
serde_json::json!({
"items": [
format!("file:///tmp/run/runtime/blobs/{blob_id}.json"),
format!("file:///sandbox/.fabro/blobs/{blob_id}.json"),
"file:///tmp/report.json",
]
}),
)]);
normalize_durable_updates(&mut updates);
assert_eq!(
updates["nested"],
serde_json::json!({
"items": [
fabro_types::format_blob_ref(&blob_id),
fabro_types::format_blob_ref(&blob_id),
"file:///tmp/report.json",
]
})
);
}
#[test]
fn durable_context_snapshot_drops_parallel_branch_preambles() {
let context = Context::new();
context.set(
context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES,
serde_json::json!({"branch-a": "runtime only"}),
);
context.set("response.work", serde_json::json!("durable"));
let snapshot = durable_context_snapshot(&context);
assert!(!snapshot.contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES));
assert_eq!(
snapshot.get("response.work"),
Some(&serde_json::json!("durable"))
);
}
#[test]
fn normalize_checkpoint_for_resume_drops_parallel_branch_preambles() {
let mut checkpoint = crate::records::Checkpoint {
timestamp: chrono::Utc::now(),
current_node: "work".to_string(),
completed_nodes: vec!["work".to_string()],
node_retries: HashMap::new(),
context_values: HashMap::from([
(
context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES.to_string(),
serde_json::json!({"branch-a": "runtime only"}),
),
("response.work".to_string(), serde_json::json!("durable")),
]),
node_outcomes: HashMap::new(),
next_node_id: Some("exit".to_string()),
git_commit_sha: None,
loop_failure_signatures: HashMap::new(),
restart_failure_signatures: HashMap::new(),
node_visits: HashMap::new(),
};
normalize_checkpoint_for_resume(&mut checkpoint);
assert!(
!checkpoint
.context_values
.contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES)
);
assert_eq!(
checkpoint.context_values.get("response.work"),
Some(&serde_json::json!("durable"))
);
}
#[test]
fn normalize_checkpoint_for_resume_converts_managed_blob_file_refs_and_drops_preamble() {
let blob_id = fabro_types::RunBlobId::new(b"managed");
let mut checkpoint = crate::records::Checkpoint {
timestamp: chrono::Utc::now(),
current_node: "work".to_string(),
completed_nodes: vec!["work".to_string()],
node_retries: HashMap::new(),
context_values: HashMap::from([
(
crate::context::keys::CURRENT_PREAMBLE.to_string(),
serde_json::json!("runtime only"),
),
(
"response.work".to_string(),
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
),
]),
node_outcomes: HashMap::from([(
"work".to_string(),
crate::outcome::Outcome {
context_updates: HashMap::from([(
"response.work".to_string(),
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
)]),
..crate::outcome::Outcome::success()
},
)]),
next_node_id: Some("exit".to_string()),
git_commit_sha: None,
loop_failure_signatures: HashMap::new(),
restart_failure_signatures: HashMap::new(),
node_visits: HashMap::new(),
};
normalize_checkpoint_for_resume(&mut checkpoint);
assert!(
!checkpoint
.context_values
.contains_key(crate::context::keys::CURRENT_PREAMBLE)
);
assert_eq!(
checkpoint.context_values.get("response.work"),
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_id)))
);
assert_eq!(
checkpoint
.node_outcomes
.get("work")
.and_then(|outcome| outcome.context_updates.get("response.work")),
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_id)))
);
}
// --- sync_artifacts_to_env tests ---
use std::sync::Mutex;
struct TestSyncEnv {
accessible: bool,
written: Mutex<Vec<(String, String)>>,
working_dir: String,
}
impl TestSyncEnv {
fn new(accessible: bool, working_dir: &str) -> Self {
Self {
accessible,
written: Mutex::new(Vec::new()),
working_dir: working_dir.to_string(),
}
}
}
#[async_trait::async_trait]
impl Sandbox for TestSyncEnv {
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
Err("not implemented".into())
}
async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> {
self.written
.lock()
.unwrap()
.push((path.to_string(), content.to_string()));
Ok(())
}
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
Ok(self.accessible)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
Err("not implemented".into())
}
async fn exec_command(
&self,
_command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> fabro_sandbox::Result<fabro_agent::ExecResult> {
Err("not implemented".into())
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &fabro_agent::GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn download_file_to_local(
&self,
_remote_path: &str,
_local_path: &std::path::Path,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {
&self.working_dir
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Linux 5.15".to_string()
}
}
#[tokio::test]
async fn sync_uploads_artifact_when_not_accessible() {
let dir = tempfile::tempdir().unwrap();
let artifact_file = dir.path().join("response.plan.json");
std::fs::write(&artifact_file, r#""hello from artifact""#).unwrap();
let pointer = format!("file://{}", artifact_file.display());
let mut updates = HashMap::new();
updates.insert("response.plan".to_string(), Value::String(pointer));
let env = TestSyncEnv::new(false, "/workspace");
sync_artifacts_to_env(&mut updates, &env).await.unwrap();
let written = env.written.lock().unwrap();
assert_eq!(written.len(), 1);
assert_eq!(
written[0].0,
"/workspace/.fabro/artifacts/response.plan.json"
);
assert_eq!(written[0].1, r#""hello from artifact""#);
let new_pointer = updates["response.plan"].as_str().unwrap();
assert_eq!(
new_pointer,
"file:///workspace/.fabro/artifacts/response.plan.json"
);
}
#[tokio::test]
async fn sync_skips_when_artifact_already_accessible() {
let dir = tempfile::tempdir().unwrap();
let artifact_file = dir.path().join("data.json");
std::fs::write(&artifact_file, "{}").unwrap();
let pointer = format!("file://{}", artifact_file.display());
let mut updates = HashMap::new();
updates.insert("key".to_string(), Value::String(pointer.clone()));
let env = TestSyncEnv::new(true, "/workspace");
sync_artifacts_to_env(&mut updates, &env).await.unwrap();
let written = env.written.lock().unwrap();
assert!(written.is_empty());
assert_eq!(updates["key"].as_str().unwrap(), &pointer);
}
#[tokio::test]
async fn sync_ignores_non_artifact_values() {
let mut updates = HashMap::new();
updates.insert("name".to_string(), serde_json::json!("Alice"));
updates.insert("count".to_string(), serde_json::json!(42));
updates.insert("nested".to_string(), serde_json::json!({"a": 1}));
let env = TestSyncEnv::new(false, "/workspace");
sync_artifacts_to_env(&mut updates, &env).await.unwrap();
let written = env.written.lock().unwrap();
assert!(written.is_empty());
assert_eq!(updates["name"], serde_json::json!("Alice"));
assert_eq!(updates["count"], serde_json::json!(42));
assert_eq!(updates["nested"], serde_json::json!({"a": 1}));
}
}