1use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26use std::time::Duration;
27
28use ta_changeset::DraftPackage;
29use ta_goal::GoalRun;
30
31use crate::adapter::{
32 CommitResult, MergeResult, PushResult, Result, ReviewResult, ReviewStatus, SavedVcsState,
33 SourceAdapter, SubmitError, SyncResult,
34};
35use crate::config::SubmitConfig;
36use crate::vcs_plugin_manifest::VcsPluginManifest;
37use crate::vcs_plugin_protocol::{
38 CheckReviewParams, CheckReviewResult, CommitParams, CommitResult as PluginCommitResult,
39 DetectParams, DetectResult, ExcludePatternsResult, HandshakeParams, HandshakeResult,
40 MergeReviewParams, MergeReviewResult, OpenReviewParams, OpenReviewResult, PrepareParams,
41 ProtectedTargetsResult, PushParams, PushResult as PluginPushResult, RestoreStateParams,
42 SaveStateResult, SyncUpstreamResult, VcsPluginRequest, VcsPluginResponse, PROTOCOL_VERSION,
43};
44
45struct ExternalSavedState {
47 state_json: serde_json::Value,
49}
50
51#[derive(Debug)]
53pub struct ExternalVcsAdapter {
54 command: String,
56 args: Vec<String>,
57 work_dir: PathBuf,
59 adapter_name: String,
61 #[allow(dead_code)]
63 plugin_version: String,
64 timeout: Duration,
66 capabilities: Vec<String>,
68 staging_env: std::collections::HashMap<String, String>,
70}
71
72impl ExternalVcsAdapter {
73 pub fn new(
78 manifest: &VcsPluginManifest,
79 work_dir: impl Into<PathBuf>,
80 ta_version: &str,
81 ) -> Result<Self> {
82 let work_dir = work_dir.into();
83 let timeout = Duration::from_secs(manifest.timeout_secs);
84
85 let handshake_params = HandshakeParams {
86 ta_version: ta_version.to_string(),
87 protocol_version: PROTOCOL_VERSION,
88 };
89 let request = VcsPluginRequest {
90 method: "handshake".to_string(),
91 params: serde_json::to_value(&handshake_params).map_err(|e| {
92 SubmitError::ConfigError(format!("Failed to serialize handshake params: {}", e))
93 })?,
94 };
95
96 let response = call_plugin(
97 &manifest.command,
98 &manifest.args,
99 &work_dir,
100 &request,
101 timeout,
102 )?;
103
104 if !response.ok {
105 return Err(SubmitError::ConfigError(format!(
106 "VCS plugin '{}' handshake failed: {}",
107 manifest.name,
108 response.error.as_deref().unwrap_or("unknown error")
109 )));
110 }
111
112 let result: HandshakeResult = serde_json::from_value(response.result).map_err(|e| {
113 SubmitError::ConfigError(format!(
114 "VCS plugin '{}' returned invalid handshake response: {}",
115 manifest.name, e
116 ))
117 })?;
118
119 if result.protocol_version != PROTOCOL_VERSION {
120 return Err(SubmitError::ConfigError(format!(
121 "VCS plugin '{}' uses protocol version {} but TA requires version {}. \
122 Upgrade the plugin or downgrade TA.",
123 manifest.name, result.protocol_version, PROTOCOL_VERSION
124 )));
125 }
126
127 tracing::info!(
128 plugin = %manifest.name,
129 plugin_version = %result.plugin_version,
130 adapter = %result.adapter_name,
131 "VCS plugin handshake successful"
132 );
133
134 Ok(Self {
135 command: manifest.command.clone(),
136 args: manifest.args.clone(),
137 work_dir,
138 adapter_name: result.adapter_name,
139 plugin_version: result.plugin_version,
140 timeout,
141 capabilities: result.capabilities,
142 staging_env: manifest.staging_env.clone(),
143 })
144 }
145
146 pub fn detect_with_plugin(
148 manifest: &VcsPluginManifest,
149 project_root: &Path,
150 ta_version: &str,
151 ) -> bool {
152 let timeout = Duration::from_secs(manifest.timeout_secs);
153 let params = DetectParams {
154 project_root: project_root.display().to_string(),
155 };
156 let request = VcsPluginRequest {
157 method: "detect".to_string(),
158 params: match serde_json::to_value(¶ms) {
159 Ok(v) => v,
160 Err(_) => return false,
161 },
162 };
163
164 let handshake_req = VcsPluginRequest {
166 method: "handshake".to_string(),
167 params: serde_json::json!({
168 "ta_version": ta_version,
169 "protocol_version": PROTOCOL_VERSION
170 }),
171 };
172 if call_plugin(
173 &manifest.command,
174 &manifest.args,
175 project_root,
176 &handshake_req,
177 timeout,
178 )
179 .map(|r| r.ok)
180 .unwrap_or(false)
181 {
182 match call_plugin(
184 &manifest.command,
185 &manifest.args,
186 project_root,
187 &request,
188 timeout,
189 ) {
190 Ok(resp) if resp.ok => serde_json::from_value::<DetectResult>(resp.result)
191 .map(|r| r.detected)
192 .unwrap_or(false),
193 _ => false,
194 }
195 } else {
196 false
197 }
198 }
199
200 fn call<T: serde::de::DeserializeOwned>(
202 &self,
203 method: &str,
204 params: serde_json::Value,
205 ) -> Result<T> {
206 let request = VcsPluginRequest {
207 method: method.to_string(),
208 params,
209 };
210 let response = call_plugin(
211 &self.command,
212 &self.args,
213 &self.work_dir,
214 &request,
215 self.timeout,
216 )?;
217
218 if !response.ok {
219 return Err(SubmitError::VcsError(format!(
220 "VCS plugin '{}' method '{}' failed: {}",
221 self.adapter_name,
222 method,
223 response.error.as_deref().unwrap_or("unknown error")
224 )));
225 }
226
227 serde_json::from_value(response.result).map_err(|e| {
228 SubmitError::VcsError(format!(
229 "VCS plugin '{}' method '{}' returned invalid response: {}",
230 self.adapter_name, method, e
231 ))
232 })
233 }
234
235 fn has_capability(&self, cap: &str) -> bool {
237 self.capabilities.iter().any(|c| c == cap)
238 }
239}
240
241impl SourceAdapter for ExternalVcsAdapter {
242 fn prepare(&self, goal: &GoalRun, config: &SubmitConfig) -> Result<()> {
243 let params = PrepareParams {
244 goal_id: goal.goal_run_id.to_string(),
245 goal_title: goal.title.clone(),
246 workspace_path: goal.workspace_path.display().to_string(),
247 branch_prefix: config.git.branch_prefix.clone(),
248 co_author: if config.co_author.is_empty() {
249 None
250 } else {
251 Some(config.co_author.clone())
252 },
253 };
254 self.call::<serde_json::Value>("prepare", serde_json::to_value(¶ms).unwrap())?;
255 Ok(())
256 }
257
258 fn commit(&self, goal: &GoalRun, pr: &DraftPackage, message: &str) -> Result<CommitResult> {
259 let changed_files: Vec<String> = pr
260 .changes
261 .artifacts
262 .iter()
263 .map(|a| {
264 a.resource_uri
265 .trim_start_matches("fs://workspace/")
266 .to_string()
267 })
268 .collect();
269
270 let params = CommitParams {
271 goal_id: goal.goal_run_id.to_string(),
272 goal_title: goal.title.clone(),
273 message: message.to_string(),
274 changed_files,
275 };
276
277 let result: PluginCommitResult =
278 self.call("commit", serde_json::to_value(¶ms).unwrap())?;
279
280 Ok(CommitResult {
281 commit_id: result.commit_id,
282 message: result.message,
283 metadata: result.metadata,
284 ignored_artifacts: vec![],
285 })
286 }
287
288 fn push(&self, goal: &GoalRun) -> Result<PushResult> {
289 let params = PushParams {
290 goal_id: goal.goal_run_id.to_string(),
291 };
292 let result: PluginPushResult = self.call("push", serde_json::to_value(¶ms).unwrap())?;
293
294 Ok(PushResult {
295 remote_ref: result.remote_ref,
296 message: result.message,
297 metadata: result.metadata,
298 })
299 }
300
301 fn open_review(&self, goal: &GoalRun, pr: &DraftPackage) -> Result<ReviewResult> {
302 let changed_files: Vec<String> = pr
303 .changes
304 .artifacts
305 .iter()
306 .map(|a| {
307 a.resource_uri
308 .trim_start_matches("fs://workspace/")
309 .to_string()
310 })
311 .collect();
312
313 let draft_summary = format!("{}\n{}", pr.summary.what_changed, pr.summary.why);
314
315 let params = OpenReviewParams {
316 goal_id: goal.goal_run_id.to_string(),
317 goal_title: goal.title.clone(),
318 draft_summary,
319 changed_files,
320 };
321 let result: OpenReviewResult =
322 self.call("open_review", serde_json::to_value(¶ms).unwrap())?;
323
324 Ok(ReviewResult {
325 review_url: result.review_url,
326 review_id: result.review_id,
327 message: result.message,
328 metadata: result.metadata,
329 })
330 }
331
332 fn sync_upstream(&self) -> Result<SyncResult> {
333 let result: SyncUpstreamResult = self.call(
334 "sync_upstream",
335 serde_json::Value::Object(Default::default()),
336 )?;
337
338 Ok(SyncResult {
339 updated: result.updated,
340 conflicts: result.conflicts,
341 new_commits: result.new_commits,
342 message: result.message,
343 metadata: result.metadata,
344 })
345 }
346
347 fn name(&self) -> &str {
348 &self.adapter_name
349 }
350
351 fn exclude_patterns(&self) -> Vec<String> {
352 self.call::<ExcludePatternsResult>(
353 "exclude_patterns",
354 serde_json::Value::Object(Default::default()),
355 )
356 .map(|r| r.patterns)
357 .unwrap_or_else(|e| {
358 tracing::warn!(
359 adapter = %self.adapter_name,
360 error = %e,
361 "VCS plugin exclude_patterns failed — using empty list"
362 );
363 vec![]
364 })
365 }
366
367 fn save_state(&self) -> Result<Option<SavedVcsState>> {
368 let result: SaveStateResult =
369 self.call("save_state", serde_json::Value::Object(Default::default()))?;
370
371 if result.state.is_null() {
372 return Ok(None);
373 }
374
375 Ok(Some(SavedVcsState {
376 adapter: self.adapter_name.clone(),
377 data: Box::new(ExternalSavedState {
378 state_json: result.state,
379 }),
380 }))
381 }
382
383 fn restore_state(&self, state: Option<SavedVcsState>) -> Result<()> {
384 let state_json = match state {
385 None => serde_json::Value::Null,
386 Some(s) => {
387 if s.adapter != self.adapter_name {
388 return Err(SubmitError::InvalidState(format!(
389 "Cannot restore state from adapter '{}' in ExternalVcsAdapter for '{}'",
390 s.adapter, self.adapter_name
391 )));
392 }
393 match s.data.downcast::<ExternalSavedState>() {
394 Ok(ext) => ext.state_json,
395 Err(_) => {
396 return Err(SubmitError::InvalidState(
397 "State data is not ExternalSavedState".to_string(),
398 ));
399 }
400 }
401 }
402 };
403
404 let params = RestoreStateParams { state: state_json };
405 self.call::<serde_json::Value>("restore_state", serde_json::to_value(¶ms).unwrap())?;
406 Ok(())
407 }
408
409 fn revision_id(&self) -> Result<String> {
410 let result: crate::vcs_plugin_protocol::RevisionIdResult =
411 self.call("revision_id", serde_json::Value::Object(Default::default()))?;
412 Ok(result.revision_id)
413 }
414
415 fn check_review(&self, review_id: &str) -> Result<Option<ReviewStatus>> {
416 let params = CheckReviewParams {
417 review_id: review_id.to_string(),
418 };
419 let result: CheckReviewResult =
420 self.call("check_review", serde_json::to_value(¶ms).unwrap())?;
421
422 if !result.found {
423 return Ok(None);
424 }
425
426 Ok(Some(ReviewStatus {
427 state: result.state,
428 checks_passing: result.checks_passing,
429 }))
430 }
431
432 fn merge_review(&self, review_id: &str) -> Result<MergeResult> {
433 let params = MergeReviewParams {
434 review_id: review_id.to_string(),
435 };
436 let result: MergeReviewResult =
437 self.call("merge_review", serde_json::to_value(¶ms).unwrap())?;
438
439 Ok(MergeResult {
440 merged: result.merged,
441 merge_commit: result.merge_commit,
442 message: result.message,
443 metadata: result.metadata,
444 })
445 }
446
447 fn protected_submit_targets(&self) -> Vec<String> {
448 if !self.has_capability("protected_targets") {
449 return vec![];
450 }
451 self.call::<ProtectedTargetsResult>(
452 "protected_targets",
453 serde_json::Value::Object(Default::default()),
454 )
455 .map(|r| r.targets)
456 .unwrap_or_else(|e| {
457 tracing::warn!(
458 adapter = %self.adapter_name,
459 error = %e,
460 "VCS plugin protected_targets failed — returning empty list"
461 );
462 vec![]
463 })
464 }
465
466 fn verify_not_on_protected_target(&self) -> Result<()> {
467 if !self.has_capability("protected_targets") {
468 tracing::debug!(
470 adapter = %self.adapter_name,
471 "VCS plugin does not declare 'protected_targets' capability; \
472 skipping §15 verify_target check"
473 );
474 return Ok(());
475 }
476
477 let response = {
478 let request = VcsPluginRequest {
479 method: "verify_target".to_string(),
480 params: serde_json::Value::Object(Default::default()),
481 };
482 call_plugin(
483 &self.command,
484 &self.args,
485 &self.work_dir,
486 &request,
487 self.timeout,
488 )?
489 };
490
491 if response.ok {
492 Ok(())
493 } else {
494 Err(SubmitError::InvalidState(response.error.unwrap_or_else(
495 || "VCS plugin verify_target returned ok=false".to_string(),
496 )))
497 }
498 }
499
500 fn stage_env(
501 &self,
502 _staging_dir: &Path,
503 _config: &crate::config::VcsAgentConfig,
504 ) -> Result<std::collections::HashMap<String, String>> {
505 Ok(self.staging_env.clone())
507 }
508}
509
510fn call_plugin(
519 command: &str,
520 extra_args: &[String],
521 work_dir: &Path,
522 request: &VcsPluginRequest,
523 timeout: Duration,
524) -> Result<VcsPluginResponse> {
525 let request_json = serde_json::to_string(request).map_err(|e| {
526 SubmitError::VcsError(format!(
527 "Failed to serialize VCS plugin request for method '{}': {}",
528 request.method, e
529 ))
530 })?;
531
532 let mut parts = command.split_whitespace();
534 let program = parts.next().ok_or_else(|| {
535 SubmitError::ConfigError(format!(
536 "VCS plugin command is empty for method '{}'",
537 request.method
538 ))
539 })?;
540
541 let mut cmd = Command::new(program);
542 for arg in parts {
543 cmd.arg(arg);
544 }
545 for arg in extra_args {
546 cmd.arg(arg);
547 }
548
549 cmd.current_dir(work_dir)
550 .stdin(Stdio::piped())
551 .stdout(Stdio::piped())
552 .stderr(Stdio::piped());
553
554 let mut child = {
559 const ETXTBSY: i32 = 26;
560 let mut last_err: Option<std::io::Error> = None;
561 let mut spawned = None;
562 for delay_ms in [0u64, 20, 80, 200] {
563 if delay_ms > 0 {
564 std::thread::sleep(std::time::Duration::from_millis(delay_ms));
565 }
566 match cmd.spawn() {
567 Ok(c) => {
568 spawned = Some(c);
569 break;
570 }
571 Err(e) if e.raw_os_error() == Some(ETXTBSY) => {
572 last_err = Some(e);
573 }
574 Err(e) => {
575 return Err(SubmitError::VcsError(format!(
576 "Failed to spawn VCS plugin '{}' for method '{}': {}. \
577 Ensure the plugin is installed and on PATH.",
578 command, request.method, e
579 )));
580 }
581 }
582 }
583 spawned.ok_or_else(|| {
584 let e = last_err.unwrap();
585 SubmitError::VcsError(format!(
586 "Failed to spawn VCS plugin '{}' for method '{}': {}. \
587 Ensure the plugin is installed and on PATH.",
588 command, request.method, e
589 ))
590 })?
591 };
592
593 if let Some(mut stdin) = child.stdin.take() {
595 stdin
596 .write_all(request_json.as_bytes())
597 .and_then(|_| stdin.write_all(b"\n"))
598 .map_err(|e| {
599 SubmitError::VcsError(format!(
600 "Failed to write to VCS plugin '{}' stdin: {}",
601 command, e
602 ))
603 })?;
604 }
605
606 let timeout_millis = timeout.as_millis() as u64;
610 let output = wait_with_timeout(child, timeout_millis).map_err(|e| {
611 SubmitError::VcsError(format!(
612 "VCS plugin '{}' timed out or failed for method '{}': {}. \
613 Increase timeout_secs in plugin.toml.",
614 command, request.method, e
615 ))
616 })?;
617
618 if !output.status.success() {
619 let stderr = String::from_utf8_lossy(&output.stderr);
620 return Err(SubmitError::VcsError(format!(
621 "VCS plugin '{}' exited with status {} for method '{}'. stderr: {}",
622 command,
623 output.status,
624 request.method,
625 stderr.trim()
626 )));
627 }
628
629 let stdout = String::from_utf8_lossy(&output.stdout);
630 let first_line = stdout.lines().next().unwrap_or("").trim();
631
632 if first_line.is_empty() {
633 return Err(SubmitError::VcsError(format!(
634 "VCS plugin '{}' produced no output for method '{}'. \
635 Plugin must write one JSON line to stdout.",
636 command, request.method
637 )));
638 }
639
640 serde_json::from_str(first_line).map_err(|e| {
641 SubmitError::VcsError(format!(
642 "VCS plugin '{}' produced invalid JSON for method '{}': {}. Got: '{}'",
643 command,
644 request.method,
645 e,
646 if first_line.len() > 200 {
647 &first_line[..200]
648 } else {
649 first_line
650 }
651 ))
652 })
653}
654
655fn wait_with_timeout(
661 child: std::process::Child,
662 timeout_ms: u64,
663) -> std::result::Result<std::process::Output, String> {
664 use std::sync::mpsc;
665
666 let child_id = child.id();
667 let (tx, rx) = mpsc::channel::<()>();
668
669 let watchdog = std::thread::spawn(move || {
671 match rx.recv_timeout(Duration::from_millis(timeout_ms)) {
672 Ok(()) => {
673 }
675 Err(_) => {
676 #[cfg(unix)]
678 unsafe {
679 libc::kill(child_id as libc::pid_t, libc::SIGKILL);
680 }
681 #[cfg(not(unix))]
682 {
683 let _ = child_id;
684 }
685 }
686 }
687 });
688
689 let output = child
690 .wait_with_output()
691 .map_err(|e| format!("wait_with_output failed: {}", e))?;
692
693 let _ = tx.send(());
695 let _ = watchdog.join();
696
697 Ok(output)
698}
699
700#[cfg(all(test, unix))]
705mod tests {
706 use super::*;
707 use std::os::unix::fs::PermissionsExt;
708
709 fn write_mock_plugin(_dir: &Path, script: &str) -> PathBuf {
717 use std::sync::atomic::{AtomicU32, Ordering};
718 static COUNTER: AtomicU32 = AtomicU32::new(0);
719 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
720 let pid = std::process::id();
721 let name = format!("ta-submit-mock-{}-{}", pid, n);
722 #[cfg(target_os = "linux")]
724 let path = std::path::PathBuf::from("/tmp").join(&name);
725 #[cfg(not(target_os = "linux"))]
726 let path = _dir.join(&name);
727 {
728 use std::io::Write;
729 let mut f = std::fs::File::create(&path).unwrap();
730 f.write_all(script.as_bytes()).unwrap();
731 f.sync_all().unwrap();
732 }
733 let mut perms = std::fs::metadata(&path).unwrap().permissions();
734 perms.set_mode(0o755);
735 std::fs::set_permissions(&path, perms).unwrap();
736 let _ = std::fs::metadata(&path).unwrap();
738 path
739 }
740
741 fn mock_manifest(command: &str, _dir: &Path) -> VcsPluginManifest {
742 VcsPluginManifest {
743 name: "mock".to_string(),
744 version: "0.1.0".to_string(),
745 plugin_type: "vcs".to_string(),
746 command: command.to_string(),
747 args: vec![],
748 capabilities: vec!["commit".to_string(), "protected_targets".to_string()],
749 description: None,
750 timeout_secs: 30,
751 min_daemon_version: None,
752 source_url: None,
753 staging_env: std::collections::HashMap::new(),
754 }
755 }
756
757 #[test]
758 fn call_plugin_with_echo_script() {
759 let dir = tempfile::tempdir().unwrap();
760
761 let plugin_path = write_mock_plugin(
763 dir.path(),
764 r#"#!/bin/sh
765read -r line
766echo '{"ok":true,"result":{"plugin_version":"0.1.0","protocol_version":1,"adapter_name":"mock","capabilities":["commit","protected_targets"]}}'
767"#,
768 );
769
770 let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
771
772 let adapter = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap();
773 assert_eq!(adapter.name(), "mock");
774 assert_eq!(adapter.plugin_version, "0.1.0");
775 }
776
777 #[test]
778 fn handshake_protocol_mismatch_returns_error() {
779 let dir = tempfile::tempdir().unwrap();
780
781 let plugin_path = write_mock_plugin(
783 dir.path(),
784 r#"#!/bin/sh
785read -r line
786echo '{"ok":true,"result":{"plugin_version":"0.1.0","protocol_version":99,"adapter_name":"bad","capabilities":[]}}'
787"#,
788 );
789
790 let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
791
792 let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
793 assert!(
794 err.to_string().contains("protocol version"),
795 "Expected protocol version error, got: {}",
796 err
797 );
798 }
799
800 #[test]
801 fn handshake_error_response_returns_error() {
802 let dir = tempfile::tempdir().unwrap();
803
804 let plugin_path = write_mock_plugin(
805 dir.path(),
806 r#"#!/bin/sh
807read -r line
808echo '{"ok":false,"error":"plugin initialization failed"}'
809"#,
810 );
811
812 let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
813
814 let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
815 let msg = err.to_string();
816 assert!(
817 msg.contains("handshake failed") || msg.contains("timed out") || msg.contains("error"),
818 "Expected handshake failure, got: {}",
819 msg
820 );
821 }
822
823 #[test]
824 fn missing_command_returns_error() {
825 let dir = tempfile::tempdir().unwrap();
826 let manifest = mock_manifest("ta-submit-nonexistent-binary-xyz", dir.path());
827
828 let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
829 assert!(
830 err.to_string().contains("spawn") || err.to_string().contains("No such file"),
831 "Expected spawn error, got: {}",
832 err
833 );
834 }
835
836 #[test]
837 fn plugin_non_zero_exit_returns_error() {
838 let dir = tempfile::tempdir().unwrap();
839
840 let plugin_path = write_mock_plugin(
841 dir.path(),
842 r#"#!/bin/sh
843read -r line
844echo "some error to stderr" >&2
845exit 1
846"#,
847 );
848
849 let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
850
851 let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
852 assert!(
853 err.to_string().contains("exited with status"),
854 "Got: {}",
855 err
856 );
857 }
858
859 #[test]
860 fn plugin_invalid_json_output_returns_error() {
861 let dir = tempfile::tempdir().unwrap();
862
863 let plugin_path = write_mock_plugin(
864 dir.path(),
865 r#"#!/bin/sh
866read -r line
867echo "this is not json"
868"#,
869 );
870
871 let manifest = mock_manifest(&plugin_path.display().to_string(), dir.path());
872
873 let err = ExternalVcsAdapter::new(&manifest, dir.path(), "0.13.5-alpha").unwrap_err();
874 assert!(err.to_string().contains("invalid JSON"), "Got: {}", err);
875 }
876}