1use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16use sha2::{Digest, Sha256};
17
18use crate::artifact_to_state::{
19 ARTIFACT_PACKET_SCHEMA, ArtifactPacket, ImportIdempotency, PacketArtifact,
20 PacketCandidateClaim, PacketOpenNeed, PacketProducer,
21};
22use crate::canonical;
23use crate::events::StateTarget;
24use crate::proposals::{self, AgentRun};
25use crate::{artifact_to_state, repo};
26
27pub const SCIENCECLAW_ARTIFACT_V1: &str = "scienceclaw-artifact-v1";
28pub const AGENT_DISCOURSE_V1: &str = "agent-discourse-v1";
29
30pub const AGENT4SCIENCE_REVIEW_V1: &str = "agent4science-review-v1";
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct RuntimeAdapterRunOptions {
45 pub adapter: String,
46 pub input: PathBuf,
47 pub actor: String,
48 #[serde(default)]
49 pub dry_run: bool,
50 #[serde(default)]
51 pub apply_artifacts: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct RuntimeAdapterRunReport {
56 pub ok: bool,
57 pub command: String,
58 pub adapter: String,
59 pub run_id: String,
60 pub frontier: String,
61 pub input: String,
62 pub dry_run: bool,
63 pub artifact_proposals: usize,
64 pub finding_proposals: usize,
65 pub gap_proposals: usize,
66 #[serde(default)]
67 pub review_note_proposals: usize,
68 pub applied_artifact_events: usize,
69 pub pending_truth_proposals: usize,
70 pub proposal_ids: Vec<String>,
71 #[serde(default)]
72 pub review_proposal_ids: Vec<String>,
73 pub applied_event_ids: Vec<String>,
74 pub idempotency: ImportIdempotency,
75 pub trusted_state_effect: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub packet_id: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub packet_path: Option<PathBuf>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub run_path: Option<PathBuf>,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85struct ScienceClawExport {
86 schema: String,
87 #[serde(default)]
88 run_id: String,
89 producer: PacketProducer,
90 topic: String,
91 created_at: String,
92 #[serde(default)]
93 artifacts: Vec<PacketArtifact>,
94 #[serde(default)]
95 candidate_claims: Vec<PacketCandidateClaim>,
96 #[serde(default)]
97 open_needs: Vec<PacketOpenNeed>,
98 #[serde(default)]
99 caveats: Vec<String>,
100}
101
102#[derive(Debug, Clone, Deserialize)]
103struct AgentDiscourseExport {
104 schema: String,
105 thread_id: String,
106 runtime: DiscourseRuntime,
107 topic: String,
108 created_at: String,
109 #[serde(default)]
110 posts: Vec<DiscoursePost>,
111 #[serde(default)]
112 comments: Vec<DiscourseComment>,
113 #[serde(default)]
114 reviews: Vec<DiscourseReview>,
115 #[serde(default)]
116 open_needs: Vec<PacketOpenNeed>,
117}
118
119#[derive(Debug, Clone, Deserialize)]
120struct DiscourseRuntime {
121 id: String,
122 name: String,
123}
124
125#[derive(Debug, Clone, Deserialize)]
126struct DiscoursePost {
127 id: String,
128 title: String,
129 assertion: String,
130 #[serde(default)]
131 body: String,
132 locator: String,
133 content_hash: String,
134 #[serde(default)]
135 conditions: Vec<String>,
136 #[serde(default)]
137 confidence: Option<f64>,
138 #[serde(default)]
139 source_refs: Vec<String>,
140 #[serde(default)]
141 target_finding_id: Option<String>,
142}
143
144#[derive(Debug, Clone, Deserialize)]
145struct DiscourseComment {
146 id: String,
147 post_id: String,
148 body: String,
149 locator: String,
150 content_hash: String,
151 #[serde(default)]
152 target_finding_id: Option<String>,
153}
154
155#[derive(Debug, Clone, Deserialize)]
156struct DiscourseReview {
157 id: String,
158 post_id: String,
159 decision: String,
160 body: String,
161 locator: String,
162 content_hash: String,
163 #[serde(default)]
164 target_finding_id: Option<String>,
165}
166
167#[derive(Debug, Clone)]
168struct ReviewSignal {
169 external_id: String,
170 parent_id: String,
171 target_finding_id: String,
172 locator: String,
173 body: String,
174 decision: Option<String>,
175}
176
177#[derive(Debug, Clone)]
178struct NormalizedRuntimePacket {
179 packet: ArtifactPacket,
180 review_signals: Vec<ReviewSignal>,
181}
182
183pub fn run(
184 frontier_path: &Path,
185 options: RuntimeAdapterRunOptions,
186) -> Result<RuntimeAdapterRunReport, String> {
187 if options.actor.trim().is_empty() {
188 return Err("actor must be non-empty".to_string());
189 }
190
191 let frontier = repo::load_from_path(frontier_path)?;
192 let input_path = resolve_input_path(&options.input)?;
193 let input_value = read_json(&input_path)?;
194 let run_id = run_id(&options.adapter, &input_value);
195 let normalized = normalize_packet(&options.adapter, input_value, &run_id)?;
196 let packet = normalized.packet.validate()?;
197 let frontier_name = frontier.project.name.clone();
198
199 if options.dry_run {
200 return Ok(RuntimeAdapterRunReport {
201 ok: true,
202 command: "runtime-adapter.run".to_string(),
203 adapter: options.adapter,
204 run_id,
205 frontier: frontier_name,
206 input: input_path.display().to_string(),
207 dry_run: true,
208 artifact_proposals: 0,
209 finding_proposals: 0,
210 gap_proposals: 0,
211 review_note_proposals: 0,
212 applied_artifact_events: 0,
213 pending_truth_proposals: 0,
214 proposal_ids: Vec::new(),
215 review_proposal_ids: Vec::new(),
216 applied_event_ids: Vec::new(),
217 idempotency: ImportIdempotency {
218 packet_hash: packet_hash(&packet),
219 duplicate_packet: false,
220 skipped_existing_proposals: Vec::new(),
221 skipped_existing_artifacts: Vec::new(),
222 },
223 trusted_state_effect: "none".to_string(),
224 packet_id: Some(packet.packet_id),
225 packet_path: None,
226 run_path: None,
227 });
228 }
229
230 let run_dir = runtime_runs_dir(frontier_path)?.join(&run_id);
231 fs::create_dir_all(&run_dir).map_err(|e| {
232 format!(
233 "create runtime adapter run dir '{}': {e}",
234 run_dir.display()
235 )
236 })?;
237 fs::write(
238 run_dir.join("input.json"),
239 serde_json::to_vec_pretty(&read_json(&input_path)?)
240 .map_err(|e| format!("serialize runtime adapter input: {e}"))?,
241 )
242 .map_err(|e| {
243 format!(
244 "write runtime adapter input '{}': {e}",
245 input_path.display()
246 )
247 })?;
248
249 let packet_path = run_dir.join("artifact-packet.json");
250 fs::write(
251 &packet_path,
252 serde_json::to_vec_pretty(&packet).map_err(|e| format!("serialize packet: {e}"))?,
253 )
254 .map_err(|e| format!("write artifact packet '{}': {e}", packet_path.display()))?;
255
256 let import_report = artifact_to_state::import_packet_at_path(
257 frontier_path,
258 &packet_path,
259 &options.actor,
260 options.apply_artifacts,
261 )?;
262 update_import_agent_runs(
263 frontier_path,
264 &import_report.proposal_ids,
265 &options.adapter,
266 &run_id,
267 &packet.packet_id,
268 &input_path,
269 )?;
270 let review_proposal_ids = create_review_note_proposals(
271 frontier_path,
272 &options,
273 &run_id,
274 &packet.packet_id,
275 &normalized.review_signals,
276 )?;
277 let mut proposal_ids = import_report.proposal_ids.clone();
278 proposal_ids.extend(review_proposal_ids.clone());
279
280 let final_run = json!({
281 "schema": "vela.runtime-adapter-run.v1",
282 "run_id": run_id,
283 "adapter": options.adapter,
284 "frontier": frontier_name,
285 "input": input_path.display().to_string(),
286 "started_at": packet.created_at,
287 "packet_id": packet.packet_id,
288 "packet_path": "artifact-packet.json",
289 "artifact_proposals": import_report.artifact_proposals,
290 "finding_proposals": import_report.finding_proposals,
291 "gap_proposals": import_report.gap_proposals,
292 "review_note_proposals": review_proposal_ids.len(),
293 "proposal_ids": proposal_ids,
294 "review_proposal_ids": review_proposal_ids,
295 "applied_event_ids": import_report.applied_event_ids,
296 "idempotency": import_report.idempotency,
297 "trusted_state_effect": import_report.trusted_state_effect,
298 "external_runtime": external_runtime_summary(&packet),
299 });
300 fs::write(
301 run_dir.join("run.json"),
302 serde_json::to_vec_pretty(&final_run).map_err(|e| format!("serialize run: {e}"))?,
303 )
304 .map_err(|e| format!("write runtime adapter run '{}': {e}", run_dir.display()))?;
305
306 Ok(RuntimeAdapterRunReport {
307 ok: true,
308 command: "runtime-adapter.run".to_string(),
309 adapter: options.adapter,
310 run_id,
311 frontier: frontier_name,
312 input: input_path.display().to_string(),
313 dry_run: false,
314 artifact_proposals: import_report.artifact_proposals,
315 finding_proposals: import_report.finding_proposals,
316 gap_proposals: import_report.gap_proposals,
317 review_note_proposals: review_proposal_ids.len(),
318 applied_artifact_events: import_report.applied_artifact_events,
319 pending_truth_proposals: import_report.pending_truth_proposals,
320 proposal_ids,
321 review_proposal_ids,
322 applied_event_ids: import_report.applied_event_ids,
323 idempotency: import_report.idempotency,
324 trusted_state_effect: import_report.trusted_state_effect,
325 packet_id: Some(packet.packet_id),
326 packet_path: Some(packet_path),
327 run_path: Some(run_dir),
328 })
329}
330
331fn packet_hash(packet: &ArtifactPacket) -> String {
332 let bytes = canonical::to_canonical_bytes(packet).unwrap_or_default();
333 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
334}
335
336fn normalize_packet(
337 adapter: &str,
338 input: Value,
339 run_id: &str,
340) -> Result<NormalizedRuntimePacket, String> {
341 match adapter {
342 SCIENCECLAW_ARTIFACT_V1 => normalize_scienceclaw(input, run_id),
343 AGENT_DISCOURSE_V1 => normalize_agent_discourse(input, run_id),
344 AGENT4SCIENCE_REVIEW_V1 => normalize_agent4science_review(input, run_id),
345 _ => Err(format!("unsupported runtime adapter '{adapter}'")),
346 }
347}
348
349#[derive(Debug, Clone, serde::Deserialize)]
371struct Agent4ScienceReviewPacket {
372 schema: String,
373 review_id: String,
374 target_finding_id: String,
375 verdict: String,
376 reasoning: String,
377 reviewer: Agent4ScienceReviewer,
378 #[serde(default)]
379 evidence: Vec<Agent4ScienceEvidence>,
380 #[serde(default)]
381 signature: Option<String>,
382}
383
384#[derive(Debug, Clone, serde::Deserialize)]
385struct Agent4ScienceReviewer {
386 id: String,
387 #[serde(rename = "type")]
388 actor_type: String,
389}
390
391#[derive(Debug, Clone, serde::Deserialize)]
392struct Agent4ScienceEvidence {
393 locator: String,
394 #[serde(default)]
398 #[allow(dead_code)]
399 span: Option<String>,
400}
401
402const AGENT4SCIENCE_REVIEW_SCHEMA: &str = "carina.review_packet.v0.1";
403
404fn normalize_agent4science_review(
405 input: Value,
406 run_id: &str,
407) -> Result<NormalizedRuntimePacket, String> {
408 let packet: Agent4ScienceReviewPacket = serde_json::from_value(input)
409 .map_err(|e| format!("parse agent4science review packet: {e}"))?;
410 if packet.schema != AGENT4SCIENCE_REVIEW_SCHEMA {
411 return Err(format!(
412 "unsupported agent4science review schema '{}', expected '{AGENT4SCIENCE_REVIEW_SCHEMA}'",
413 packet.schema
414 ));
415 }
416 if !packet.target_finding_id.starts_with("vf_") {
417 return Err(format!(
418 "target_finding_id must start with 'vf_', got '{}'",
419 packet.target_finding_id
420 ));
421 }
422 if !["accepted", "needs_revision", "contested", "rejected"].contains(&packet.verdict.as_str()) {
423 return Err(format!("verdict '{}' not in allowlist", packet.verdict));
424 }
425 if !["human", "agent"].contains(&packet.reviewer.actor_type.as_str()) {
426 return Err(format!(
427 "reviewer.type '{}' must be 'human' or 'agent'",
428 packet.reviewer.actor_type
429 ));
430 }
431
432 let body = format!(
438 "Agent4Science review {}: {}. Reasoning: {}.",
439 packet.review_id, packet.verdict, packet.reasoning
440 );
441 let locator = packet
442 .evidence
443 .first()
444 .map(|e| e.locator.clone())
445 .unwrap_or_else(|| format!("agent4science:review:{}", packet.review_id));
446
447 let mut metadata = BTreeMap::new();
448 metadata.insert("external_object_kind".to_string(), json!("review_packet"));
449 metadata.insert(
450 "external_object_id".to_string(),
451 json!(packet.review_id.clone()),
452 );
453 metadata.insert("verdict".to_string(), json!(packet.verdict.clone()));
454 metadata.insert("reviewer_id".to_string(), json!(packet.reviewer.id.clone()));
455 metadata.insert(
456 "reviewer_type".to_string(),
457 json!(packet.reviewer.actor_type.clone()),
458 );
459 metadata.insert(
460 "target_findings".to_string(),
461 json!([packet.target_finding_id.clone()]),
462 );
463 if let Some(sig) = &packet.signature {
464 metadata.insert("signature".to_string(), json!(sig));
465 }
466
467 let content_hash = format!("sha256:{}", hex::encode(Sha256::digest(body.as_bytes())));
468
469 let artifact = PacketArtifact {
470 id: packet.review_id.clone(),
471 kind: "source_file".to_string(),
476 title: format!("Agent4Science review {}", packet.review_id),
477 locator: locator.clone(),
478 content_hash,
479 parents: Vec::new(),
480 metadata,
481 };
482
483 let review_signals = vec![ReviewSignal {
484 external_id: packet.review_id.clone(),
485 parent_id: packet.review_id.clone(),
486 target_finding_id: packet.target_finding_id.clone(),
487 locator,
488 body: body.clone(),
489 decision: Some(packet.verdict.clone()),
490 }];
491
492 let inner_packet = ArtifactPacket {
493 schema: ARTIFACT_PACKET_SCHEMA.to_string(),
494 packet_id: packet_id(AGENT4SCIENCE_REVIEW_V1, run_id, &packet.review_id),
495 producer: PacketProducer {
496 kind: packet.reviewer.actor_type.clone(),
497 id: packet.reviewer.id.clone(),
498 name: format!("agent4science:{}", packet.reviewer.id),
499 },
500 topic: "agent4science.review".to_string(),
501 created_at: chrono::Utc::now().to_rfc3339(),
502 artifacts: vec![artifact],
503 candidate_claims: Vec::new(),
504 open_needs: Vec::new(),
505 caveats: bridge_caveats(vec![
506 "Agent4Science review packets are review signals, not canonical truth. A human reviewer must sign an accept event."
507 .to_string(),
508 ]),
509 };
510
511 Ok(NormalizedRuntimePacket {
512 packet: with_runtime_metadata(inner_packet, AGENT4SCIENCE_REVIEW_V1, run_id),
513 review_signals,
514 })
515}
516
517fn normalize_scienceclaw(input: Value, run_id: &str) -> Result<NormalizedRuntimePacket, String> {
518 if input.get("schema").and_then(Value::as_str) == Some(ARTIFACT_PACKET_SCHEMA) {
519 let packet: ArtifactPacket =
520 serde_json::from_value(input).map_err(|e| format!("parse artifact packet: {e}"))?;
521 return Ok(NormalizedRuntimePacket {
522 packet: with_runtime_metadata(packet, SCIENCECLAW_ARTIFACT_V1, run_id),
523 review_signals: Vec::new(),
524 });
525 }
526
527 let export: ScienceClawExport =
528 serde_json::from_value(input).map_err(|e| format!("parse ScienceClaw export: {e}"))?;
529 if export.schema != "scienceclaw.artifact_export.v1" {
530 return Err(format!(
531 "unsupported ScienceClaw export schema '{}'",
532 export.schema
533 ));
534 }
535 let packet = ArtifactPacket {
536 schema: ARTIFACT_PACKET_SCHEMA.to_string(),
537 packet_id: packet_id(SCIENCECLAW_ARTIFACT_V1, run_id, &export.run_id),
538 producer: export.producer,
539 topic: export.topic,
540 created_at: export.created_at,
541 artifacts: export.artifacts,
542 candidate_claims: export.candidate_claims,
543 open_needs: export.open_needs,
544 caveats: bridge_caveats(export.caveats),
545 };
546 Ok(NormalizedRuntimePacket {
547 packet: with_runtime_metadata(packet, SCIENCECLAW_ARTIFACT_V1, run_id),
548 review_signals: Vec::new(),
549 })
550}
551
552fn normalize_agent_discourse(
553 input: Value,
554 run_id: &str,
555) -> Result<NormalizedRuntimePacket, String> {
556 let export: AgentDiscourseExport =
557 serde_json::from_value(input).map_err(|e| format!("parse agent discourse export: {e}"))?;
558 if export.schema != "agent_discourse.v1" {
559 return Err(format!(
560 "unsupported agent discourse export schema '{}'",
561 export.schema
562 ));
563 }
564
565 let mut artifacts = Vec::new();
566 let mut candidate_claims = Vec::new();
567 let mut review_signals = Vec::new();
568
569 for post in &export.posts {
570 let mut metadata = BTreeMap::new();
571 metadata.insert("external_object_kind".to_string(), json!("post"));
572 metadata.insert("external_object_id".to_string(), json!(post.id));
573 metadata.insert("body".to_string(), json!(post.body));
574 if let Some(target) = &post.target_finding_id {
575 metadata.insert("target_findings".to_string(), json!([target]));
576 }
577 artifacts.push(PacketArtifact {
578 id: post.id.clone(),
579 kind: "model_output".to_string(),
580 title: post.title.clone(),
581 locator: post.locator.clone(),
582 content_hash: post.content_hash.clone(),
583 parents: Vec::new(),
584 metadata,
585 });
586 candidate_claims.push(PacketCandidateClaim {
587 id: format!("claim_{}", post.id),
588 assertion: post.assertion.clone(),
589 assertion_type: "therapeutic".to_string(),
590 evidence_artifact_ids: vec![post.id.clone()],
591 source_refs: source_refs_with_locator(&post.source_refs, &post.locator),
592 conditions: post.conditions.clone(),
593 confidence: post.confidence.unwrap_or(0.5),
594 caveats: vec![
595 "Agent discourse post is a candidate claim; reviewer acceptance required."
596 .to_string(),
597 ],
598 });
599 }
600
601 for comment in &export.comments {
602 let mut metadata = BTreeMap::new();
603 metadata.insert("external_object_kind".to_string(), json!("comment"));
604 metadata.insert("external_object_id".to_string(), json!(comment.id));
605 metadata.insert("body".to_string(), json!(comment.body));
606 if let Some(target) = &comment.target_finding_id {
607 metadata.insert("target_findings".to_string(), json!([target]));
608 review_signals.push(ReviewSignal {
609 external_id: comment.id.clone(),
610 parent_id: comment.post_id.clone(),
611 target_finding_id: target.clone(),
612 locator: comment.locator.clone(),
613 body: comment.body.clone(),
614 decision: None,
615 });
616 }
617 artifacts.push(PacketArtifact {
618 id: comment.id.clone(),
619 kind: "source_file".to_string(),
620 title: format!("Discourse comment {}", comment.id),
621 locator: comment.locator.clone(),
622 content_hash: comment.content_hash.clone(),
623 parents: vec![comment.post_id.clone()],
624 metadata,
625 });
626 }
627
628 for review in &export.reviews {
629 let mut metadata = BTreeMap::new();
630 metadata.insert("external_object_kind".to_string(), json!("review"));
631 metadata.insert("external_object_id".to_string(), json!(review.id));
632 metadata.insert("decision".to_string(), json!(review.decision));
633 metadata.insert("body".to_string(), json!(review.body));
634 if let Some(target) = &review.target_finding_id {
635 metadata.insert("target_findings".to_string(), json!([target]));
636 review_signals.push(ReviewSignal {
637 external_id: review.id.clone(),
638 parent_id: review.post_id.clone(),
639 target_finding_id: target.clone(),
640 locator: review.locator.clone(),
641 body: review.body.clone(),
642 decision: Some(review.decision.clone()),
643 });
644 }
645 artifacts.push(PacketArtifact {
646 id: review.id.clone(),
647 kind: "source_file".to_string(),
648 title: format!("Discourse review {}", review.id),
649 locator: review.locator.clone(),
650 content_hash: review.content_hash.clone(),
651 parents: vec![review.post_id.clone()],
652 metadata,
653 });
654 }
655
656 let packet = ArtifactPacket {
657 schema: ARTIFACT_PACKET_SCHEMA.to_string(),
658 packet_id: packet_id(AGENT_DISCOURSE_V1, run_id, &export.thread_id),
659 producer: PacketProducer {
660 kind: "agent".to_string(),
661 id: format!("agent:{}", export.runtime.id),
662 name: export.runtime.name,
663 },
664 topic: export.topic,
665 created_at: export.created_at,
666 artifacts,
667 candidate_claims,
668 open_needs: export.open_needs,
669 caveats: bridge_caveats(vec![
670 "Agent discourse is upstream review signal, not canonical truth.".to_string(),
671 ]),
672 };
673 Ok(NormalizedRuntimePacket {
674 packet: with_runtime_metadata(packet, AGENT_DISCOURSE_V1, run_id),
675 review_signals,
676 })
677}
678
679fn create_review_note_proposals(
680 frontier_path: &Path,
681 options: &RuntimeAdapterRunOptions,
682 run_id: &str,
683 packet_id: &str,
684 review_signals: &[ReviewSignal],
685) -> Result<Vec<String>, String> {
686 let mut ids = Vec::new();
687 for signal in review_signals {
688 let text = match &signal.decision {
689 Some(decision) => format!(
690 "External runtime review {} on {} recorded decision '{}': {}. Treat this as review signal until a Vela reviewer accepts a state transition.",
691 signal.external_id, signal.parent_id, decision, signal.body
692 ),
693 None => format!(
694 "External runtime comment {} on {}: {}. Treat this as review signal until a Vela reviewer accepts a state transition.",
695 signal.external_id, signal.parent_id, signal.body
696 ),
697 };
698 let mut proposal = proposals::new_proposal(
699 "finding.note",
700 StateTarget {
701 r#type: "finding".to_string(),
702 id: signal.target_finding_id.clone(),
703 },
704 options.actor.clone(),
705 actor_type(&options.actor),
706 format!(
707 "Import external runtime review signal {} from packet {}",
708 signal.external_id, packet_id
709 ),
710 json!({
711 "text": text,
712 "runtime_adapter": options.adapter,
713 "runtime_adapter_run_id": run_id,
714 "artifact_packet_id": packet_id,
715 "external_object_id": signal.external_id,
716 "parent_external_object_id": signal.parent_id,
717 "decision": signal.decision,
718 "locator": signal.locator,
719 }),
720 vec![
721 signal.locator.clone(),
722 format!("runtime_adapter_run:{run_id}"),
723 format!("runtime_packet:{packet_id}"),
724 ],
725 bridge_caveats(vec![
726 "External comments and reviews are not canonical attestations.".to_string(),
727 ]),
728 );
729 proposal.agent_run = Some(agent_run(&options.adapter, run_id, packet_id));
730 let result = proposals::create_or_apply(frontier_path, proposal, false)?;
731 ids.push(result.proposal_id);
732 }
733 Ok(ids)
734}
735
736fn update_import_agent_runs(
737 frontier_path: &Path,
738 proposal_ids: &[String],
739 adapter: &str,
740 run_id: &str,
741 packet_id: &str,
742 input_path: &Path,
743) -> Result<(), String> {
744 let mut frontier = repo::load_from_path(frontier_path)?;
745 for proposal in &mut frontier.proposals {
746 if proposal_ids.iter().any(|id| id == &proposal.id) {
747 let mut run = proposal
748 .agent_run
749 .clone()
750 .unwrap_or_else(|| agent_run(adapter, run_id, packet_id));
751 run.model = format!("runtime-adapter:{adapter}");
752 run.run_id = run_id.to_string();
753 run.context
754 .insert("runtime_adapter".to_string(), adapter.to_string());
755 run.context
756 .insert("runtime_adapter_run_id".to_string(), run_id.to_string());
757 run.context
758 .insert("artifact_packet_id".to_string(), packet_id.to_string());
759 run.context
760 .insert("input".to_string(), input_path.display().to_string());
761 proposal.agent_run = Some(run);
762 }
763 }
764 repo::save_to_path(frontier_path, &frontier)
765}
766
767fn with_runtime_metadata(
768 mut packet: ArtifactPacket,
769 adapter: &str,
770 run_id: &str,
771) -> ArtifactPacket {
772 for artifact in &mut packet.artifacts {
773 artifact
774 .metadata
775 .insert("runtime_adapter".to_string(), json!(adapter));
776 artifact
777 .metadata
778 .insert("runtime_adapter_run_id".to_string(), json!(run_id));
779 artifact
780 .metadata
781 .insert("external_runtime".to_string(), json!(packet.producer.name));
782 }
783 packet
784}
785
786fn bridge_caveats(mut caveats: Vec<String>) -> Vec<String> {
787 caveats
788 .push("External runtime output is source material until reviewer acceptance.".to_string());
789 caveats.push(
790 "External upvotes, comments, reviews, and agent confidence do not grant canonical authority."
791 .to_string(),
792 );
793 caveats.sort();
794 caveats.dedup();
795 caveats
796}
797
798fn source_refs_with_locator(source_refs: &[String], locator: &str) -> Vec<String> {
799 let mut refs = source_refs.to_vec();
800 refs.push(locator.to_string());
801 refs.sort();
802 refs.dedup();
803 refs
804}
805
806fn external_runtime_summary(packet: &ArtifactPacket) -> Value {
807 json!({
808 "producer": packet.producer,
809 "topic": packet.topic,
810 "artifact_count": packet.artifacts.len(),
811 "candidate_claim_count": packet.candidate_claims.len(),
812 "open_need_count": packet.open_needs.len(),
813 })
814}
815
816fn actor_type(actor: &str) -> String {
817 if actor.starts_with("agent:") {
818 "agent".to_string()
819 } else {
820 "human".to_string()
821 }
822}
823
824fn agent_run(adapter: &str, run_id: &str, packet_id: &str) -> AgentRun {
825 let mut context = BTreeMap::new();
826 context.insert("runtime_adapter".to_string(), adapter.to_string());
827 context.insert("runtime_adapter_run_id".to_string(), run_id.to_string());
828 context.insert("artifact_packet_id".to_string(), packet_id.to_string());
829 AgentRun {
830 agent: adapter.to_string(),
831 model: format!("runtime-adapter:{adapter}"),
832 run_id: run_id.to_string(),
833 started_at: Utc::now().to_rfc3339(),
834 finished_at: None,
835 context,
836 tool_calls: Vec::new(),
837 permissions: None,
838 }
839}
840
841fn runtime_runs_dir(frontier_path: &Path) -> Result<PathBuf, String> {
842 match repo::detect(frontier_path)? {
843 repo::VelaSource::VelaRepo(root) => Ok(root.join("ingest").join("runtime-runs")),
844 repo::VelaSource::ProjectFile(path) => path
845 .parent()
846 .map(|parent| parent.join("ingest").join("runtime-runs"))
847 .ok_or_else(|| format!("frontier file '{}' has no parent", path.display())),
848 repo::VelaSource::PacketDir(dir) => Ok(dir.join("ingest").join("runtime-runs")),
849 }
850}
851
852fn resolve_input_path(input: &Path) -> Result<PathBuf, String> {
853 if input.is_file() {
854 return Ok(input.to_path_buf());
855 }
856 if !input.is_dir() {
857 return Err(format!(
858 "runtime adapter input '{}' not found",
859 input.display()
860 ));
861 }
862 let default = input.join("runtime-export.json");
863 if default.is_file() {
864 return Ok(default);
865 }
866 let mut candidates = fs::read_dir(input)
867 .map_err(|e| format!("read runtime adapter input dir '{}': {e}", input.display()))?
868 .flatten()
869 .map(|entry| entry.path())
870 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
871 .collect::<Vec<_>>();
872 candidates.sort();
873 candidates.into_iter().next().ok_or_else(|| {
874 format!(
875 "runtime adapter input dir '{}' has no JSON files",
876 input.display()
877 )
878 })
879}
880
881fn read_json(path: &Path) -> Result<Value, String> {
882 let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
883 serde_json::from_slice(&bytes).map_err(|e| format!("parse {}: {e}", path.display()))
884}
885
886fn run_id(adapter: &str, value: &Value) -> String {
887 let bytes = canonical::to_canonical_bytes(&json!({
888 "adapter": adapter,
889 "input": value,
890 }))
891 .unwrap_or_default();
892 format!("rir_{}", &hex::encode(Sha256::digest(bytes))[..16])
893}
894
895fn packet_id(adapter: &str, run_id: &str, external_id: &str) -> String {
896 let bytes = canonical::to_canonical_bytes(&json!({
897 "adapter": adapter,
898 "run_id": run_id,
899 "external_id": external_id,
900 }))
901 .unwrap_or_default();
902 format!("cap_{}", &hex::encode(Sha256::digest(bytes))[..16])
903}