Skip to main content

harn_vm/orchestration/
handoffs.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3
4use serde::{Deserialize, Serialize};
5
6use super::{current_mutation_session, new_id, now_rfc3339, ArtifactRecord, RunRecord};
7
8const HANDOFF_TYPE: &str = "handoff_artifact";
9const HANDOFF_ARTIFACT_KIND: &str = "handoff";
10const RUN_RECEIPT_LINK_KIND: &str = "run_receipt";
11const DEFAULT_HANDOFF_KIND: &str = "handoff";
12
13thread_local! {
14    static HANDOFF_ROUTES: RefCell<Vec<HandoffRouteConfig>> = const { RefCell::new(Vec::new()) };
15}
16
17#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(default)]
19pub struct HandoffTargetRecord {
20    pub kind: String,
21    pub id: Option<String>,
22    pub label: Option<String>,
23    pub uri: Option<String>,
24}
25
26impl HandoffTargetRecord {
27    pub fn normalize(mut self) -> Self {
28        self.kind = normalize_target_kind(&self.kind);
29        if self
30            .id
31            .as_deref()
32            .is_some_and(|value| value.trim().is_empty())
33        {
34            self.id = None;
35        }
36        if self
37            .label
38            .as_deref()
39            .is_some_and(|value| value.trim().is_empty())
40        {
41            self.label = None;
42        }
43        if self
44            .uri
45            .as_deref()
46            .is_some_and(|value| value.trim().is_empty())
47        {
48            self.uri = None;
49        }
50        self
51    }
52
53    pub fn display_name(&self) -> String {
54        self.label
55            .clone()
56            .or_else(|| self.id.clone())
57            .unwrap_or_else(|| "unknown".to_string())
58    }
59}
60
61#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
62#[serde(default)]
63pub struct HandoffRouteTargetConfig {
64    pub id: Option<String>,
65    pub target: String,
66    pub when: Option<String>,
67    pub transport: Option<String>,
68    pub allow_cleartext: Option<bool>,
69    pub metadata: BTreeMap<String, serde_json::Value>,
70}
71
72impl HandoffRouteTargetConfig {
73    pub fn normalize(mut self) -> Self {
74        if self
75            .id
76            .as_deref()
77            .is_some_and(|value| value.trim().is_empty())
78        {
79            self.id = None;
80        }
81        self.target = self.target.trim().to_string();
82        self.when = self
83            .when
84            .map(|value| value.trim().to_string())
85            .filter(|value| !value.is_empty());
86        self.transport = self
87            .transport
88            .map(|value| value.trim().to_string())
89            .filter(|value| !value.is_empty());
90        self
91    }
92}
93
94#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
95#[serde(default)]
96pub struct HandoffRouteConfig {
97    pub id: Option<String>,
98    pub kind: String,
99    pub from: String,
100    #[serde(alias = "routes")]
101    pub route: Vec<HandoffRouteTargetConfig>,
102    pub metadata: BTreeMap<String, serde_json::Value>,
103}
104
105impl HandoffRouteConfig {
106    pub fn normalize(mut self) -> Self {
107        if self
108            .id
109            .as_deref()
110            .is_some_and(|value| value.trim().is_empty())
111        {
112            self.id = None;
113        }
114        self.kind = normalize_handoff_kind(&self.kind);
115        self.from = self.from.trim().to_string();
116        self.route = self
117            .route
118            .into_iter()
119            .map(HandoffRouteTargetConfig::normalize)
120            .collect();
121        self
122    }
123}
124
125#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
126#[serde(default)]
127pub struct HandoffRouteDecisionRecord {
128    pub route_id: Option<String>,
129    pub route_index: Option<u64>,
130    pub target_index: Option<u64>,
131    pub handoff_id: Option<String>,
132    pub handoff_kind: String,
133    pub source_persona: String,
134    pub target: String,
135    pub target_persona_or_human: HandoffTargetRecord,
136    pub matched_when: String,
137    pub selected_at: String,
138    pub dispatch_kind: String,
139    pub dispatch_status: Option<String>,
140    pub dispatch_receipt: Option<serde_json::Value>,
141    pub metadata: BTreeMap<String, serde_json::Value>,
142}
143
144impl HandoffRouteDecisionRecord {
145    pub fn normalize(mut self) -> Self {
146        self.handoff_id = self
147            .handoff_id
148            .map(|value| value.trim().to_string())
149            .filter(|value| !value.is_empty());
150        self.handoff_kind = normalize_handoff_kind(&self.handoff_kind);
151        self.source_persona = self.source_persona.trim().to_string();
152        self.target = self.target.trim().to_string();
153        self.target_persona_or_human = self.target_persona_or_human.normalize();
154        self.matched_when = self.matched_when.trim().to_string();
155        if self.matched_when.is_empty() {
156            self.matched_when = "always".to_string();
157        }
158        self.selected_at = self.selected_at.trim().to_string();
159        if self.selected_at.is_empty() {
160            self.selected_at = now_rfc3339();
161        }
162        self.dispatch_kind = normalize_target_kind(&self.dispatch_kind);
163        self.dispatch_status = self
164            .dispatch_status
165            .map(|value| value.trim().to_string())
166            .filter(|value| !value.is_empty());
167        self
168    }
169}
170
171#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
172#[serde(default)]
173pub struct HandoffEvidenceRefRecord {
174    pub artifact_id: Option<String>,
175    pub kind: Option<String>,
176    pub label: Option<String>,
177    pub path: Option<String>,
178    pub uri: Option<String>,
179}
180
181#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
182#[serde(default)]
183pub struct HandoffBudgetRemainingRecord {
184    pub tokens: Option<i64>,
185    pub tool_calls: Option<i64>,
186    pub dollars: Option<f64>,
187}
188
189#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
190#[serde(default)]
191pub struct HandoffDeadlineCheckbackRecord {
192    pub deadline: Option<String>,
193    pub checkback_at: Option<String>,
194}
195
196#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
197#[serde(default)]
198pub struct HandoffReceiptLinkRecord {
199    pub kind: String,
200    pub label: Option<String>,
201    pub run_id: Option<String>,
202    pub artifact_id: Option<String>,
203    pub path: Option<String>,
204    pub href: Option<String>,
205}
206
207impl HandoffReceiptLinkRecord {
208    pub fn normalize(mut self) -> Self {
209        if self.kind.trim().is_empty() {
210            self.kind = RUN_RECEIPT_LINK_KIND.to_string();
211        }
212        if self
213            .label
214            .as_deref()
215            .is_some_and(|value| value.trim().is_empty())
216        {
217            self.label = None;
218        }
219        if self
220            .run_id
221            .as_deref()
222            .is_some_and(|value| value.trim().is_empty())
223        {
224            self.run_id = None;
225        }
226        if self
227            .artifact_id
228            .as_deref()
229            .is_some_and(|value| value.trim().is_empty())
230        {
231            self.artifact_id = None;
232        }
233        if self
234            .path
235            .as_deref()
236            .is_some_and(|value| value.trim().is_empty())
237        {
238            self.path = None;
239        }
240        if self
241            .href
242            .as_deref()
243            .is_some_and(|value| value.trim().is_empty())
244        {
245            self.href = None;
246        }
247        self
248    }
249}
250
251#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
252#[serde(default)]
253pub struct HandoffArtifact {
254    #[serde(rename = "_type")]
255    pub type_name: String,
256    pub kind: String,
257    pub id: String,
258    pub parent_run_id: Option<String>,
259    pub source_persona: String,
260    pub target_persona_or_human: HandoffTargetRecord,
261    pub task: String,
262    pub reason: String,
263    pub evidence_refs: Vec<HandoffEvidenceRefRecord>,
264    pub files_or_entities_touched: Vec<String>,
265    pub open_questions: Vec<String>,
266    pub blocked_on: Vec<String>,
267    pub requested_capabilities: Vec<String>,
268    pub allowed_side_effects: Vec<String>,
269    pub budget_remaining: Option<HandoffBudgetRemainingRecord>,
270    pub deadline_checkback: Option<HandoffDeadlineCheckbackRecord>,
271    pub confidence: Option<f64>,
272    pub receipt_links: Vec<HandoffReceiptLinkRecord>,
273    pub route_decision: Option<HandoffRouteDecisionRecord>,
274    pub created_at: String,
275    pub metadata: BTreeMap<String, serde_json::Value>,
276}
277
278impl HandoffArtifact {
279    pub fn normalize(mut self) -> Self {
280        if self.type_name.is_empty() {
281            self.type_name = HANDOFF_TYPE.to_string();
282        }
283        self.kind = normalize_handoff_kind(&self.kind);
284        if self.id.is_empty() {
285            self.id = new_id("handoff");
286        }
287        if self.created_at.is_empty() {
288            self.created_at = now_rfc3339();
289        }
290        if self.parent_run_id.is_none() {
291            self.parent_run_id = current_mutation_session().and_then(|session| session.run_id);
292        }
293        self.source_persona = self.source_persona.trim().to_string();
294        self.task = self.task.trim().to_string();
295        self.reason = self.reason.trim().to_string();
296        self.target_persona_or_human = self.target_persona_or_human.normalize();
297        self.files_or_entities_touched = normalize_string_list(self.files_or_entities_touched);
298        self.open_questions = normalize_string_list(self.open_questions);
299        self.blocked_on = normalize_string_list(self.blocked_on);
300        self.requested_capabilities = normalize_string_list(self.requested_capabilities);
301        self.allowed_side_effects = normalize_string_list(self.allowed_side_effects);
302        self.receipt_links = self
303            .receipt_links
304            .into_iter()
305            .map(HandoffReceiptLinkRecord::normalize)
306            .collect();
307        self.route_decision = self
308            .route_decision
309            .map(HandoffRouteDecisionRecord::normalize);
310        self.confidence = self.confidence.map(|value| value.clamp(0.0, 1.0));
311        self
312    }
313}
314
315pub fn install_handoff_routes(routes: Vec<HandoffRouteConfig>) {
316    HANDOFF_ROUTES.with(|installed| {
317        *installed.borrow_mut() = routes
318            .into_iter()
319            .map(HandoffRouteConfig::normalize)
320            .collect();
321    });
322}
323
324pub fn snapshot_handoff_routes() -> Vec<HandoffRouteConfig> {
325    HANDOFF_ROUTES.with(|installed| installed.borrow().clone())
326}
327
328fn normalize_string_list(values: Vec<String>) -> Vec<String> {
329    let mut seen = BTreeSet::new();
330    values
331        .into_iter()
332        .map(|value| value.trim().to_string())
333        .filter(|value| !value.is_empty() && seen.insert(value.clone()))
334        .collect()
335}
336
337fn normalize_target_kind(kind: &str) -> String {
338    match kind.trim() {
339        "human" => "human".to_string(),
340        "persona" => "persona".to_string(),
341        "a2a" | "external_a2a" => "a2a".to_string(),
342        "worker" | "queue" => "worker".to_string(),
343        _ => "persona".to_string(),
344    }
345}
346
347fn normalize_handoff_kind(kind: &str) -> String {
348    let kind = kind.trim();
349    if kind.is_empty() {
350        DEFAULT_HANDOFF_KIND.to_string()
351    } else {
352        kind.to_string()
353    }
354}
355
356pub fn normalize_handoff_artifact_json(
357    value: serde_json::Value,
358) -> Result<HandoffArtifact, String> {
359    let handoff: HandoffArtifact =
360        serde_json::from_value(value).map_err(|error| format!("handoff parse error: {error}"))?;
361    let handoff = handoff.normalize();
362    if handoff.source_persona.is_empty() {
363        return Err("handoff source_persona is required".to_string());
364    }
365    if handoff.target_persona_or_human.display_name() == "unknown" {
366        return Err("handoff target_persona_or_human is required".to_string());
367    }
368    if handoff.task.is_empty() {
369        return Err("handoff task is required".to_string());
370    }
371    if handoff.reason.is_empty() {
372        return Err("handoff reason is required".to_string());
373    }
374    if let Some(decision) = handoff.route_decision.as_ref() {
375        if decision.target_persona_or_human.display_name() == "unknown" {
376            return Err("handoff route_decision target is required".to_string());
377        }
378    }
379    Ok(handoff)
380}
381
382pub fn handoff_from_json_value(value: &serde_json::Value) -> Option<HandoffArtifact> {
383    let object = value.as_object()?;
384    if object.get("_type").and_then(|value| value.as_str()) == Some(HANDOFF_TYPE)
385        || (object.contains_key("source_persona")
386            && object.contains_key("target_persona_or_human")
387            && object.contains_key("task"))
388    {
389        return normalize_handoff_artifact_json(value.clone()).ok();
390    }
391    if object.get("_type").and_then(|value| value.as_str()) == Some("artifact")
392        || object.get("kind").and_then(|value| value.as_str()) == Some(HANDOFF_ARTIFACT_KIND)
393    {
394        return object
395            .get("data")
396            .and_then(handoff_from_json_value)
397            .or_else(|| normalize_handoff_artifact_json(value.clone()).ok());
398    }
399    if object.get("_type").and_then(|value| value.as_str()) == Some("agent_state_handoff") {
400        return object
401            .get("handoff")
402            .and_then(handoff_from_json_value)
403            .or_else(|| object.get("summary").and_then(handoff_from_json_value));
404    }
405    None
406}
407
408pub fn extract_handoff_from_artifact(artifact: &ArtifactRecord) -> Option<HandoffArtifact> {
409    if artifact.kind != HANDOFF_ARTIFACT_KIND {
410        return None;
411    }
412    artifact.data.as_ref().and_then(handoff_from_json_value)
413}
414
415pub fn extract_handoffs_from_json_value(value: &serde_json::Value) -> Vec<HandoffArtifact> {
416    fn collect(value: &serde_json::Value, out: &mut Vec<HandoffArtifact>) {
417        if let Some(handoff) = handoff_from_json_value(value) {
418            out.push(handoff);
419        }
420        let Some(object) = value.as_object() else {
421            return;
422        };
423        for key in ["handoffs", "artifacts"] {
424            if let Some(items) = object.get(key).and_then(|value| value.as_array()) {
425                for item in items {
426                    collect(item, out);
427                }
428            }
429        }
430        for key in ["run", "result"] {
431            if let Some(nested) = object.get(key) {
432                collect(nested, out);
433            }
434        }
435    }
436
437    let mut handoffs = Vec::new();
438    collect(value, &mut handoffs);
439    dedup_handoffs(handoffs)
440}
441
442fn dedup_handoffs(handoffs: Vec<HandoffArtifact>) -> Vec<HandoffArtifact> {
443    let mut by_id = BTreeMap::new();
444    for handoff in handoffs {
445        by_id
446            .entry(handoff.id.clone())
447            .and_modify(|existing: &mut HandoffArtifact| {
448                *existing = merge_handoffs(existing.clone(), handoff.clone())
449            })
450            .or_insert(handoff);
451    }
452    by_id.into_values().collect()
453}
454
455fn merge_receipt_links(
456    left: Vec<HandoffReceiptLinkRecord>,
457    right: Vec<HandoffReceiptLinkRecord>,
458) -> Vec<HandoffReceiptLinkRecord> {
459    let mut seen = BTreeSet::new();
460    left.into_iter()
461        .chain(right)
462        .map(HandoffReceiptLinkRecord::normalize)
463        .filter(|link| {
464            seen.insert((
465                link.kind.clone(),
466                link.run_id.clone(),
467                link.artifact_id.clone(),
468                link.path.clone(),
469                link.href.clone(),
470            ))
471        })
472        .collect()
473}
474
475fn merge_handoffs(mut left: HandoffArtifact, right: HandoffArtifact) -> HandoffArtifact {
476    if left.parent_run_id.is_none() {
477        left.parent_run_id = right.parent_run_id;
478    }
479    if left.source_persona.is_empty() {
480        left.source_persona = right.source_persona;
481    }
482    if left.target_persona_or_human.display_name() == "unknown" {
483        left.target_persona_or_human = right.target_persona_or_human;
484    }
485    if left.task.is_empty() {
486        left.task = right.task;
487    }
488    if left.reason.is_empty() {
489        left.reason = right.reason;
490    }
491    if left.evidence_refs.is_empty() {
492        left.evidence_refs = right.evidence_refs;
493    }
494    if left.files_or_entities_touched.is_empty() {
495        left.files_or_entities_touched = right.files_or_entities_touched;
496    }
497    if left.open_questions.is_empty() {
498        left.open_questions = right.open_questions;
499    }
500    if left.blocked_on.is_empty() {
501        left.blocked_on = right.blocked_on;
502    }
503    if left.requested_capabilities.is_empty() {
504        left.requested_capabilities = right.requested_capabilities;
505    }
506    if left.allowed_side_effects.is_empty() {
507        left.allowed_side_effects = right.allowed_side_effects;
508    }
509    if left.budget_remaining.is_none() {
510        left.budget_remaining = right.budget_remaining;
511    }
512    if left.deadline_checkback.is_none() {
513        left.deadline_checkback = right.deadline_checkback;
514    }
515    if left.confidence.is_none() {
516        left.confidence = right.confidence;
517    }
518    if left.route_decision.is_none() {
519        left.route_decision = right.route_decision;
520    }
521    left.receipt_links = merge_receipt_links(left.receipt_links, right.receipt_links);
522    for (key, value) in right.metadata {
523        left.metadata.entry(key).or_insert(value);
524    }
525    left
526}
527
528pub fn handoff_context_text(handoff: &HandoffArtifact) -> String {
529    let mut lines = vec![
530        format!("<kind>{}</kind>", handoff.kind),
531        format!(
532            "<source_persona>{}</source_persona>",
533            handoff.source_persona
534        ),
535        format!(
536            "<target kind=\"{}\">{}</target>",
537            handoff.target_persona_or_human.kind,
538            handoff.target_persona_or_human.display_name()
539        ),
540        format!("<task>{}</task>", handoff.task),
541        format!("<reason>{}</reason>", handoff.reason),
542    ];
543    append_list_section(
544        &mut lines,
545        "files_or_entities_touched",
546        &handoff.files_or_entities_touched,
547    );
548    append_list_section(&mut lines, "open_questions", &handoff.open_questions);
549    append_list_section(&mut lines, "blocked_on", &handoff.blocked_on);
550    append_list_section(
551        &mut lines,
552        "requested_capabilities",
553        &handoff.requested_capabilities,
554    );
555    append_list_section(
556        &mut lines,
557        "allowed_side_effects",
558        &handoff.allowed_side_effects,
559    );
560    if !handoff.evidence_refs.is_empty() {
561        lines.push("<evidence_refs>".to_string());
562        for evidence in &handoff.evidence_refs {
563            let mut parts = Vec::new();
564            if let Some(label) = evidence.label.as_ref() {
565                parts.push(label.clone());
566            }
567            if let Some(artifact_id) = evidence.artifact_id.as_ref() {
568                parts.push(format!("artifact_id={artifact_id}"));
569            }
570            if let Some(path) = evidence.path.as_ref() {
571                parts.push(format!("path={path}"));
572            }
573            if let Some(uri) = evidence.uri.as_ref() {
574                parts.push(format!("uri={uri}"));
575            }
576            if let Some(kind) = evidence.kind.as_ref() {
577                parts.push(format!("kind={kind}"));
578            }
579            lines.push(format!("- {}", parts.join(" | ")));
580        }
581        lines.push("</evidence_refs>".to_string());
582    }
583    if let Some(budget) = handoff.budget_remaining.as_ref() {
584        lines.push(format!(
585            "<budget_remaining tokens=\"{}\" tool_calls=\"{}\" dollars=\"{}\" />",
586            budget
587                .tokens
588                .map(|value| value.to_string())
589                .unwrap_or_default(),
590            budget
591                .tool_calls
592                .map(|value| value.to_string())
593                .unwrap_or_default(),
594            budget
595                .dollars
596                .map(|value| format!("{value:.4}"))
597                .unwrap_or_default(),
598        ));
599    }
600    if let Some(deadline) = handoff.deadline_checkback.as_ref() {
601        lines.push(format!(
602            "<deadline_checkback deadline=\"{}\" checkback_at=\"{}\" />",
603            deadline.deadline.clone().unwrap_or_default(),
604            deadline.checkback_at.clone().unwrap_or_default(),
605        ));
606    }
607    if let Some(confidence) = handoff.confidence {
608        lines.push(format!("<confidence>{confidence:.2}</confidence>"));
609    }
610    if let Some(decision) = handoff.route_decision.as_ref() {
611        lines.push(format!(
612            "<route_decision target=\"{}\" when=\"{}\" dispatch=\"{}\" selected_at=\"{}\" />",
613            decision.target, decision.matched_when, decision.dispatch_kind, decision.selected_at
614        ));
615    }
616    format!("<handoff>\n{}\n</handoff>", lines.join("\n"))
617}
618
619fn append_list_section(lines: &mut Vec<String>, label: &str, items: &[String]) {
620    if items.is_empty() {
621        return;
622    }
623    lines.push(format!("<{label}>"));
624    for item in items {
625        lines.push(format!("- {item}"));
626    }
627    lines.push(format!("</{label}>"));
628}
629
630fn handoff_target_label(handoff: &HandoffArtifact) -> String {
631    handoff.target_persona_or_human.display_name()
632}
633
634fn handoff_metadata(handoff: &HandoffArtifact) -> BTreeMap<String, serde_json::Value> {
635    BTreeMap::from([
636        ("handoff_id".to_string(), serde_json::json!(handoff.id)),
637        ("handoff_kind".to_string(), serde_json::json!(handoff.kind)),
638        (
639            "target_kind".to_string(),
640            serde_json::json!(handoff.target_persona_or_human.kind),
641        ),
642        (
643            "target_label".to_string(),
644            serde_json::json!(handoff_target_label(handoff)),
645        ),
646    ])
647}
648
649pub fn handoff_artifact_record(
650    handoff: &HandoffArtifact,
651    existing: Option<&ArtifactRecord>,
652) -> ArtifactRecord {
653    let mut metadata = existing
654        .map(|artifact| artifact.metadata.clone())
655        .unwrap_or_default();
656    metadata.extend(handoff_metadata(handoff));
657    ArtifactRecord {
658        type_name: "artifact".to_string(),
659        id: existing
660            .map(|artifact| artifact.id.clone())
661            .unwrap_or_else(|| format!("artifact_{}", handoff.id)),
662        kind: HANDOFF_ARTIFACT_KIND.to_string(),
663        title: existing
664            .and_then(|artifact| artifact.title.clone())
665            .or_else(|| Some(format!("Handoff to {}", handoff_target_label(handoff)))),
666        text: Some(handoff_context_text(handoff)),
667        data: Some(serde_json::to_value(handoff).unwrap_or(serde_json::Value::Null)),
668        source: existing
669            .and_then(|artifact| artifact.source.clone())
670            .or_else(|| Some(handoff.source_persona.clone())),
671        created_at: existing
672            .map(|artifact| artifact.created_at.clone())
673            .unwrap_or_else(now_rfc3339),
674        freshness: existing
675            .and_then(|artifact| artifact.freshness.clone())
676            .or_else(|| Some("fresh".to_string())),
677        priority: existing.and_then(|artifact| artifact.priority).or(Some(85)),
678        lineage: existing
679            .map(|artifact| artifact.lineage.clone())
680            .unwrap_or_default(),
681        relevance: handoff.confidence.or(Some(1.0)),
682        estimated_tokens: None,
683        stage: existing.and_then(|artifact| artifact.stage.clone()),
684        metadata,
685    }
686    .normalize()
687}
688
689fn receipt_link_for_run(run: &RunRecord) -> HandoffReceiptLinkRecord {
690    HandoffReceiptLinkRecord {
691        kind: RUN_RECEIPT_LINK_KIND.to_string(),
692        label: run
693            .workflow_name
694            .clone()
695            .or_else(|| Some(run.workflow_id.clone())),
696        run_id: Some(run.id.clone()),
697        artifact_id: None,
698        path: run.persisted_path.clone(),
699        href: None,
700    }
701    .normalize()
702}
703
704fn sync_handoff_receipt_links(handoff: &mut HandoffArtifact, run: &RunRecord) {
705    if handoff.parent_run_id.is_none() {
706        handoff.parent_run_id = Some(run.id.clone());
707    }
708    handoff.receipt_links = merge_receipt_links(
709        std::mem::take(&mut handoff.receipt_links),
710        vec![receipt_link_for_run(run)],
711    );
712}
713
714fn artifact_handoff_id(artifact: &ArtifactRecord) -> Option<String> {
715    if artifact.kind != HANDOFF_ARTIFACT_KIND {
716        return None;
717    }
718    artifact
719        .metadata
720        .get("handoff_id")
721        .and_then(|value| value.as_str())
722        .map(str::to_string)
723        .or_else(|| {
724            artifact
725                .data
726                .as_ref()
727                .and_then(|value| value.get("id"))
728                .and_then(|value| value.as_str())
729                .map(str::to_string)
730        })
731}
732
733pub fn sync_run_handoffs(run: &mut RunRecord) {
734    let mut by_id = BTreeMap::new();
735    for handoff in std::mem::take(&mut run.handoffs) {
736        by_id.insert(handoff.id.clone(), handoff.normalize());
737    }
738    for artifact in &run.artifacts {
739        if let Some(handoff) = extract_handoff_from_artifact(artifact) {
740            by_id
741                .entry(handoff.id.clone())
742                .and_modify(|existing| {
743                    *existing = merge_handoffs(existing.clone(), handoff.clone())
744                })
745                .or_insert(handoff);
746        }
747    }
748
749    let mut artifact_index_by_handoff_id = BTreeMap::new();
750    for (index, artifact) in run.artifacts.iter().enumerate() {
751        if let Some(handoff_id) = artifact_handoff_id(artifact) {
752            artifact_index_by_handoff_id.insert(handoff_id, index);
753        }
754    }
755
756    let mut handoffs = by_id.into_values().collect::<Vec<_>>();
757    handoffs.sort_by(|left, right| left.created_at.cmp(&right.created_at));
758    for handoff in &mut handoffs {
759        sync_handoff_receipt_links(handoff, run);
760        if let Some(index) = artifact_index_by_handoff_id.get(&handoff.id).copied() {
761            let existing = run.artifacts[index].clone();
762            run.artifacts[index] = handoff_artifact_record(handoff, Some(&existing));
763        } else {
764            run.artifacts.push(handoff_artifact_record(handoff, None));
765        }
766    }
767    run.handoffs = handoffs;
768}