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}