Skip to main content

harn_vm/orchestration/
pipeline_lifecycle.rs

1//! Pipeline-finish lifecycle state.
2//!
3//! The pipeline DSL accepts a single `on_finish` callback that runs after the
4//! pipeline's declared steps complete but before the pipeline returns. The
5//! callback receives `(harness, return_value)` and may transform the value.
6//! Storage is a thread-local one-shot slot: `Vm::execute` consumes the
7//! registered closure with `take_pipeline_on_finish` exactly once, so a stale
8//! registration cannot leak across consecutive runs.
9//!
10//! `unsettled_state_snapshot` exposes the pipeline-finish harness view of
11//! work that can outlive the main pipeline body.
12//!
13//! Beyond the snapshot, drain callbacks need two write-side surfaces:
14//! `record_lifecycle_audit` (which `harness.emit_audit` routes to) and
15//! `record_partial_handoff` (which `harness.handoff_to` routes to). Both are
16//! thread-local because pipeline execution is single-threaded per run and we
17//! want deterministic ordering for replay/conformance.
18
19use std::cell::RefCell;
20use std::collections::{BTreeMap, BTreeSet};
21use std::sync::Arc;
22
23use serde_json::Value;
24
25use crate::event_log::{EventLog, LogEvent, Topic};
26use crate::value::VmClosure;
27
28pub const LIFECYCLE_AUDIT_TOPIC: &str = "pipeline.lifecycle.audit";
29
30thread_local! {
31    static PIPELINE_ON_FINISH: RefCell<Option<Arc<VmClosure>>> = const { RefCell::new(None) };
32    static LIFECYCLE_AUDIT_LOG: RefCell<Vec<LifecycleAuditEntry>> = const { RefCell::new(Vec::new()) };
33    static PARTIAL_HANDOFF_REGISTRY: RefCell<Vec<PartialHandoffEnvelope>> = const { RefCell::new(Vec::new()) };
34    static PIPELINE_DISPOSITION: RefCell<Option<Value>> = const { RefCell::new(None) };
35    static LIFECYCLE_SEQ: RefCell<u64> = const { RefCell::new(0) };
36}
37
38#[derive(Clone, Default)]
39struct PipelineLifecycleState {
40    on_finish: Option<Arc<VmClosure>>,
41    audit_log: Vec<LifecycleAuditEntry>,
42    partial_handoffs: Vec<PartialHandoffEnvelope>,
43    disposition: Option<Value>,
44    seq: u64,
45}
46
47fn swap_pipeline_lifecycle_state(state: PipelineLifecycleState) -> PipelineLifecycleState {
48    PipelineLifecycleState {
49        on_finish: PIPELINE_ON_FINISH
50            .with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.on_finish)),
51        audit_log: LIFECYCLE_AUDIT_LOG
52            .with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.audit_log)),
53        partial_handoffs: PARTIAL_HANDOFF_REGISTRY
54            .with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.partial_handoffs)),
55        disposition: PIPELINE_DISPOSITION
56            .with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.disposition)),
57        seq: LIFECYCLE_SEQ.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.seq)),
58    }
59}
60
61/// Roll back pipeline-lifecycle state if the owning future is cancelled.
62///
63/// Construction leaves the current state untouched. Reaching `complete`
64/// commits whatever the execution did; dropping first replaces it with the
65/// exact pre-execution snapshot.
66pub(crate) fn checkpoint_pipeline_lifecycle() -> PipelineLifecycleCheckpoint {
67    let current = swap_pipeline_lifecycle_state(PipelineLifecycleState::default());
68    let outer = current.clone();
69    let _ = swap_pipeline_lifecycle_state(current);
70    PipelineLifecycleCheckpoint { outer: Some(outer) }
71}
72
73pub(crate) struct PipelineLifecycleCheckpoint {
74    outer: Option<PipelineLifecycleState>,
75}
76
77impl PipelineLifecycleCheckpoint {
78    pub(crate) fn complete(mut self) {
79        self.outer = None;
80    }
81}
82
83impl Drop for PipelineLifecycleCheckpoint {
84    fn drop(&mut self) {
85        if let Some(outer) = self.outer.take() {
86            let _ = swap_pipeline_lifecycle_state(outer);
87        }
88    }
89}
90
91/// Register the callback `Vm::execute` will invoke after the pipeline's
92/// declared steps complete. Last-write-wins.
93pub fn set_pipeline_on_finish(callback: Arc<VmClosure>) {
94    PIPELINE_ON_FINISH.with(|slot| *slot.borrow_mut() = Some(callback));
95}
96
97/// Consume the pending callback, leaving the slot empty. Returns `None` when
98/// no callback was registered.
99pub fn take_pipeline_on_finish() -> Option<Arc<VmClosure>> {
100    PIPELINE_ON_FINISH.with(|slot| slot.borrow_mut().take())
101}
102
103/// Drop any pending callback and every captured lifecycle audit entry,
104/// partial-handoff envelope, and seq counter. Called from
105/// `reset_thread_local_state` so test harnesses don't carry registrations
106/// across runs, and from `Vm::execute` on the error exit path so a
107/// failed pipeline doesn't leak in-progress lifecycle state into the
108/// next run.
109pub fn clear_pipeline_on_finish() {
110    PIPELINE_ON_FINISH.with(|slot| *slot.borrow_mut() = None);
111    LIFECYCLE_AUDIT_LOG.with(|log| log.borrow_mut().clear());
112    PARTIAL_HANDOFF_REGISTRY.with(|reg| reg.borrow_mut().clear());
113    PIPELINE_DISPOSITION.with(|slot| *slot.borrow_mut() = None);
114    LIFECYCLE_SEQ.with(|seq| *seq.borrow_mut() = 0);
115}
116
117/// Snapshot of unsettled work that the pipeline `on_finish` harness exposes.
118///
119/// Buckets intentionally stay JSON-shaped at this boundary: each producer
120/// owns its richer Rust types, while callbacks need a stable Harn dict/list
121/// contract. Producers without a durable per-item registry yet return a typed
122/// empty list rather than inventing storage in the lifecycle layer.
123#[derive(Debug, Default, Clone)]
124pub struct UnsettledStateSnapshot {
125    pub suspended_subagents: Vec<Value>,
126    pub queued_triggers: Vec<Value>,
127    pub partial_handoffs: Vec<Value>,
128    pub in_flight_llm_calls: Vec<Value>,
129    pub pool_pending_tasks: Vec<Value>,
130}
131
132impl UnsettledStateSnapshot {
133    pub fn is_empty(&self) -> bool {
134        self.suspended_subagents.is_empty()
135            && self.queued_triggers.is_empty()
136            && self.partial_handoffs.is_empty()
137            && self.in_flight_llm_calls.is_empty()
138            && self.pool_pending_tasks.is_empty()
139    }
140
141    pub fn to_json(&self) -> Value {
142        serde_json::json!({
143            "suspended_subagents": self.suspended_subagents,
144            "queued_triggers": self.queued_triggers,
145            "partial_handoffs": self.partial_handoffs,
146            "in_flight_llm_calls": self.in_flight_llm_calls,
147            "pool_pending_tasks": self.pool_pending_tasks,
148        })
149    }
150
151    pub fn counts_json(&self) -> Value {
152        serde_json::json!({
153            "suspended": self.suspended_subagents.len(),
154            "queued": self.queued_triggers.len(),
155            "partial": self.partial_handoffs.len(),
156            "in_flight": self.in_flight_llm_calls.len(),
157            "pool_pending": self.pool_pending_tasks.len(),
158        })
159    }
160
161    pub fn summary(&self) -> String {
162        let suspended = self.suspended_subagents.len();
163        let queued = self.queued_triggers.len();
164        let partial = self.partial_handoffs.len();
165        let in_flight = self.in_flight_llm_calls.len();
166        let pool_pending = self.pool_pending_tasks.len();
167        if suspended == 0 && queued == 0 && partial == 0 && in_flight == 0 && pool_pending == 0 {
168            "no unsettled work".to_string()
169        } else {
170            format!(
171                "unsettled work: {suspended} suspended subagents, {queued} queued triggers, {partial} partial handoffs, {in_flight} in-flight llm calls, {pool_pending} pool pending tasks"
172            )
173        }
174    }
175}
176
177/// Return the current unsettled-state snapshot. This synchronous variant only
178/// reads in-memory registries; VM lifecycle code should prefer
179/// `unsettled_state_snapshot_async` so event-log-backed trigger queues are
180/// included when available.
181pub fn unsettled_state_snapshot() -> UnsettledStateSnapshot {
182    unsettled_state_snapshot_base(Vec::new())
183}
184
185/// Return the current unsettled-state snapshot, including event-log-backed
186/// trigger queues when an active event log is installed for this thread.
187pub async fn unsettled_state_snapshot_async() -> UnsettledStateSnapshot {
188    unsettled_state_snapshot_base(queued_trigger_snapshot_json().await)
189}
190
191fn unsettled_state_snapshot_base(queued_triggers: Vec<Value>) -> UnsettledStateSnapshot {
192    UnsettledStateSnapshot {
193        suspended_subagents: crate::stdlib::agents::snapshot_suspended_subagents(),
194        queued_triggers,
195        partial_handoffs: partial_handoff_snapshot_json(),
196        in_flight_llm_calls: crate::llm::snapshot_in_flight_llm_calls(),
197        pool_pending_tasks: crate::stdlib::pool::snapshot_pending_tasks(),
198    }
199}
200
201/// One recorded `harness.emit_audit` call. `seq` is a per-pipeline-run
202/// monotonic counter so conformance fixtures and replay can match entries by
203/// shape rather than wall-clock time.
204#[derive(Debug, Clone)]
205pub struct LifecycleAuditEntry {
206    pub seq: u64,
207    pub kind: String,
208    pub payload: Value,
209    pub pipeline_id: Option<String>,
210}
211
212impl LifecycleAuditEntry {
213    pub fn to_json(&self) -> Value {
214        serde_json::json!({
215            "seq": self.seq,
216            "kind": self.kind,
217            "payload": self.payload,
218            "pipeline_id": self.pipeline_id,
219        })
220    }
221}
222
223/// Record an audit entry from a lifecycle callback. Returns the entry's seq
224/// number so the caller can echo it back as a receipt.
225pub fn record_lifecycle_audit(kind: impl Into<String>, payload: Value) -> LifecycleAuditEntry {
226    let entry = LifecycleAuditEntry {
227        seq: next_seq(),
228        kind: kind.into(),
229        payload,
230        pipeline_id: crate::orchestration::current_mutation_session()
231            .and_then(|session| session.run_id.or(Some(session.session_id))),
232    };
233    LIFECYCLE_AUDIT_LOG.with(|log| log.borrow_mut().push(entry.clone()));
234    persist_lifecycle_audit_entry(&entry);
235    entry
236}
237
238/// Drain the audit log, returning all entries recorded since the last drain.
239/// Used by conformance fixtures and replay oracles.
240pub fn take_lifecycle_audit_log() -> Vec<LifecycleAuditEntry> {
241    LIFECYCLE_AUDIT_LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
242}
243
244/// Non-destructive read of the audit log for introspection.
245pub fn lifecycle_audit_log_snapshot() -> Vec<LifecycleAuditEntry> {
246    LIFECYCLE_AUDIT_LOG.with(|log| log.borrow().clone())
247}
248
249/// One recorded `harness.handoff_to` call. The runtime stores envelopes in
250/// the thread-local registry so a follow-on pipeline (or the conformance
251/// suite) can inspect them via `unsettled_state_snapshot()`.
252#[derive(Debug, Clone)]
253pub struct PartialHandoffEnvelope {
254    pub envelope_id: String,
255    pub target_pipeline: String,
256    pub origin_pipeline: Option<String>,
257    pub payload: Value,
258    pub seq: u64,
259    pub queued_at_ms: i64,
260}
261
262impl PartialHandoffEnvelope {
263    pub fn to_json(&self) -> Value {
264        self.to_json_at(crate::stdlib::clock::now_wall_ms())
265    }
266
267    pub fn to_json_at(&self, now_ms: i64) -> Value {
268        serde_json::json!({
269            "envelope_id": self.envelope_id,
270            "from": self.origin_pipeline,
271            "to": self.target_pipeline,
272            "payload_summary": payload_summary(&self.payload),
273            "queued_at_ms": self.queued_at_ms,
274            "age_ms": now_ms.saturating_sub(self.queued_at_ms).max(0),
275            "target_pipeline": self.target_pipeline,
276            "origin_pipeline": self.origin_pipeline,
277            "payload": self.payload,
278            "seq": self.seq,
279        })
280    }
281}
282
283/// Record a partial-handoff envelope and return the entry. Allocates a
284/// deterministic envelope id derived from the lifecycle seq counter so test
285/// fixtures don't depend on wall-clock time or uuids.
286pub fn record_partial_handoff(
287    target_pipeline: impl Into<String>,
288    payload: Value,
289) -> PartialHandoffEnvelope {
290    let seq = next_seq();
291    let envelope = PartialHandoffEnvelope {
292        envelope_id: format!("envelope_{seq}"),
293        target_pipeline: target_pipeline.into(),
294        origin_pipeline: crate::orchestration::current_mutation_session()
295            .and_then(|session| session.run_id.or(Some(session.session_id))),
296        payload,
297        seq,
298        queued_at_ms: crate::stdlib::clock::now_wall_ms(),
299    };
300    PARTIAL_HANDOFF_REGISTRY.with(|reg| reg.borrow_mut().push(envelope.clone()));
301    envelope
302}
303
304/// Acknowledge a partial handoff envelope by removing it from the unsettled
305/// registry and recording the settlement decision in the lifecycle audit log.
306pub fn acknowledge_partial_handoff(
307    envelope_id: &str,
308    decision: Value,
309) -> Option<PartialHandoffEnvelope> {
310    let removed = PARTIAL_HANDOFF_REGISTRY.with(|reg| {
311        let mut reg = reg.borrow_mut();
312        let index = reg
313            .iter()
314            .position(|entry| entry.envelope_id == envelope_id)?;
315        Some(reg.remove(index))
316    })?;
317    record_lifecycle_audit(
318        "handoff_acknowledged",
319        serde_json::json!({
320            "envelope_id": envelope_id,
321            "decision": decision,
322        }),
323    );
324    Some(removed)
325}
326
327pub fn finalize_pipeline_disposition(disposition: Value) -> Value {
328    PIPELINE_DISPOSITION.with(|slot| *slot.borrow_mut() = Some(disposition.clone()));
329    let entry = record_lifecycle_audit(
330        "pipeline_finalized",
331        serde_json::json!({
332            "disposition": disposition,
333        }),
334    );
335    serde_json::json!({
336        "status": "finalized",
337        "method": "finalize",
338        "entry": entry.to_json(),
339    })
340}
341
342pub fn pipeline_disposition_snapshot() -> Option<Value> {
343    PIPELINE_DISPOSITION.with(|slot| slot.borrow().clone())
344}
345
346fn partial_handoff_snapshot_json() -> Vec<Value> {
347    let now_ms = crate::stdlib::clock::now_wall_ms();
348    PARTIAL_HANDOFF_REGISTRY.with(|reg| reg.borrow().iter().map(|e| e.to_json_at(now_ms)).collect())
349}
350
351fn next_seq() -> u64 {
352    LIFECYCLE_SEQ.with(|seq| {
353        let mut slot = seq.borrow_mut();
354        *slot += 1;
355        *slot
356    })
357}
358
359fn persist_lifecycle_audit_entry(entry: &LifecycleAuditEntry) {
360    let Some(log) = crate::event_log::active_event_log() else {
361        return;
362    };
363    let Ok(topic) = Topic::new(LIFECYCLE_AUDIT_TOPIC) else {
364        return;
365    };
366    let mut headers = BTreeMap::new();
367    headers.insert("kind".to_string(), entry.kind.clone());
368    headers.insert("seq".to_string(), entry.seq.to_string());
369    if let Some(pipeline_id) = entry.pipeline_id.as_ref() {
370        headers.insert("pipeline_id".to_string(), pipeline_id.clone());
371    }
372    let _ = futures::executor::block_on(log.append(
373        &topic,
374        LogEvent::new("lifecycle_audit", entry.to_json()).with_headers(headers),
375    ));
376}
377
378async fn queued_trigger_snapshot_json() -> Vec<Value> {
379    let Some(log) = crate::event_log::active_event_log() else {
380        return Vec::new();
381    };
382    let now_ms = lifecycle_now_ms();
383    let mut out = Vec::new();
384    out.extend(snapshot_inbox_triggers(log.as_ref(), now_ms).await);
385    out.extend(snapshot_worker_queue_triggers(log, now_ms).await);
386    out.sort_by(|left, right| {
387        let left_key = (
388            left.get("queued_at_ms")
389                .and_then(Value::as_i64)
390                .unwrap_or(i64::MAX),
391            left.get("id").and_then(Value::as_str).unwrap_or_default(),
392        );
393        let right_key = (
394            right
395                .get("queued_at_ms")
396                .and_then(Value::as_i64)
397                .unwrap_or(i64::MAX),
398            right.get("id").and_then(Value::as_str).unwrap_or_default(),
399        );
400        left_key.cmp(&right_key)
401    });
402    out
403}
404
405fn lifecycle_now_ms() -> i64 {
406    crate::clock_mock::now_ms()
407}
408
409async fn snapshot_inbox_triggers(log: &crate::event_log::AnyEventLog, now_ms: i64) -> Vec<Value> {
410    let Ok(inbox_topic) = Topic::new(crate::triggers::TRIGGER_INBOX_ENVELOPES_TOPIC) else {
411        return Vec::new();
412    };
413    let Ok(outbox_topic) = Topic::new(crate::triggers::TRIGGER_OUTBOX_TOPIC) else {
414        return Vec::new();
415    };
416    let Ok(cancel_topic) = Topic::new(crate::triggers::TRIGGER_CANCEL_REQUESTS_TOPIC) else {
417        return Vec::new();
418    };
419    let inbox = log
420        .read_range(&inbox_topic, None, usize::MAX)
421        .await
422        .unwrap_or_default();
423    let outbox = log
424        .read_range(&outbox_topic, None, usize::MAX)
425        .await
426        .unwrap_or_default();
427    let cancels = log
428        .read_range(&cancel_topic, None, usize::MAX)
429        .await
430        .unwrap_or_default();
431
432    let completed_events = outbox
433        .into_iter()
434        .filter_map(|(_, event)| {
435            let event_id = event
436                .headers
437                .get("event_id")
438                .cloned()
439                .or_else(|| json_string(&event.payload, &["event_id"]))?;
440            let binding_key = event
441                .headers
442                .get("binding_key")
443                .cloned()
444                .or_else(|| json_string(&event.payload, &["binding_key"]))
445                .unwrap_or_default();
446            Some((binding_key, event_id))
447        })
448        .collect::<BTreeSet<_>>();
449    let cancelled_events = cancels
450        .into_iter()
451        .filter(|(_, event)| event.kind == "dispatch_cancel_requested")
452        .filter_map(|(_, event)| {
453            let event_id = json_string(&event.payload, &["event_id"])?;
454            let binding_key = json_string(&event.payload, &["binding_key"]).unwrap_or_default();
455            Some((binding_key, event_id))
456        })
457        .collect::<BTreeSet<_>>();
458
459    inbox
460        .into_iter()
461        .filter(|(_, event)| event.kind == "event_ingested")
462        .filter_map(|(_, event)| {
463            let trigger = event
464                .payload
465                .get("event")
466                .cloned()
467                .unwrap_or_else(|| event.payload.clone());
468            let event_id = json_string(&trigger, &["id"])?;
469            let binding_key = event
470                .headers
471                .get("binding_key")
472                .cloned()
473                .or_else(|| {
474                    let trigger_id = event
475                        .headers
476                        .get("trigger_id")
477                        .cloned()
478                        .or_else(|| json_string(&event.payload, &["trigger_id"]))?;
479                    let version = json_u64(&event.payload, &["binding_version"])?;
480                    Some(format!("{trigger_id}@v{version}"))
481                });
482            if event_is_settled(&completed_events, binding_key.as_deref(), &event_id)
483                || event_is_settled(&cancelled_events, binding_key.as_deref(), &event_id)
484            {
485                return None;
486            }
487            let trigger_id = event
488                .headers
489                .get("trigger_id")
490                .cloned()
491                .or_else(|| json_string(&event.payload, &["trigger_id"]));
492            let queued_at_ms = event.occurred_at_ms;
493            let provider = json_string(&trigger, &["provider"]).unwrap_or_default();
494            let kind = json_string(&trigger, &["kind"]).unwrap_or_default();
495            let id = binding_key
496                .as_ref()
497                .map(|key| format!("trigger://{key}/{event_id}"))
498                .unwrap_or_else(|| format!("trigger://{event_id}"));
499            Some(serde_json::json!({
500                "id": id,
501                "event_id": event_id,
502                "trigger_id": trigger_id,
503                "binding_key": binding_key,
504                "spec_summary": trigger_spec_summary(provider.as_str(), kind.as_str(), trigger_id.as_deref()),
505                "queued_at_ms": queued_at_ms,
506                "age_ms": now_ms.saturating_sub(queued_at_ms).max(0),
507                "source": "trigger_inbox",
508            }))
509        })
510        .collect()
511}
512
513fn event_is_settled(
514    settled_events: &BTreeSet<(String, String)>,
515    binding_key: Option<&str>,
516    event_id: &str,
517) -> bool {
518    let scoped_key = binding_key.unwrap_or_default().to_string();
519    settled_events.contains(&(scoped_key, event_id.to_string()))
520        || settled_events.contains(&(String::new(), event_id.to_string()))
521}
522
523async fn snapshot_worker_queue_triggers(
524    log: std::sync::Arc<crate::event_log::AnyEventLog>,
525    now_ms: i64,
526) -> Vec<Value> {
527    let queue = crate::triggers::WorkerQueue::new(log);
528    let Ok(queues) = queue.known_queues().await else {
529        return Vec::new();
530    };
531    let mut out = Vec::new();
532    for queue_name in queues {
533        let Ok(state) = queue.queue_state(&queue_name).await else {
534            continue;
535        };
536        let queue_label = state.queue.clone();
537        for job in state.jobs {
538            if job.acked || job.purged {
539                continue;
540            }
541            let event_id = job.job.event.id.0.clone();
542            let id = format!("worker://{}/{}", queue_label, job.job_event_id);
543            out.push(serde_json::json!({
544                "id": id,
545                "event_id": event_id,
546                "trigger_id": job.job.trigger_id,
547                "binding_key": job.job.binding_key,
548                "spec_summary": trigger_spec_summary(
549                    job.job.event.provider.0.as_str(),
550                    job.job.event.kind.as_str(),
551                    Some(job.job.trigger_id.as_str())
552                ),
553                "queued_at_ms": job.enqueued_at_ms,
554                "age_ms": now_ms.saturating_sub(job.enqueued_at_ms).max(0),
555                "source": "worker_queue",
556                "queue": queue_label.clone(),
557                "job_event_id": job.job_event_id,
558                "claimed": job.active_claim.is_some(),
559            }));
560        }
561    }
562    out
563}
564
565fn trigger_spec_summary(provider: &str, kind: &str, trigger_id: Option<&str>) -> String {
566    match (
567        trigger_id.filter(|id| !id.is_empty()),
568        provider.is_empty(),
569        kind.is_empty(),
570    ) {
571        (Some(id), false, false) => format!("{id}: {provider}.{kind}"),
572        (Some(id), _, _) => id.to_string(),
573        (None, false, false) => format!("{provider}.{kind}"),
574        (None, false, true) => provider.to_string(),
575        (None, true, false) => kind.to_string(),
576        (None, true, true) => "trigger event".to_string(),
577    }
578}
579
580fn json_string(value: &Value, path: &[&str]) -> Option<String> {
581    let mut cursor = value;
582    for key in path {
583        cursor = cursor.get(*key)?;
584    }
585    cursor.as_str().map(ToString::to_string)
586}
587
588fn json_u64(value: &Value, path: &[&str]) -> Option<u64> {
589    let mut cursor = value;
590    for key in path {
591        cursor = cursor.get(*key)?;
592    }
593    cursor.as_u64()
594}
595
596fn payload_summary(payload: &Value) -> String {
597    match payload {
598        Value::Null => "nil".to_string(),
599        Value::Bool(value) => value.to_string(),
600        Value::Number(value) => value.to_string(),
601        Value::String(value) => {
602            let mut chars = value.chars();
603            let preview: String = chars.by_ref().take(80).collect();
604            if chars.next().is_some() {
605                format!("{preview}...")
606            } else {
607                preview
608            }
609        }
610        Value::Array(items) => format!("list(len={})", items.len()),
611        Value::Object(map) => {
612            let keys = map.keys().take(6).cloned().collect::<Vec<_>>().join(",");
613            if map.len() > 6 {
614                format!("object(keys={keys},...)")
615            } else {
616                format!("object(keys={keys})")
617            }
618        }
619    }
620}